fix(tool-sieve): allow mixed prose + tool json interception

This commit is contained in:
CJACK.
2026-03-20 01:15:32 +08:00
parent 2d5103997b
commit 145501d4a5
2 changed files with 9 additions and 29 deletions

View File

@@ -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,

View File

@@ -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', () => {