From 145501d4a5e5a973b21c29b9b5806e2c4f153f9c Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Fri, 20 Mar 2026 01:15:32 +0800 Subject: [PATCH] fix(tool-sieve): allow mixed prose + tool json interception --- .../js/helpers/stream-tool-sieve/sieve.js | 26 +++---------------- tests/node/stream-tool-sieve.test.js | 12 ++++----- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index ef12e8b..ae95ffd 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -46,6 +46,9 @@ function processToolSieveChunk(state, chunk, toolNames) { if (Array.isArray(consumed.calls) && consumed.calls.length > 0) { state.pendingToolRaw = captured; state.pendingToolCalls = consumed.calls; + if (consumed.suffix) { + state.pending = consumed.suffix + state.pending; + } continue; } if (consumed.prefix) { @@ -235,17 +238,6 @@ function consumeToolCapture(state, toolNames) { }; } - // Strict standalone mode: if the stream already produced meaningful prose, - // treat later tool-looking JSON as plain text instead of intercepting. - if ((state.recentTextTail || '').trim() !== '' && prefixPart.trim() === '') { - return { - ready: true, - prefix: captured, - calls: [], - suffix: '', - }; - } - const parsed = parseStandaloneToolCallsDetailed(captured.slice(actualStart, obj.end), toolNames); if (!Array.isArray(parsed.calls) || parsed.calls.length === 0) { if (parsed.sawToolCallSyntax && parsed.rejectedByPolicy) { @@ -264,18 +256,6 @@ function consumeToolCapture(state, toolNames) { }; } - // Strict standalone mode: only intercept when the tool payload stands alone - // (allowing only surrounding whitespace). If there is non-whitespace prose - // before/after the JSON object, keep everything as normal text. - if (prefixPart.trim() !== '' || suffixPart.trim() !== '') { - return { - ready: true, - prefix: captured, - calls: [], - suffix: '', - }; - } - return { ready: true, prefix: prefixPart, diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 61d72d6..7c703a1 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -259,28 +259,28 @@ test('sieve emits final tool_calls for split arguments payload without increment assert.deepEqual(finalCalls[0].input, { path: 'README.MD', mode: 'head' }); }); -test('sieve keeps tool json as text when leading prose exists (strict mode)', () => { +test('sieve still emits tool_calls when leading prose exists before tool json', () => { const events = runSieve( ['我将调用工具。', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}'], ['read_file'], ); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const leakedText = collectText(events); - assert.equal(hasTool, false); + assert.equal(hasTool, true); assert.equal(leakedText.includes('我将调用工具。'), true); - assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); }); -test('sieve keeps same-chunk trailing prose payload as text in strict mode', () => { +test('sieve emits tool_calls and keeps trailing prose when payload and prose share a chunk', () => { const events = runSieve( ['{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}然后继续解释。'], ['read_file'], ); const hasTool = events.some((evt) => (evt.type === 'tool_calls' && evt.calls?.length > 0) || (evt.type === 'tool_call_deltas' && evt.deltas?.length > 0)); const leakedText = collectText(events); - assert.equal(hasTool, false); + assert.equal(hasTool, true); assert.equal(leakedText.includes('然后继续解释。'), true); - assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); }); test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => {