From b108a7915aa7503b4bce4e31d1cc653cd820aa36 Mon Sep 17 00:00:00 2001 From: "CJACK." Date: Sun, 22 Mar 2026 15:12:55 +0800 Subject: [PATCH] Support nested fenced blocks in stream fence tracking --- .../js/helpers/stream-tool-sieve/parse.js | 25 +++++ .../stream-tool-sieve/parse_payload.js | 18 ++++ .../js/helpers/stream-tool-sieve/sieve.js | 14 ++- .../js/helpers/stream-tool-sieve/state.js | 93 ++++++++++++++++++- internal/util/toolcalls_candidates.go | 21 +++++ internal/util/toolcalls_parse.go | 31 +++++++ internal/util/toolcalls_test.go | 19 ++-- internal/util/util_edge_test.go | 4 +- .../expected/toolcalls_fenced_json.json | 9 +- .../toolcalls_standalone_fenced_example.json | 9 +- tests/node/stream-tool-sieve.test.js | 76 +++++++++++++-- 11 files changed, 283 insertions(+), 36 deletions(-) diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index eeb2e34..21378eb 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -8,6 +8,7 @@ const { parseToolCallsPayload, parseMarkupToolCalls, parseTextKVToolCalls, + stripFencedCodeBlocks, } = require('./parse_payload'); const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g; @@ -44,6 +45,9 @@ function parseToolCallsDetailed(text, toolNames) { return result; } result.sawToolCallSyntax = looksLikeToolCallSyntax(normalized); + if (shouldSkipToolCallParsingForCodeFenceExample(normalized)) { + return result; + } const candidates = buildToolCallCandidates(normalized); let parsed = []; @@ -89,6 +93,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) { return result; } result.sawToolCallSyntax = looksLikeToolCallSyntax(trimmed); + if (shouldSkipToolCallParsingForCodeFenceExample(trimmed)) { + return result; + } const candidates = buildToolCallCandidates(trimmed); let parsed = []; for (const c of candidates) { @@ -230,6 +237,24 @@ function looksLikeToolCallSyntax(text) { || lower.includes('function.name:'); } +function shouldSkipToolCallParsingForCodeFenceExample(text) { + if (!looksLikeToolCallSyntax(text) || looksLikeMarkupToolSyntax(text)) { + return false; + } + const stripped = stripFencedCodeBlocks(text); + return !looksLikeToolCallSyntax(stripped); +} + +function looksLikeMarkupToolSyntax(text) { + const raw = toStringSafe(text); + if (!raw) { + return false; + } + return /<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b)/i.test(raw) + || /<(?:[a-z0-9_:-]+:)?function_calls\b/i.test(raw) + || /<(?:[a-z0-9_:-]+:)?tool_use\b/i.test(raw); +} + module.exports = { extractToolNames, parseToolCalls, diff --git a/internal/js/helpers/stream-tool-sieve/parse_payload.js b/internal/js/helpers/stream-tool-sieve/parse_payload.js index 41ed787..dad52ab 100644 --- a/internal/js/helpers/stream-tool-sieve/parse_payload.js +++ b/internal/js/helpers/stream-tool-sieve/parse_payload.js @@ -114,6 +114,9 @@ function parseToolCallsPayload(payload) { return []; } if (decoded.tool_calls) { + if (isLikelyChatMessageEnvelope(decoded)) { + return []; + } return parseToolCallList(decoded.tool_calls); } @@ -121,6 +124,21 @@ function parseToolCallsPayload(payload) { return one ? [one] : []; } +function isLikelyChatMessageEnvelope(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + if (!Object.prototype.hasOwnProperty.call(value, 'tool_calls')) { + return false; + } + const role = toStringSafe(value.role).trim().toLowerCase(); + if (role === 'assistant' || role === 'tool' || role === 'user' || role === 'system') { + return true; + } + return Object.prototype.hasOwnProperty.call(value, 'tool_call_id') + || Object.prototype.hasOwnProperty.call(value, 'content'); +} + function parseMarkupToolCalls(text) { const raw = toStringSafe(text).trim(); if (!raw) { diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 6cf8b5c..fe90901 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -1,5 +1,9 @@ 'use strict'; -const { resetIncrementalToolState, noteText, insideCodeFence } = require('./state'); +const { + resetIncrementalToolState, + noteText, + insideCodeFenceWithState, +} = require('./state'); const { parseStandaloneToolCallsDetailed } = require('./parse'); const { extractJSONObjectFrom } = require('./jsonscan'); @@ -53,7 +57,7 @@ function processToolSieveChunk(state, chunk, toolNames) { if (!pending) { break; } - const start = findToolSegmentStart(pending); + const start = findToolSegmentStart(state, pending); if (start >= 0) { const prefix = pending.slice(0, start); if (prefix) { @@ -143,7 +147,7 @@ function findSuspiciousPrefixStart(s) { return start; } -function findToolSegmentStart(s) { +function findToolSegmentStart(state, s) { if (!s) { return -1; } @@ -168,7 +172,7 @@ function findToolSegmentStart(s) { const keyIdx = bestKeyIdx; const start = s.slice(0, keyIdx).lastIndexOf('{'); const candidateStart = start >= 0 ? start : keyIdx; - if (!insideCodeFence(s.slice(0, candidateStart))) { + if (!insideCodeFenceWithState(state, s.slice(0, candidateStart))) { return candidateStart; } offset = keyIdx + matchedKeyword.length; @@ -211,7 +215,7 @@ function consumeToolCapture(state, toolNames) { } const prefixPart = captured.slice(0, actualStart); const suffixPart = captured.slice(obj.end); - if (insideCodeFence((state.recentTextTail || '') + prefixPart)) { + if (insideCodeFenceWithState(state, prefixPart)) { return { ready: true, prefix: captured, diff --git a/internal/js/helpers/stream-tool-sieve/state.js b/internal/js/helpers/stream-tool-sieve/state.js index d013727..74d1904 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 = 256; +const TOOL_SIEVE_CONTEXT_TAIL_LIMIT = 4096; function createToolSieveState() { return { @@ -8,6 +8,9 @@ function createToolSieveState() { capture: '', capturing: false, recentTextTail: '', + codeFenceStack: [], + codeFencePendingTicks: 0, + codeFenceLineStart: true, pendingToolRaw: '', pendingToolCalls: [], disableDeltas: false, @@ -34,6 +37,7 @@ function noteText(state, text) { if (!state || !hasMeaningfulText(text)) { return; } + updateCodeFenceState(state, text); state.recentTextTail = appendTail(state.recentTextTail, text, TOOL_SIEVE_CONTEXT_TAIL_LIMIT); } @@ -63,6 +67,91 @@ function insideCodeFence(text) { return ticks % 2 === 1; } +function insideCodeFenceWithState(state, text) { + if (!state) { + return insideCodeFence(text); + } + const simulated = simulateCodeFenceState( + Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [], + Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0, + state.codeFenceLineStart !== false, + text, + ); + return simulated.stack.length > 0; +} + +function updateCodeFenceState(state, text) { + if (!state) { + return; + } + const next = simulateCodeFenceState( + Array.isArray(state.codeFenceStack) ? state.codeFenceStack : [], + Number.isInteger(state.codeFencePendingTicks) ? state.codeFencePendingTicks : 0, + state.codeFenceLineStart !== false, + text, + ); + state.codeFenceStack = next.stack; + state.codeFencePendingTicks = next.pendingTicks; + state.codeFenceLineStart = next.lineStart; +} + +function simulateCodeFenceState(stack, pendingTicks, lineStart, text) { + const chunk = typeof text === 'string' ? text : ''; + const nextStack = Array.isArray(stack) ? [...stack] : []; + let ticks = Number.isInteger(pendingTicks) ? pendingTicks : 0; + let atLineStart = lineStart !== false; + + const flushTicks = () => { + if (ticks > 0) { + if (atLineStart && ticks >= 3) { + applyFenceMarker(nextStack, ticks); + } + atLineStart = false; + ticks = 0; + } + }; + + for (let i = 0; i < chunk.length; i += 1) { + const ch = chunk[i]; + if (ch === '`') { + ticks += 1; + continue; + } + flushTicks(); + if (ch === '\n' || ch === '\r') { + atLineStart = true; + continue; + } + if ((ch === ' ' || ch === '\t') && atLineStart) { + continue; + } + atLineStart = false; + } + // keep ticks for cross-chunk continuation. + return { + stack: nextStack, + pendingTicks: ticks, + lineStart: atLineStart, + }; +} + +function applyFenceMarker(stack, ticks) { + if (!Array.isArray(stack)) { + return; + } + if (stack.length === 0) { + stack.push(ticks); + return; + } + const top = stack[stack.length - 1]; + if (ticks >= top) { + stack.pop(); + return; + } + // nested/open inner fence using longer marker for robustness. + stack.push(ticks); +} + function hasMeaningfulText(text) { return toStringSafe(text) !== ''; } @@ -88,6 +177,8 @@ module.exports = { appendTail, looksLikeToolExampleContext, insideCodeFence, + insideCodeFenceWithState, + updateCodeFenceState, hasMeaningfulText, toStringSafe, }; diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index e4495b7..f847580 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -7,6 +7,8 @@ import ( var toolCallPattern = regexp.MustCompile(`\{\s*["']tool_calls["']\s*:\s*\[(.*?)\]\s*\}`) var fencedJSONPattern = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```") +var fencedCodeBlockPattern = regexp.MustCompile("(?s)```[\\s\\S]*?```") +var markupToolSyntaxPattern = regexp.MustCompile(`(?i)<(?:(?:[a-z0-9_:-]+:)?(?:tool_call|function_call|invoke)\b|(?:[a-z0-9_:-]+:)?function_calls\b|(?:[a-z0-9_:-]+:)?tool_use\b)`) func buildToolCallCandidates(text string) []string { trimmed := strings.TrimSpace(text) @@ -173,3 +175,22 @@ func looksLikeToolExampleContext(text string) bool { } return strings.Contains(t, "```") } + +func shouldSkipToolCallParsingForCodeFenceExample(text string) bool { + if !looksLikeToolCallSyntax(text) || looksLikeMarkupToolSyntax(text) { + return false + } + stripped := strings.TrimSpace(stripFencedCodeBlocks(text)) + return !looksLikeToolCallSyntax(stripped) +} + +func looksLikeMarkupToolSyntax(text string) bool { + return markupToolSyntaxPattern.MatchString(text) +} + +func stripFencedCodeBlocks(text string) string { + if text == "" { + return "" + } + return fencedCodeBlockPattern.ReplaceAllString(text, " ") +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index aa2288a..7aad445 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -26,6 +26,9 @@ func ParseToolCallsDetailed(text string, availableToolNames []string) ToolCallPa return result } result.SawToolCallSyntax = looksLikeToolCallSyntax(text) + if shouldSkipToolCallParsingForCodeFenceExample(text) { + return result + } candidates := buildToolCallCandidates(text) var parsed []ParsedToolCall @@ -74,6 +77,9 @@ func ParseStandaloneToolCallsDetailed(text string, availableToolNames []string) return result } result.SawToolCallSyntax = looksLikeToolCallSyntax(trimmed) + if shouldSkipToolCallParsingForCodeFenceExample(trimmed) { + return result + } candidates := buildToolCallCandidates(trimmed) var parsed []ParsedToolCall for _, candidate := range candidates { @@ -183,6 +189,9 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { switch v := decoded.(type) { case map[string]any: if tc, ok := v["tool_calls"]; ok { + if isLikelyChatMessageEnvelope(v) { + return nil + } return parseToolCallList(tc) } if parsed, ok := parseToolCallItem(v); ok { @@ -194,6 +203,28 @@ func parseToolCallsPayload(payload string) []ParsedToolCall { return nil } +func isLikelyChatMessageEnvelope(v map[string]any) bool { + if v == nil { + return false + } + if _, ok := v["tool_calls"]; !ok { + return false + } + if role, ok := v["role"].(string); ok { + switch strings.ToLower(strings.TrimSpace(role)) { + case "assistant", "tool", "user", "system": + return true + } + } + if _, ok := v["tool_call_id"]; ok { + return true + } + if _, ok := v["content"]; ok { + return true + } + return false +} + func looksLikeToolCallSyntax(text string) bool { lower := strings.ToLower(text) return strings.Contains(lower, "tool_calls") || diff --git a/internal/util/toolcalls_test.go b/internal/util/toolcalls_test.go index d66519b..215d479 100644 --- a/internal/util/toolcalls_test.go +++ b/internal/util/toolcalls_test.go @@ -19,11 +19,11 @@ func TestParseToolCalls(t *testing.T) { } } -func TestParseToolCallsFromFencedJSON(t *testing.T) { +func TestParseToolCallsIgnoresFencedJSON(t *testing.T) { text := "I will call tools now\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"news\"}}]}\n```" calls := ParseToolCalls(text, []string{"search"}) - if len(calls) != 1 { - t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls) + if len(calls) != 0 { + t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls) } } @@ -112,10 +112,17 @@ func TestParseStandaloneToolCallsSupportsMixedProsePayload(t *testing.T) { } } -func TestParseStandaloneToolCallsParsesFencedCodeBlock(t *testing.T) { +func TestParseStandaloneToolCallsIgnoresFencedCodeBlock(t *testing.T) { fenced := "```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```" - if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 1 { - t.Fatalf("expected fenced tool_call payload to be parsed, got %#v", calls) + if calls := ParseStandaloneToolCalls(fenced, []string{"search"}); len(calls) != 0 { + t.Fatalf("expected fenced tool_call payload to be ignored, got %#v", calls) + } +} + +func TestParseStandaloneToolCallsIgnoresChatTranscriptEnvelope(t *testing.T) { + transcript := `[{"role":"user","content":"请展示完整会话"},{"role":"assistant","content":null,"tool_calls":[{"function":{"name":"search","arguments":"{\"q\":\"go\"}"}}]}]` + if calls := ParseStandaloneToolCalls(transcript, []string{"search"}); len(calls) != 0 { + t.Fatalf("expected transcript envelope not to trigger tool call parse, got %#v", calls) } } diff --git a/internal/util/util_edge_test.go b/internal/util/util_edge_test.go index 81d607e..8113709 100644 --- a/internal/util/util_edge_test.go +++ b/internal/util/util_edge_test.go @@ -409,8 +409,8 @@ func TestParseToolCallsWithFunctionWrapper(t *testing.T) { func TestParseStandaloneToolCallsFencedCodeBlock(t *testing.T) { fenced := "Here's an example:\n```json\n{\"tool_calls\":[{\"name\":\"search\",\"input\":{\"q\":\"go\"}}]}\n```\nDon't execute this." calls := ParseStandaloneToolCalls(fenced, []string{"search"}) - if len(calls) != 1 { - t.Fatalf("expected fenced code block to be parsed, got %d calls", len(calls)) + if len(calls) != 0 { + t.Fatalf("expected fenced code block to be ignored, got %d calls", len(calls)) } } diff --git a/tests/compat/expected/toolcalls_fenced_json.json b/tests/compat/expected/toolcalls_fenced_json.json index 124de59..5b9e4d1 100644 --- a/tests/compat/expected/toolcalls_fenced_json.json +++ b/tests/compat/expected/toolcalls_fenced_json.json @@ -1,12 +1,5 @@ { - "calls": [ - { - "name": "read_file", - "input": { - "path": "README.MD" - } - } - ], + "calls": [], "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] diff --git a/tests/compat/expected/toolcalls_standalone_fenced_example.json b/tests/compat/expected/toolcalls_standalone_fenced_example.json index 124de59..5b9e4d1 100644 --- a/tests/compat/expected/toolcalls_standalone_fenced_example.json +++ b/tests/compat/expected/toolcalls_standalone_fenced_example.json @@ -1,12 +1,5 @@ { - "calls": [ - { - "name": "read_file", - "input": { - "path": "README.MD" - } - } - ], + "calls": [], "sawToolCallSyntax": true, "rejectedByPolicy": false, "rejectedToolNames": [] diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index b24e138..23834ec 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -84,7 +84,7 @@ test('parseToolCalls rejects all names when toolNames is empty (Go strict parity assert.deepEqual(detailed.rejectedToolNames, ['not_in_schema']); }); -test('parseToolCalls supports fenced json and function.arguments string payload', () => { +test('parseToolCalls ignores tool_call payloads that exist only inside fenced code blocks', () => { const text = [ 'I will call a tool now.', '```json', @@ -92,9 +92,7 @@ test('parseToolCalls supports fenced json and function.arguments string payload' '```', ].join('\n'); const calls = parseToolCalls(text, ['read_file']); - assert.equal(calls.length, 1); - assert.equal(calls[0].name, 'read_file'); - assert.equal(calls[0].input.path, 'README.md'); + assert.equal(calls.length, 0); }); test('parseToolCalls parses text-kv fallback payload', () => { @@ -134,10 +132,23 @@ test('parseStandaloneToolCalls parses mixed prose payload', () => { assert.equal(standaloneCalls.length, 1); }); -test('parseStandaloneToolCalls parses fenced code block tool_call payload', () => { +test('parseStandaloneToolCalls ignores fenced code block tool_call payload', () => { const fenced = ['```json', '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}', '```'].join('\n'); const calls = parseStandaloneToolCalls(fenced, ['read_file']); - assert.equal(calls.length, 1); + assert.equal(calls.length, 0); +}); + +test('parseStandaloneToolCalls ignores chat transcript message envelope with tool_calls', () => { + const transcript = JSON.stringify([ + { role: 'user', content: '请展示完整会话' }, + { + role: 'assistant', + content: null, + tool_calls: [{ function: { name: 'read_file', arguments: '{"path":"README.MD"}' } }], + }, + ]); + const calls = parseStandaloneToolCalls(transcript, ['read_file']); + assert.equal(calls.length, 0); }); @@ -348,6 +359,59 @@ test('sieve preserves closed fence before standalone tool payload', () => { assert.equal(leakedText.toLowerCase().includes('tool_calls'), false); }); +test('sieve does not trigger tool calls for long fenced examples beyond legacy tail window', () => { + const longPadding = 'x'.repeat(700); + const events = runSieve( + [ + `前置说明\n\`\`\`json\n${longPadding}\n`, + '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n', + '```', + '\n后置说明', + ], + ['read_file'], + ); + const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + const leakedText = collectText(events); + assert.equal(hasTool, false); + assert.equal(leakedText.includes('后置说明'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); +}); + +test('sieve keeps fence state when triple-backticks are split across chunks', () => { + const events = runSieve( + [ + '示例开始\n``', + '`json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n', + '```', + '\n示例结束', + ], + ['read_file'], + ); + const hasTool = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); + const leakedText = collectText(events); + assert.equal(hasTool, false); + assert.equal(leakedText.includes('示例结束'), true); + assert.equal(leakedText.toLowerCase().includes('tool_calls'), true); +}); + +test('sieve ignores tool-like payload inside nested fences and resumes detection after close', () => { + const events = runSieve( + [ + '外层示例开始\n````markdown\n', + '```json\n{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}\n```\n', + '````\n', + '{"tool_calls":[{"name":"read_file","input":{"path":"README2.MD"}}]}', + ], + ['read_file'], + ); + const calls = events.filter((evt) => evt.type === 'tool_calls').flatMap((evt) => evt.calls || []); + const leakedText = collectText(events); + assert.equal(calls.length, 1); + assert.equal(calls[0].input.path, 'README2.MD'); + assert.equal(leakedText.includes('README.MD'), true); + assert.equal(leakedText.includes('README2.MD'), false); +}); + test('formatOpenAIStreamToolCalls reuses ids with the same idStore', () => { const idStore = new Map(); const calls = [{ name: 'read_file', input: { path: 'README.MD' } }];