mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
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:
committed by
GitHub
parent
b754e44e26
commit
41877978e0
1
changelog.d/fixes/10527-deepseek-web-context-amnesia.md
Normal file
1
changelog.d/fixes/10527-deepseek-web-context-amnesia.md
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
60
tests/unit/deepseek-web-issue-10527-repro.test.ts
Normal file
60
tests/unit/deepseek-web-issue-10527-repro.test.ts
Normal 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?");
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user