From 6efba7b2e40efb41a04aa7eaeccff087f61105d3 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 12:51:33 +0800 Subject: [PATCH 1/5] fix(js): avoid false tool-call capture on plain tool_calls prose --- internal/js/helpers/stream-tool-sieve/sieve.js | 6 +++++- tests/node/stream-tool-sieve.test.js | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 3250c86..23e477d 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -197,7 +197,11 @@ function findToolSegmentStart(state, s) { } const keyIdx = bestKeyIdx; const start = s.slice(0, keyIdx).lastIndexOf('{'); - let candidateStart = start >= 0 ? start : keyIdx; + if (start < 0) { + offset = keyIdx + matchedKeyword.length; + continue; + } + let candidateStart = start; // If the keyword matched inside an XML tag (e.g. "tool_calls" in ""), // back up past the '<' to capture the full tag. if (candidateStart > 0 && s[candidateStart - 1] === '<') { diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 33b666e..feff59a 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -252,6 +252,18 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', () assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); }); +test('sieve does not start capture on plain "tool_calls" prose without opening json brace', () => { + 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 emits unknown tool payload (no args) as executable tool call', () => { const events = runSieve( ['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'], From ab3943ebebc5daaf51a4d8768ff4fb3a5c317a4f Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 15:39:09 +0800 Subject: [PATCH 2/5] test(js): cover numbered planning prose around tool calls --- tests/node/stream-tool-sieve.test.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index feff59a..e072373 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -264,6 +264,34 @@ test('sieve does not start capture on plain "tool_calls" prose without opening j 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。'], From 775bf3b57896bbefc5fd212cf9d580307841cf45 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 15:41:26 +0800 Subject: [PATCH 3/5] refactor(js): align tool-sieve segment start and tail window with go --- internal/js/helpers/stream-tool-sieve/sieve.js | 6 +----- internal/js/helpers/stream-tool-sieve/state.js | 2 +- tests/node/stream-tool-sieve.test.js | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 23e477d..3250c86 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -197,11 +197,7 @@ function findToolSegmentStart(state, s) { } const keyIdx = bestKeyIdx; const start = s.slice(0, keyIdx).lastIndexOf('{'); - if (start < 0) { - offset = keyIdx + matchedKeyword.length; - continue; - } - let candidateStart = start; + let candidateStart = start >= 0 ? start : keyIdx; // If the keyword matched inside an XML tag (e.g. "tool_calls" in ""), // back up past the '<' to capture the full tag. if (candidateStart > 0 && s[candidateStart - 1] === '<') { diff --git a/internal/js/helpers/stream-tool-sieve/state.js b/internal/js/helpers/stream-tool-sieve/state.js index 74d1904..df82404 100644 --- a/internal/js/helpers/stream-tool-sieve/state.js +++ b/internal/js/helpers/stream-tool-sieve/state.js @@ -1,6 +1,6 @@ 'use strict'; -const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096; +const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 256; function createToolSieveState() { return { diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index e072373..8f8a2bd 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -252,7 +252,7 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', () assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); }); -test('sieve does not start capture on plain "tool_calls" prose without opening json brace', () => { +test('sieve keeps plain "tool_calls" prose as text when no valid payload follows', () => { const events = runSieve( ['前置。', '这里提到 tool_calls 只是解释,不是调用。', '后置。'], ['read_file'], From c07736fbea4867926c941b4803645ece4df21dce Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 15:41:38 +0800 Subject: [PATCH 4/5] chore: set shared tool-sieve context tail window to 2048 --- internal/adapter/openai/tool_sieve_state.go | 3 ++- internal/js/helpers/stream-tool-sieve/state.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/state.js b/internal/js/helpers/stream-tool-sieve/state.js index df82404..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 = 256; +// Keep in sync with Go toolSieveContextTailLimit. +const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 2048; function createToolSieveState() { return { From 1fe1240240a0d88ba7847b101144154b56a56f86 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Mon, 30 Mar 2026 15:59:34 +0800 Subject: [PATCH 5/5] fix(js): prevent XML wrapper attribute tool_calls scan loop --- .../helpers/stream-tool-sieve/parse_payload.js | 5 ++++- tests/node/stream-tool-sieve.test.js | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 8f8a2bd..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}"}}]}`;