diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 56271795..459266ec 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -11,6 +11,30 @@ import { extractTextContent } from "../translator/formats/gemini.js"; // stripped). Must be prioritized. Soft (e.g. search) only degrades a feature. const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]); +// Prefixes used when flattening tool turns into plain prose for panel models. +const TOOL_CALL_PREFIX = "[Called tools: "; +const TOOL_RESULT_PREFIX = "[Tool result: "; + +// Flatten tool turns into prose so panel models keep the context but can't loop +// on tools: drop the request's tools, turn tool/function results into assistant +// text, and inline assistant tool_calls names instead of the structured field. +function flattenToolHistory(messages) { + return messages + .filter((msg) => msg) + .map((msg) => { + if (msg.role === "tool" || msg.role === "function") { + return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` }; + } + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + const { tool_calls, ...rest } = msg; + const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", "); + const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : ""); + return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` }; + } + return msg; + }); +} + // Reorder combo models by capability fit. Stable; never drops a model (fallback intact). // Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest. export function reorderByCapabilities(models, required) { @@ -468,8 +492,16 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). const { tools, tool_choice, ...rest } = body; const panelBody = { ...rest, stream: false }; + + // Flatten tool turns to prose so panel models keep context without emitting tool_calls. + if (Array.isArray(panelBody.messages)) { + panelBody.messages = flattenToolHistory(panelBody.messages); + } else if (Array.isArray(panelBody.input)) { + panelBody.input = flattenToolHistory(panelBody.input); + } + const t0 = Date.now(); - const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m), cfg.panelHardTimeoutMs)); + const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs)); const settled = await collectPanel(calls, { ...cfg, minPanel }); log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 1e9b86c2..75c26b5d 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -102,7 +102,14 @@ export async function handleChat(request, clientRawRequest = null) { return handleFusionChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m, isPanel) => { + let cleanRawReq = clientRawRequest; + if (isPanel && clientRawRequest) { + const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {}; + cleanRawReq = { ...clientRawRequest, body: cleanBody }; + } + return handleSingleModelChat(b, m, cleanRawReq, request, apiKey); + }, log, comboName: modelStr, judgeModel: comboStrategies[modelStr]?.judgeModel, @@ -148,7 +155,14 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re return handleFusionChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey), + handleSingleModel: (b, m, isPanel) => { + let cleanRawReq = clientRawRequest; + if (isPanel && clientRawRequest) { + const { tools, tool_choice, ...cleanBody } = clientRawRequest.body || {}; + cleanRawReq = { ...clientRawRequest, body: cleanBody }; + } + return handleSingleModelChat(b, m, cleanRawReq, request, apiKey); + }, log, comboName: modelStr, judgeModel: comboStrategies[modelStr]?.judgeModel, diff --git a/tests/unit/combo-fusion.test.js b/tests/unit/combo-fusion.test.js index 210dcf62..1a47843d 100644 --- a/tests/unit/combo-fusion.test.js +++ b/tests/unit/combo-fusion.test.js @@ -32,7 +32,7 @@ describe("fusion combo", () => { it("fans out to the panel then routes a synthesis turn to the judge", async () => { const seen = []; - const handleSingleModel = vi.fn(async (body, model) => { + const handleSingleModel = vi.fn(async (body, model, isPanel) => { seen.push(model); if (model === "p/judge") return okResponse("FINAL"); return okResponse(`ans-${model}`); @@ -52,19 +52,21 @@ describe("fusion combo", () => { expect(seen[3]).toBe("p/judge"); // Panel calls are non-streaming with tools stripped. - for (const [body, model] of handleSingleModel.mock.calls.filter(([, m]) => m !== "p/judge")) { + for (const [body, model, isPanel] of handleSingleModel.mock.calls.filter(([, m]) => m !== "p/judge")) { expect(body.stream).toBe(false); expect(body.tools).toBeUndefined(); + expect(isPanel).toBe(true); } // Judge call carries every panel answer + keeps the client's stream flag. - const [judgeBody] = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge"); + const [judgeBody, , isPanel] = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge"); const judgeText = judgeBody.messages.at(-1).content; expect(judgeText).toContain("ans-p/a"); expect(judgeText).toContain("ans-p/b"); expect(judgeText).toContain("ans-p/c"); expect(judgeText).toContain("Source 1"); expect(judgeBody.stream).toBe(true); + expect(isPanel).toBeUndefined(); expect(res.ok).toBe(true); }); @@ -139,4 +141,45 @@ describe("fusion combo", () => { }); expect(res.status).toBe(503); }); + + it("flattens previous tool history and assistant tool_calls into prose for panel calls", async () => { + const handleSingleModel = vi.fn(async () => okResponse("ans")); + await handleFusionChat({ + body: { + messages: [ + { role: "user", content: "find files" }, + { role: "assistant", content: "", tool_calls: [{ id: "c1", type: "function", function: { name: "find" } }] }, + { role: "tool", tool_call_id: "c1", content: "['a.js']" }, + { role: "user", content: "describe it" } + ], + tools: [{ type: "function" }] + }, + models: ["p/a", "p/b"], + handleSingleModel, + log, + judgeModel: "p/judge" + }); + + // Panel calls keep every turn but tool turns are flattened to assistant prose. + const panelCalls = handleSingleModel.mock.calls.filter(([,, isPanel]) => isPanel === true); + expect(panelCalls.length).toBe(2); + for (const [panelBody] of panelCalls) { + expect(panelBody.tools).toBeUndefined(); + expect(panelBody.messages.length).toBe(4); + expect(panelBody.messages[0]).toEqual({ role: "user", content: "find files" }); + expect(panelBody.messages[1].tool_calls).toBeUndefined(); + expect(panelBody.messages[1].content).toContain("find"); + expect(panelBody.messages[2].role).toBe("assistant"); + expect(panelBody.messages[2].content).toContain("['a.js']"); + expect(panelBody.messages[3]).toEqual({ role: "user", content: "describe it" }); + } + + // Judge call still receives the unmodified history + synthesis prompt. + const judgeCall = handleSingleModel.mock.calls.find(([, m]) => m === "p/judge"); + expect(judgeCall).toBeDefined(); + const judgeBody = judgeCall[0]; + expect(judgeBody.messages.length).toBe(5); // original 4 + judge prompt turn + expect(judgeBody.messages[1].tool_calls).toBeDefined(); + expect(judgeBody.messages[2].role).toBe("tool"); + }); });