feat: suppress output of partial XML tool tag fragments in stream processing

This commit is contained in:
CJACK
2026-03-29 14:59:30 +08:00
parent 4b42fe9086
commit 3ab9d44f60
4 changed files with 51 additions and 4 deletions

View File

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

View File

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

View File

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

View File

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