Skip to content

Commit e080b60

Browse files
committed
fix(agent-core): require explicit tool-call terminals
1 parent 964ca3e commit e080b60

3 files changed

Lines changed: 126 additions & 14 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2843,6 +2843,125 @@ describe("openai transport stream", () => {
28432843
},
28442844
);
28452845

2846+
it("does not authorize recovered DeepSeek DSML calls when the stream omits a terminal", async () => {
2847+
const model = createDeepSeekCompletionsModel();
2848+
const output = createAssistantOutput(model);
2849+
2850+
await testing.processOpenAICompletionsStream(
2851+
streamChunks([
2852+
{
2853+
id: "chatcmpl-deepseek-dsml-no-terminal",
2854+
object: "chat.completion.chunk",
2855+
created: 1,
2856+
model: model.id,
2857+
choices: [
2858+
{
2859+
index: 0,
2860+
delta: {
2861+
content:
2862+
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
2863+
},
2864+
logprobs: null,
2865+
finish_reason: null,
2866+
},
2867+
],
2868+
},
2869+
]),
2870+
output,
2871+
model,
2872+
{ push() {} },
2873+
);
2874+
2875+
expect(output.stopReason).toBe("stop");
2876+
expect(output.content).toEqual([]);
2877+
});
2878+
2879+
it("emits recovered DeepSeek content-filter terminals as errors", async () => {
2880+
const server = createServer((req, res) => {
2881+
req.resume();
2882+
req.on("end", () => {
2883+
res.writeHead(200, {
2884+
"content-type": "text/event-stream; charset=utf-8",
2885+
"cache-control": "no-cache",
2886+
connection: "keep-alive",
2887+
});
2888+
res.write(
2889+
`data: ${JSON.stringify({
2890+
id: "chatcmpl-deepseek-dsml-content-filter",
2891+
object: "chat.completion.chunk",
2892+
created: 1,
2893+
model: "deepseek-v4-pro",
2894+
choices: [
2895+
{
2896+
index: 0,
2897+
delta: {
2898+
content:
2899+
'<|DSML|tool_calls><|DSML|invoke name="read">{"path":"/tmp/partial.md"}</|DSML|invoke></|DSML|tool_calls>',
2900+
},
2901+
finish_reason: "content_filter",
2902+
},
2903+
],
2904+
})}\n\n`,
2905+
);
2906+
res.end("data: [DONE]\n\n");
2907+
});
2908+
});
2909+
2910+
await new Promise<void>((resolve) => {
2911+
server.listen(0, "127.0.0.1", resolve);
2912+
});
2913+
try {
2914+
const address = server.address();
2915+
if (!address || typeof address === "string") {
2916+
throw new Error("Missing loopback server address");
2917+
}
2918+
const model = {
2919+
...createDeepSeekCompletionsModel(),
2920+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
2921+
} satisfies Model<"openai-completions">;
2922+
const stream = createOpenAICompletionsTransportStreamFn()(
2923+
model,
2924+
{
2925+
systemPrompt: "system",
2926+
messages: [{ role: "user", content: "Read the file", timestamp: Date.now() }],
2927+
tools: [],
2928+
} as never,
2929+
{ apiKey: "test-key" } as never,
2930+
);
2931+
2932+
const terminalEvents: Array<{
2933+
type: string;
2934+
reason?: string;
2935+
error?: Record<string, unknown>;
2936+
}> = [];
2937+
for await (const event of stream as AsyncIterable<{
2938+
type: string;
2939+
reason?: string;
2940+
error?: Record<string, unknown>;
2941+
}>) {
2942+
if (event.type === "done" || event.type === "error") {
2943+
terminalEvents.push(event);
2944+
}
2945+
}
2946+
2947+
expect(terminalEvents).toEqual([
2948+
expect.objectContaining({
2949+
type: "error",
2950+
reason: "error",
2951+
error: expect.objectContaining({
2952+
stopReason: "error",
2953+
errorMessage: "Provider finish_reason: content_filter",
2954+
content: [],
2955+
}),
2956+
}),
2957+
]);
2958+
} finally {
2959+
await new Promise<void>((resolve, reject) => {
2960+
server.close((error) => (error ? reject(error) : resolve()));
2961+
});
2962+
}
2963+
});
2964+
28462965
it("parses repeated DeepSeek DSML name attributes consistently", async () => {
28472966
// Guards the cached attribute matchers: repeated parses must stay identical
28482967
// (no stale RegExp lastIndex) across separate stream invocations.

src/agents/openai-transport-stream.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.j
9898
import { transformTransportMessages } from "./transport-message-transform.js";
9999
import {
100100
assignTransportErrorDetails,
101+
failTransportStream,
102+
finalizeTransportStream,
101103
mergeTransportMetadata,
102104
sanitizeTransportPayloadText,
103105
} from "./transport-stream-shared.js";
@@ -2801,15 +2803,9 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
28012803
signal: options?.signal,
28022804
emitReasoning,
28032805
});
2804-
if (options?.signal?.aborted) {
2805-
throw new Error("Request was aborted");
2806-
}
2807-
stream.push({ type: "done", reason: output.stopReason as never, message: output as never });
2808-
stream.end();
2806+
finalizeTransportStream({ stream, output, signal: options?.signal });
28092807
} catch (error) {
2810-
assignTransportErrorDetails(output, error, options?.signal);
2811-
stream.push({ type: "error", reason: output.stopReason as never, error: output as never });
2812-
stream.end();
2808+
failTransportStream({ stream, output, signal: options?.signal, error });
28132809
}
28142810
})();
28152811
return eventStream as unknown as ReturnType<StreamFn>;
@@ -2976,11 +2972,6 @@ async function processOpenAICompletionsStream(
29762972
currentBlock = null;
29772973
flushPendingPostToolCallDeltas();
29782974
}
2979-
// Recovery may run after this chunk's finish reason was parsed. Preserve
2980-
// length/error terminals so complete-looking partial DSML never executes.
2981-
if (output.stopReason === "stop") {
2982-
output.stopReason = "toolUse";
2983-
}
29842975
recoveredDeepSeekToolCallIndex += 1;
29852976
const block: ToolCallBlock = {
29862977
type: "toolCall",
@@ -3250,6 +3241,8 @@ async function processOpenAICompletionsStream(
32503241
if (output.stopReason === "toolUse" && !hasToolCalls) {
32513242
output.stopReason = "stop";
32523243
}
3244+
// Tool-call recovery is executable only after an explicit provider terminal.
3245+
// EOF alone can mean transport truncation, even when the recovered call parses.
32533246
if (sawStopFinishReason && output.stopReason === "stop" && hasToolCalls && !hasVisibleText) {
32543247
output.stopReason = "toolUse";
32553248
}

src/llm/providers/google-shared.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ describe("consumeGoogleGenerateContentStream", () => {
159159
finishReason: FinishReason.MAX_TOKENS,
160160
},
161161
],
162-
} as GenerateContentResponse,
162+
} as unknown as GenerateContentResponse,
163163
]),
164164
model,
165165
output,

0 commit comments

Comments
 (0)