diff --git a/internal/adapter/openai/tool_sieve_state.go b/internal/adapter/openai/tool_sieve_state.go index f36560a..60370e9 100644 --- a/internal/adapter/openai/tool_sieve_state.go +++ b/internal/adapter/openai/tool_sieve_state.go @@ -34,7 +34,8 @@ type toolCallDelta struct { Arguments string } -const toolSieveContextTailLimit = 256 +// Keep in sync with JS TOOL_SIEVE_CONTEXT_TAIL_LIMIT. +const toolSieveContextTailLimit = 2048 func (s *toolStreamSieveState) resetIncrementalToolState() { s.disableDeltas = false diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index e658be5..c480033 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -102,7 +102,10 @@ function extractToolCallObjects(text) { const obj = extractJSONObjectFrom(raw, start); if (obj.ok) { out.push(raw.slice(start, obj.end).trim()); - offset = obj.end; + // Ensure forward progress even when the matched keyword is outside + // the extracted JSON object (e.g. closing XML wrapper tags containing + // "tool_calls" after an earlier JSON arguments object). + offset = Math.max(obj.end, idx + matched.length); idx = -1; break; } diff --git a/internal/js/helpers/stream-tool-sieve/state.js b/internal/js/helpers/stream-tool-sieve/state.js index 74d1904..9a5b1c3 100644 --- a/internal/js/helpers/stream-tool-sieve/state.js +++ b/internal/js/helpers/stream-tool-sieve/state.js @@ -1,6 +1,7 @@ 'use strict'; -const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096; +// Keep in sync with Go toolSieveContextTailLimit. +const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048; function createToolSieveState() { return { diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 33b666e..e53086e 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -227,6 +227,24 @@ test('sieve flushes incomplete captured XML tool blocks without leaking raw tags assert.equal(leakedText.includes(' { + const events = runSieve( + [ + '前置正文H。', + 'read_file{"path":"README.MD"}', + '后置正文I。', + ], + ['read_file'], + ); + const leakedText = collectText(events); + const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + assert.equal(hasToolCall, true); + assert.equal(leakedText.includes('前置正文H。'), true); + assert.equal(leakedText.includes('后置正文I。'), true); + assert.equal(leakedText.includes(''), false); + assert.equal(leakedText.includes(''), false); +}); + test('sieve still intercepts large tool json payloads over previous capture limit', () => { const large = 'a'.repeat(9000); const payload = `{"tool_calls":[{"name":"read_file","input":{"path":"${large}"}}]}`; @@ -252,6 +270,46 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', () assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); }); +test('sieve keeps plain "tool_calls" prose as text when no valid payload follows', () => { + const events = runSieve( + ['前置。', '这里提到 tool_calls 只是解释,不是调用。', '后置。'], + ['read_file'], + ); + const leakedText = collectText(events); + const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + assert.equal(hasToolCall, false); + assert.equal(leakedText.includes('tool_calls'), true); + assert.equal(leakedText, '前置。这里提到 tool_calls 只是解释,不是调用。后置。'); +}); + +test('sieve keeps numbered planning prose before a real tool payload (mobile-chat style)', () => { + const events = runSieve( + [ + '好的,我会依次测试每个工具,先把所有工具都调用一遍,然后汇总结果给你看。\n\n1. 获取当前时间\n', + '{"tool_calls":[{"name":"get_current_time","input":{}}]}', + ], + ['get_current_time'], + ); + const leakedText = collectText(events); + const finalCalls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + assert.equal(finalCalls.length, 1); + assert.equal(finalCalls[0].name, 'get_current_time'); + assert.equal(leakedText.includes('先把所有工具都调用一遍'), true); + assert.equal(leakedText.includes('1. 获取当前时间'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); +}); + +test('sieve keeps numbered planning prose when no tool payload follows', () => { + const events = runSieve( + ['好的,我会依次测试每个工具。\n\n1. 获取当前时间'], + ['get_current_time'], + ); + const leakedText = collectText(events); + const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + assert.equal(hasToolCall, false); + assert.equal(leakedText, '好的,我会依次测试每个工具。\n\n1. 获取当前时间'); +}); + test('sieve emits unknown tool payload (no args) as executable tool call', () => { const events = runSieve( ['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],