fix(sse): deepseek-web folds role:tool results into prompt transcript (#4712) (#4756)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-23 08:17:25 -03:00
committed by GitHub
parent db358991f2
commit ec9c93fe29
4 changed files with 94 additions and 3 deletions

View File

@@ -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).
---

View File

@@ -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,

View File

@@ -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<string, string>();
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) {

View File

@@ -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/);
});