diff --git a/CHANGELOG.md b/CHANGELOG.md index 29f6c75688..322f0ec9ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ _In development — bullets added per PR; finalized at release._ - **db-backups**: make the database-import size cap configurable via `OMNIROUTE_DB_IMPORT_MAX_MB` (default 100 MB, 4 GB ceiling) so backups larger than 100 MB can be restored; error message now points to the env var and to VACUUM (#4719). - **Onboarding**: add the missing `onboarding.tiers` step-title translation so the setup wizard no longer crashes with `MISSING_MESSAGE: onboarding.tiers` (#4698). +- **deepseek-web**: fold `role:"tool"` results into the single-prompt transcript (`messagesToPrompt`) so tool outputs reach the model instead of being silently dropped when a follow-up turn omits the `tools[]` array (#4712). --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 2e31708636..02ed1822c6 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -114,8 +114,9 @@ "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, "open-sse/executors/cursor.ts": 1453, - "open-sse/executors/deepseek-web.ts": 1125, + "open-sse/executors/deepseek-web.ts": 1148, "_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.", + "_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.", "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1871, "open-sse/executors/muse-spark-web.ts": 1284, diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts index 4e2546dce1..c9b81ddad7 100644 --- a/open-sse/executors/deepseek-web.ts +++ b/open-sse/executors/deepseek-web.ts @@ -511,13 +511,14 @@ function extractMessageText(content: unknown): string { * is the last line of the transcript. */ export function messagesToPrompt( - messages: Array<{ role: string; content: string }>, + messages: Array<{ role: string; content: string; tool_call_id?: string; name?: string }>, historyWindow = 0 ): string { if (messages.length === 0) return ""; const systemParts: string[] = []; const conversation: Array<{ role: string; text: string }> = []; + const callNameById = new Map(); let lastUserContent = ""; for (const m of messages) { const text = extractMessageText(m.content).trim(); @@ -526,6 +527,22 @@ export function messagesToPrompt( } else if (m.role === "user" || m.role === "assistant") { if (text) conversation.push({ role: m.role, text }); if (m.role === "user") lastUserContent = text; + const calls = Array.isArray((m as { tool_calls?: unknown }).tool_calls) + ? (m as { tool_calls: Array<{ id?: string; function?: { name?: string } }> }).tool_calls + : []; + for (const c of calls) { + if (c?.id && typeof c.function?.name === "string") callNameById.set(c.id, c.function.name); + } + } else if (m.role === "tool") { + // Tool results have no native slot in deepseek-web's single-prompt format. Without + // this branch they were silently dropped (#4712) — the model never saw the tool + // output and either re-called the tool endlessly or answered "I don't have that + // information". Fold them into the transcript as plain text, mirroring the agentic + // buildToolConversationPrompt() path. + if (text) { + const name = (m.tool_call_id && callNameById.get(m.tool_call_id)) || m.name || "tool"; + conversation.push({ role: "tool", text: `(${name}) ${text}` }); + } } } @@ -538,7 +555,13 @@ export function messagesToPrompt( // Rolling-window transcript of the most recent turns (#2942). const recent = conversation.slice(-historyWindow); const transcript = recent - .map((turn) => `${turn.role === "assistant" ? "Assistant" : "User"}: ${turn.text}`) + .map((turn) => + turn.role === "assistant" + ? `Assistant: ${turn.text}` + : turn.role === "tool" + ? `Tool result ${turn.text}` + : `User: ${turn.text}` + ) .join("\n\n"); parts.push(transcript); } else if (lastUserContent) { diff --git a/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts new file mode 100644 index 0000000000..38136f270d --- /dev/null +++ b/tests/unit/deepseek-web-tool-result-prompt-4712.test.ts @@ -0,0 +1,66 @@ +// #4712 — deepseek-web drops role:"tool" messages. The web API takes a single `prompt` +// string, so tool results must be folded into the transcript. The agentic path +// (buildToolConversationPrompt) already does this, but messagesToPrompt — used whenever +// the follow-up request no longer carries a `tools[]` array (hasTools=false) — silently +// discarded role:"tool" messages, so the model never saw the tool output and either +// re-called the tool endlessly or answered "I don't have that information". +import test from "node:test"; +import assert from "node:assert/strict"; + +const { messagesToPrompt } = await import("../../open-sse/executors/deepseek-web.ts"); + +const CONVO = [ + { role: "system", content: "You are helpful." }, + { role: "user", content: "what is the weather in Tokyo?" }, + { + role: "assistant", + content: "", + tool_calls: [ + { id: "call_1", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }, + ], + }, + { + role: "tool", + tool_call_id: "call_1", + name: "get_weather", + content: '{"temp":22,"conditions":"Sunny"}', + }, + { role: "user", content: "should I bring an umbrella?" }, +]; + +test("messagesToPrompt includes role:tool results in the rolling-window transcript (#4712)", () => { + const prompt = messagesToPrompt(CONVO, 50); + // The tool output must reach the model — this is the regression guard. + assert.match(prompt, /22/); + assert.match(prompt, /Sunny/); + // It should be labelled as a tool result, not silently merged into a user turn. + assert.match(prompt, /Tool result/i); + // Tool name is preserved for context when available. + assert.match(prompt, /get_weather/); +}); + +test("messagesToPrompt tags the tool name from tool_call_id when no explicit name (#4712)", () => { + const convo = [ + { role: "user", content: "q" }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "c9", function: { name: "lookup", arguments: "{}" } }], + }, + { role: "tool", tool_call_id: "c9", content: "RESULT_PAYLOAD" }, + ]; + const prompt = messagesToPrompt(convo, 50); + assert.match(prompt, /RESULT_PAYLOAD/); + assert.match(prompt, /lookup/); +}); + +test("messagesToPrompt still drops empty tool results without crashing (#4712)", () => { + const convo = [ + { role: "user", content: "hello" }, + { role: "tool", tool_call_id: "x", content: "" }, + { role: "user", content: "world" }, + ]; + const prompt = messagesToPrompt(convo, 50); + assert.match(prompt, /hello/); + assert.match(prompt, /world/); +});