Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion internal/adapter/openai/tool_sieve_state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion internal/js/helpers/stream-tool-sieve/parse_payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion internal/js/helpers/stream-tool-sieve/state.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
58 changes: 58 additions & 0 deletions tests/node/stream-tool-sieve.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,24 @@ test('sieve flushes incomplete captured XML tool blocks without leaking raw tags
assert.equal(leakedText.includes('<tool_call'), false);
});

test('sieve captures XML wrapper tags with attributes without leaking wrapper text', () => {
const events = runSieve(
[
'前置正文H。',
'<tool_calls id="x"><tool_call><tool_name>read_file</tool_name><parameters>{"path":"README.MD"}</parameters></tool_call></tool_calls>',
'后置正文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('<tool_calls id=\"x\">'), false);
assert.equal(leakedText.includes('</tool_calls>'), 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}"}}]}`;
Expand All @@ -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。'],
Expand Down
Loading