diff --git a/internal/js/helpers/stream-tool-sieve/parse.js b/internal/js/helpers/stream-tool-sieve/parse.js index 1b882ff..6e6ff7d 100644 --- a/internal/js/helpers/stream-tool-sieve/parse.js +++ b/internal/js/helpers/stream-tool-sieve/parse.js @@ -9,6 +9,7 @@ const { buildToolCallCandidates, parseToolCallsPayload, parseMarkupToolCalls, + parseTextKVToolCalls, } = require('./parse_payload'); const TOOL_NAME_LOOSE_PATTERN = /[^a-z0-9]+/g; @@ -53,13 +54,23 @@ function parseToolCallsDetailed(text, toolNames) { if (parsed.length === 0) { parsed = parseMarkupToolCalls(c); } + if (parsed.length === 0) { + parsed = parseTextKVToolCalls(c); + } if (parsed.length > 0) { result.sawToolCallSyntax = true; break; } } if (parsed.length === 0) { - return result; + parsed = parseMarkupToolCalls(sanitized); + if (parsed.length === 0) { + parsed = parseTextKVToolCalls(sanitized); + if (parsed.length === 0) { + return result; + } + } + result.sawToolCallSyntax = true; } const filtered = filterToolCallsDetailed(parsed, toolNames); @@ -90,6 +101,9 @@ function parseStandaloneToolCallsDetailed(text, toolNames) { if (parsed.length === 0) { parsed = parseMarkupToolCalls(trimmed); } + if (parsed.length === 0) { + parsed = parseTextKVToolCalls(trimmed); + } if (parsed.length === 0) { return result; } @@ -207,7 +221,8 @@ function looksLikeToolCallSyntax(text) { return lower.includes('tool_calls') || lower.includes(']*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?args>/i, /<(?:[a-z0-9_:-]+:)?params\b[^>]*>([\s\S]*?)<\/(?:[a-z0-9_:-]+:)?params>/i, ]; +const TEXT_KV_NAME_PATTERN = /function\.name:\s*([a-zA-Z0-9_.-]+)/gi; const { toStringSafe, @@ -141,6 +142,47 @@ function parseMarkupToolCalls(text) { return out; } +function parseTextKVToolCalls(text) { + const raw = toStringSafe(text); + if (!raw) { + return []; + } + const out = []; + const matches = [...raw.matchAll(TEXT_KV_NAME_PATTERN)]; + if (matches.length === 0) { + return out; + } + for (let i = 0; i < matches.length; i += 1) { + const match = matches[i]; + const name = toStringSafe(match[1]).trim(); + if (!name) { + continue; + } + const nameEnd = match.index + toStringSafe(match[0]).length; + const searchEnd = i + 1 < matches.length ? matches[i + 1].index : raw.length; + const searchArea = raw.slice(nameEnd, searchEnd); + const argIdx = searchArea.indexOf('function.arguments:'); + if (argIdx < 0) { + continue; + } + const argStart = nameEnd + argIdx + 'function.arguments:'.length; + const bracePos = raw.slice(argStart, searchEnd).indexOf('{'); + if (bracePos < 0) { + continue; + } + const objStart = argStart + bracePos; + const obj = extractJSONObjectFrom(raw, objStart); + if (!obj.ok) { + continue; + } + out.push({ + name, + input: parseToolCallInput(raw.slice(objStart, obj.end)), + }); + } + return out; +} + function parseMarkupSingleToolCall(attrs, inner) { const embedded = parseToolCallsPayload(inner); if (embedded.length > 0) { @@ -317,4 +359,5 @@ module.exports = { buildToolCallCandidates, parseToolCallsPayload, parseMarkupToolCalls, + parseTextKVToolCalls, }; diff --git a/internal/util/toolcalls_name_match.go b/internal/util/toolcalls_name_match.go new file mode 100644 index 0000000..c3d1f3f --- /dev/null +++ b/internal/util/toolcalls_name_match.go @@ -0,0 +1,33 @@ +package util + +import ( + "regexp" + "strings" +) + +var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`) + +func resolveAllowedToolNameWithLooseMatch(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string { + if _, ok := allowed[name]; ok { + return name + } + lower := strings.ToLower(strings.TrimSpace(name)) + if canonical, ok := allowedCanonical[lower]; ok { + return canonical + } + if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 { + if canonical, ok := allowedCanonical[lower[idx+1:]]; ok { + return canonical + } + } + loose := toolNameLoosePattern.ReplaceAllString(lower, "") + if loose == "" { + return "" + } + for candidateLower, canonical := range allowedCanonical { + if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose { + return canonical + } + } + return "" +} diff --git a/internal/util/toolcalls_parse.go b/internal/util/toolcalls_parse.go index d08ffe7..fb6d459 100644 --- a/internal/util/toolcalls_parse.go +++ b/internal/util/toolcalls_parse.go @@ -2,12 +2,9 @@ package util import ( "encoding/json" - "regexp" "strings" ) -var toolNameLoosePattern = regexp.MustCompile(`[^a-z0-9]+`) - type ParsedToolCall struct { Name string `json:"name"` Input map[string]any `json:"input"` @@ -168,28 +165,7 @@ func filterToolCallsDetailed(parsed []ParsedToolCall, availableToolNames []strin } func resolveAllowedToolName(name string, allowed map[string]struct{}, allowedCanonical map[string]string) string { - if _, ok := allowed[name]; ok { - return name - } - lower := strings.ToLower(strings.TrimSpace(name)) - if canonical, ok := allowedCanonical[lower]; ok { - return canonical - } - if idx := strings.LastIndex(lower, "."); idx >= 0 && idx < len(lower)-1 { - if canonical, ok := allowedCanonical[lower[idx+1:]]; ok { - return canonical - } - } - loose := toolNameLoosePattern.ReplaceAllString(lower, "") - if loose == "" { - return "" - } - for candidateLower, canonical := range allowedCanonical { - if toolNameLoosePattern.ReplaceAllString(candidateLower, "") == loose { - return canonical - } - } - return "" + return resolveAllowedToolNameWithLooseMatch(name, allowed, allowedCanonical) } func parseToolCallsPayload(payload string) []ParsedToolCall { diff --git a/internal/util/toolcalls_textkv_test.go b/internal/util/toolcalls_textkv_test.go index c32d9f2..337c4a5 100644 --- a/internal/util/toolcalls_textkv_test.go +++ b/internal/util/toolcalls_textkv_test.go @@ -50,3 +50,14 @@ function.arguments: {"command": "ls"} t.Fatalf("unexpected 2nd name: %s", calls[1].Name) } } + +func TestParseTextKVToolCalls_Standalone(t *testing.T) { + text := "function.name: read_file\nfunction.arguments: {\"path\":\"README.md\"}" + calls := ParseStandaloneToolCalls(text, []string{"read_file"}) + if len(calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(calls)) + } + if calls[0].Name != "read_file" { + t.Fatalf("unexpected name: %s", calls[0].Name) + } +} diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index f71279c..61d72d6 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -94,6 +94,34 @@ test('parseToolCalls supports fenced json and function.arguments string payload' assert.equal(calls.length, 0); }); +test('parseToolCalls parses text-kv fallback payload', () => { + const text = [ + '[TOOL_CALL_HISTORY]', + 'function.name: execute_command', + 'function.arguments: {"command":"cd scripts && python check_syntax.py example.py","cwd":null,"timeout":30}', + '[/TOOL_CALL_HISTORY]', + 'Some other text thinking...', + ].join('\n'); + const calls = parseToolCalls(text, ['execute_command']); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, 'execute_command'); + assert.equal(calls[0].input.command, 'cd scripts && python check_syntax.py example.py'); +}); + +test('parseToolCalls parses multiple text-kv fallback payloads', () => { + const text = [ + 'function.name: read_file', + 'function.arguments: {"path":"abc.txt"}', + '', + 'function.name: bash', + 'function.arguments: {"command":"ls"}', + ].join('\n'); + const calls = parseToolCalls(text, ['read_file', 'bash']); + assert.equal(calls.length, 2); + assert.equal(calls[0].name, 'read_file'); + assert.equal(calls[1].name, 'bash'); +}); + test('parseStandaloneToolCalls only matches standalone payload and ignores mixed prose', () => { const mixed = '这里是示例:{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]},请勿执行。'; const standalone = '{"tool_calls":[{"name":"read_file","input":{"path":"README.MD"}}]}';