diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index bc81882..9cacea9 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -130,8 +130,14 @@ func flushToolSieve(state *toolStreamSieveState, toolNames []string) []toolStrea } if state.pending.Len() > 0 { content := state.pending.String() - state.noteText(content) - events = append(events, toolStreamEvent{Content: content}) + // Safety: if pending contains XML tool tag fragments (e.g. "tool_calls>" + // from a split closing tag), swallow them instead of leaking. + if hasOpenXMLToolTag(content) || looksLikeXMLToolTagFragment(content) { + // Drop it — likely an incomplete tool call fragment. + } else { + state.noteText(content) + events = append(events, toolStreamEvent{Content: content}) + } state.pending.Reset() } return events diff --git a/internal/adapter/openai/tool_sieve_xml.go b/internal/adapter/openai/tool_sieve_xml.go index c4474af..aa97b5e 100644 --- a/internal/adapter/openai/tool_sieve_xml.go +++ b/internal/adapter/openai/tool_sieve_xml.go @@ -104,3 +104,27 @@ func findPartialXMLToolTagStart(s string) int { } return -1 } + +// looksLikeXMLToolTagFragment returns true if s looks like a fragment from a +// split XML tool call tag — for example "tool_calls>" or "/tool_call>\n". +// These fragments arise when '<' was consumed separately and the tail remains. +func looksLikeXMLToolTagFragment(s string) bool { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return false + } + lower := strings.ToLower(trimmed) + // Check for closing tag tails like "tool_calls>" or "/tool_calls>" + fragments := []string{ + "tool_calls>", "tool_call>", "/tool_calls>", "/tool_call>", + "function_calls>", "function_call>", "/function_calls>", "/function_call>", + "invoke>", "/invoke>", "tool_use>", "/tool_use>", + "tool_name>", "/tool_name>", "parameters>", "/parameters>", + } + for _, f := range fragments { + if strings.Contains(lower, f) { + return true + } + } + return false +} diff --git a/internal/js/helpers/stream-tool-sieve/sieve-xml.js b/internal/js/helpers/stream-tool-sieve/sieve-xml.js index e286b92..7e61b24 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve-xml.js +++ b/internal/js/helpers/stream-tool-sieve/sieve-xml.js @@ -79,8 +79,22 @@ function findPartialXMLToolTagStart(s) { return -1; } +function looksLikeXMLToolTagFragment(s) { + const trimmed = (s || '').trim(); + if (!trimmed) return false; + const lower = trimmed.toLowerCase(); + const fragments = [ + 'tool_calls>', 'tool_call>', '/tool_calls>', '/tool_call>', + 'function_calls>', 'function_call>', '/function_calls>', '/function_call>', + 'invoke>', '/invoke>', 'tool_use>', '/tool_use>', + 'tool_name>', '/tool_name>', 'parameters>', '/parameters>', + ]; + return fragments.some(f => lower.includes(f)); +} + module.exports = { consumeXMLToolCapture, hasOpenXMLToolTag, findPartialXMLToolTagStart, + looksLikeXMLToolTagFragment, }; diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 43c9224..fca7683 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -15,6 +15,7 @@ const { consumeXMLToolCapture: consumeXMLToolCaptureImpl, hasOpenXMLToolTag, findPartialXMLToolTagStart, + looksLikeXMLToolTagFragment, } = require('./sieve-xml'); function processToolSieveChunk(state, chunk, toolNames) { if (!state) { @@ -123,8 +124,10 @@ function flushToolSieve(state, toolNames) { resetIncrementalToolState(state); } if (state.pending) { - noteText(state, state.pending); - events.push({ type: 'text', text: state.pending }); + if (!hasOpenXMLToolTag(state.pending) && !looksLikeXMLToolTagFragment(state.pending)) { + noteText(state, state.pending); + events.push({ type: 'text', text: state.pending }); + } state.pending = ''; } return events;