fix: auto-replay bounded trajectory in DeepSeek Web prompt builder for non-tool-calling agentic clients (#10527) (#10741)

Co-authored-by: Markus Hartung <mail@hartmark.se>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-19 11:08:07 -03:00
committed by GitHub
parent b754e44e26
commit 41877978e0
4 changed files with 117 additions and 17 deletions

View File

@@ -0,0 +1 @@
- fix(sse): auto-replay a bounded multi-turn trajectory in the DeepSeek Web prompt builder for clients that never send `tools[]`, so agentic clients like Cline stop losing the original task after a couple of turns (#10527)

View File

@@ -498,17 +498,31 @@ function extractMessageText(content: unknown): string {
return String(content || "");
}
// #10527 — with no explicit `historyWindow`, genuinely multi-turn conversations (any
// assistant turn present, or more than one user turn) now auto-replay a bounded
// trajectory instead of only the last user message, so agentic clients that never send
// OpenAI-native `tools[]` (e.g. Cline, which embeds its own XML tool convention) don't
// silently lose the original task after a couple of tool-result turns. This cap keeps
// the auto-replay bounded for very long agent sessions; set `historyWindow` explicitly
// on the connection to raise or lower it.
const DEFAULT_AUTO_HISTORY_WINDOW = 20;
/**
* Build the single prompt string the DeepSeek web API accepts.
*
* The web endpoint (`/api/v0/chat/completion`) takes only a `prompt` string, not a
* `messages` array. With `historyWindow <= 0` (default) we keep the legacy behavior
* system prompt(s) + the last user message only — which is fine for plain chat.
* `messages` array. For a genuinely single-turn request (one user message, no prior
* assistant turns) we keep the minimal behavior — system prompt(s) + the last user
* message only — which is fine for plain chat and avoids inflating token usage.
*
* With `historyWindow > 0` we stitch the last N non-system messages into a role-tagged
* transcript so agentic multi-turn clients keep context across turns (rolling-window
* memory, #2942). The system prompt(s) still lead the prompt and the newest user turn
* is the last line of the transcript.
* For a multi-turn conversation, `historyWindow > 0` stitches the last N non-system
* messages into a role-tagged transcript so agentic multi-turn clients keep context
* across turns (rolling-window memory, #2942). With `historyWindow` unset/`<= 0` we now
* auto-apply a bounded window (`DEFAULT_AUTO_HISTORY_WINDOW`) instead of dropping every
* earlier turn (#10527) — the previous default silently discarded the original task
* after a couple of turns for clients (Cline) that never send `tools[]`. The system
* prompt(s) still lead the prompt and the newest user turn is the last line of the
* transcript.
*/
export function messagesToPrompt(
messages: Array<{ role: string; content: string; tool_call_id?: string; name?: string }>,
@@ -551,9 +565,18 @@ export function messagesToPrompt(
parts.push(systemParts.join("\n\n"));
}
if (historyWindow > 0 && conversation.length > 1) {
// Rolling-window transcript of the most recent turns (#2942).
const recent = conversation.slice(-historyWindow);
const effectiveWindow =
historyWindow > 0
? historyWindow
: conversation.length > 1
? DEFAULT_AUTO_HISTORY_WINDOW
: 0;
if (effectiveWindow > 0 && conversation.length > 1) {
// Rolling-window transcript of the most recent turns (#2942, auto-applied per
// #10527 when no explicit historyWindow is configured and the conversation is
// genuinely multi-turn).
const recent = conversation.slice(-effectiveWindow);
const transcript = recent
.map((turn) =>
turn.role === "assistant"

View File

@@ -0,0 +1,60 @@
// Regression test for issue #10527 — "Deepseek web just never does anything" (Cline).
//
// DeepSeek Web has no native messages[]/tool-calling API, so OmniRoute flattens the chat
// history into a single `prompt` string via messagesToPrompt(). Cline (and most
// XML-tool-convention agentic clients) never sends OpenAI-native `tools[]`, so it always
// takes this non-tool path. With the old default (historyWindow<=0 -> system + last user
// message only), the original task instruction was silently dropped after a couple of
// tool-result turns, causing the model to lose the task and loop asking "what do you want
// me to do?". The fix auto-applies a bounded rolling window for genuinely multi-turn
// conversations even when historyWindow is unset.
import test from "node:test";
import assert from "node:assert/strict";
const { messagesToPrompt } = await import("../../open-sse/executors/deepseek-web.ts");
test("#10527: default (no tools[], no historyWindow) keeps the original task after a few Cline-style turns", () => {
const clineStyleConversation = [
{ role: "system", content: "You are Cline, an autonomous coding agent. Use the available tools." },
{ role: "user", content: "Add input validation to the /api/v1/signup route and write a test for it." },
{
role: "assistant",
content:
"I'll look at the signup route first.\n<read_file><path>src/app/api/v1/signup/route.ts</path></read_file>",
},
{
role: "user",
content:
"[read_file for 'src/app/api/v1/signup/route.ts'] Result:\nexport async function POST(req) { /* ...200 lines... */ }",
},
{
role: "assistant",
content:
"Now let me check the existing validation schema.\n<read_file><path>src/lib/validation/signup.ts</path></read_file>",
},
{
role: "user",
content:
"[read_file for 'src/lib/validation/signup.ts'] Result:\nexport const signupSchema = z.object({ /* ... */ });",
},
];
const upstreamPrompt = messagesToPrompt(clineStyleConversation, 0 /* default historyWindow */);
assert.ok(
upstreamPrompt.includes("Add input validation to the /api/v1/signup route"),
"#10527: the original task instruction must survive in the upstream prompt " +
"even after a couple of Cline-style tool-result turns.\n\n" +
`Actual upstream prompt sent to chat.deepseek.com:\n${upstreamPrompt}`
);
});
test("#10527: genuinely single-turn request keeps the minimal system + last-user-only prompt", () => {
const singleTurn = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "What is 2+2?" },
];
const upstreamPrompt = messagesToPrompt(singleTurn, 0);
assert.equal(upstreamPrompt, "You are helpful.\n\nWhat is 2+2?");
});

View File

@@ -1,7 +1,14 @@
// #2942 — rolling-window prompt memory for deepseek-web. The web API takes only a single
// `prompt` string, so multi-turn context must be stitched into that prompt. With the
// window disabled (default) the legacy behavior (system + last user only) is preserved;
// with a window > 0, the last N turns are stitched into a role-tagged transcript.
// `prompt` string, so multi-turn context must be stitched into that prompt. With an
// explicit `historyWindow > 0`, the last N turns are stitched into a role-tagged
// transcript.
//
// #10527 — with the window unset/<=0 (default), a genuinely multi-turn conversation
// (any assistant turn present, or more than one user turn) now auto-replays a bounded
// trajectory instead of the old "system + last user only" behavior, which silently
// dropped the original task for agentic clients (Cline) that never send OpenAI-native
// `tools[]`. Only a genuinely single-turn request (one user message, no assistant turns)
// keeps the minimal "system + last user only" prompt.
import test from "node:test";
import assert from "node:assert/strict";
@@ -14,18 +21,27 @@ const CONVO = [
{ role: "user", content: "second question" },
];
test("window 0 (default) keeps legacy behavior: system + last user only", () => {
test("window 0 (default) on a multi-turn conversation auto-replays the bounded trajectory (#10527)", () => {
const prompt = messagesToPrompt(CONVO, 0);
assert.ok(prompt.includes("You are helpful."), "system prompt present");
assert.ok(prompt.includes("second question"), "last user message present");
assert.ok(!prompt.includes("first question"), "earlier user turn must be dropped");
assert.ok(!prompt.includes("first answer"), "assistant turn must be dropped");
assert.ok(prompt.includes("first question"), "earlier user turn must survive (#10527)");
assert.ok(prompt.includes("first answer"), "assistant turn must survive (#10527)");
});
test("default call (no window arg) behaves like window 0", () => {
test("default call (no window arg) on a multi-turn conversation behaves like window 0 (#10527)", () => {
const prompt = messagesToPrompt(CONVO);
assert.ok(prompt.includes("second question"));
assert.ok(!prompt.includes("first answer"));
assert.ok(prompt.includes("first answer"), "assistant turn must survive (#10527)");
});
test("window 0 (default) on a genuinely single-turn request keeps the minimal system + last-user-only prompt", () => {
const singleTurn = [
{ role: "system", content: "You are helpful." },
{ role: "user", content: "second question" },
];
const prompt = messagesToPrompt(singleTurn, 0);
assert.equal(prompt, "You are helpful.\n\nsecond question");
});
test("window > 0 stitches recent turns into a role-tagged transcript", () => {