From 30a53b6c43098040356b79d37fffeef498d4ed77 Mon Sep 17 00:00:00 2001 From: CJACK Date: Mon, 30 Mar 2026 00:20:38 +0800 Subject: [PATCH] refactor: remove legacy TOOL_CALL_HISTORY/TOOL_RESULT_HISTORY markers and consolidate tool call formatting into a new prompt package --- internal/adapter/claude/handler_util_test.go | 10 +- internal/adapter/claude/handler_utils.go | 3 +- internal/adapter/openai/message_normalize.go | 43 ++---- .../adapter/openai/message_normalize_test.go | 53 +++++--- internal/adapter/openai/prompt_build_test.go | 7 +- .../adapter/openai/responses_input_items.go | 27 +--- .../adapter/openai/tool_history_sanitize.go | 4 +- .../openai/tool_history_sanitize_test.go | 95 -------------- internal/adapter/openai/tool_sieve_core.go | 7 +- .../adapter/openai/tool_sieve_jsonscan.go | 25 ---- .../js/helpers/stream-tool-sieve/jsonscan.js | 25 ---- .../js/helpers/stream-tool-sieve/sieve.js | 13 +- .../stream-tool-sieve/tool-keywords.js | 2 - internal/prompt/tool_calls.go | 124 ++++++++++++++++++ internal/prompt/tool_calls_test.go | 28 ++++ internal/util/toolcalls_candidates.go | 2 +- internal/util/toolcalls_textkv_test.go | 2 - tests/node/stream-tool-sieve.test.js | 52 -------- 18 files changed, 220 insertions(+), 302 deletions(-) create mode 100644 internal/prompt/tool_calls.go create mode 100644 internal/prompt/tool_calls_test.go diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index ad467c0..0b6085b 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -93,8 +93,11 @@ func TestNormalizeClaudeMessagesToolUseToAssistantToolCalls(t *testing.T) { t.Fatalf("expected call id preserved, got %#v", call) } content, _ := m["content"].(string) - if !containsStr(content, "search_web") || !containsStr(content, `"arguments":"{\"query\":\"latest\"}"`) { - t.Fatalf("expected assistant content to include serialized tool call for prompt roundtrip, got %q", content) + if !containsStr(content, "") || !containsStr(content, "search_web") { + t.Fatalf("expected assistant content to include XML tool call history, got %q", content) + } + if !containsStr(content, `{"query":"latest"}`) { + t.Fatalf("expected assistant content to include serialized parameters, got %q", content) } } @@ -251,9 +254,6 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "") { t.Fatalf("expected XML tool_calls format in prompt") } - if containsStr(prompt, "TOOL_CALL_HISTORY") || containsStr(prompt, "TOOL_RESULT_HISTORY") { - t.Fatalf("expected legacy tool history markers removed from prompt") - } if !containsStr(prompt, "TOOL CALL FORMAT") { t.Fatalf("expected tool call format header in prompt") } diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index 4aa28a1..c46e37a 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "ds2api/internal/prompt" "ds2api/internal/util" ) @@ -153,7 +154,7 @@ func normalizeClaudeToolUseToAssistant(block map[string]any) map[string]any { } return map[string]any{ "role": "assistant", - "content": marshalCompactJSON(toolCalls), + "content": prompt.FormatToolCallsForPrompt(toolCalls), "tool_calls": toolCalls, } } diff --git a/internal/adapter/openai/message_normalize.go b/internal/adapter/openai/message_normalize.go index 27d1b2b..94c67ad 100644 --- a/internal/adapter/openai/message_normalize.go +++ b/internal/adapter/openai/message_normalize.go @@ -1,7 +1,6 @@ package openai import ( - "encoding/json" "strings" "ds2api/internal/prompt" @@ -55,7 +54,18 @@ func normalizeOpenAIMessagesForPrompt(raw []any, traceID string) []map[string]an } func buildAssistantContentForPrompt(msg map[string]any) string { - return strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + content := strings.TrimSpace(normalizeOpenAIContentForPrompt(msg["content"])) + toolHistory := prompt.FormatToolCallsForPrompt(msg["tool_calls"]) + switch { + case content == "" && toolHistory == "": + return "" + case content == "": + return toolHistory + case toolHistory == "": + return content + default: + return content + "\n\n" + toolHistory + } } func buildToolContentForPrompt(msg map[string]any) string { @@ -70,18 +80,6 @@ func normalizeOpenAIContentForPrompt(v any) string { return prompt.NormalizeContent(v) } -func normalizeToolArgumentString(raw string) string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return "" - } - if looksLikeConcatenatedJSON(trimmed) { - // Keep original payload to avoid silent argument rewrites. - return raw - } - return trimmed -} - func normalizeOpenAIRoleForPrompt(role string) string { role = strings.ToLower(strings.TrimSpace(role)) if role == "developer" { @@ -96,20 +94,3 @@ func asString(v any) string { } return "" } - -func looksLikeConcatenatedJSON(raw string) bool { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return false - } - if strings.Contains(trimmed, "}{") || strings.Contains(trimmed, "][") { - return true - } - dec := json.NewDecoder(strings.NewReader(trimmed)) - var first any - if err := dec.Decode(&first); err != nil { - return false - } - var second any - return dec.Decode(&second) == nil -} diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/adapter/openai/message_normalize_test.go index 31c37d4..00b3ef4 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/adapter/openai/message_normalize_test.go @@ -34,20 +34,23 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes } normalized := normalizeOpenAIMessagesForPrompt(raw, "") - if len(normalized) != 3 { - t.Fatalf("expected 3 normalized messages with tool-call-only assistant turn omitted, got %d", len(normalized)) + if len(normalized) != 4 { + t.Fatalf("expected 4 normalized messages with assistant tool history preserved, got %d", len(normalized)) } - toolContent, _ := normalized[2]["content"].(string) - if !strings.Contains(toolContent, `"temp":18`) { - t.Fatalf("tool result should be transparently forwarded, got %q", toolContent) + assistantContent, _ := normalized[2]["content"].(string) + if !strings.Contains(assistantContent, "") { + t.Fatalf("assistant tool history should be preserved in XML form, got %q", assistantContent) } - if strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") { - t.Fatalf("tool history marker should not be injected: %q", toolContent) + if !strings.Contains(assistantContent, "get_weather") { + t.Fatalf("expected tool name in preserved history, got %q", assistantContent) + } + if !strings.Contains(normalized[3]["content"].(string), `"temp":18`) { + t.Fatalf("tool result should be transparently forwarded, got %#v", normalized[3]["content"]) } prompt := util.MessagesPrepare(normalized) - if strings.Contains(prompt, "[TOOL_CALL_HISTORY]") || strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") { - t.Fatalf("expected no synthetic history markers in prompt: %q", prompt) + if !strings.Contains(prompt, "") { + t.Fatalf("expected preserved assistant tool history in prompt: %q", prompt) } } @@ -170,8 +173,15 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantMultipleToolCallsRemainSepara } normalized := normalizeOpenAIMessagesForPrompt(raw, "") - if len(normalized) != 0 { - t.Fatalf("expected assistant tool_call-only message omitted, got %#v", normalized) + if len(normalized) != 1 { + t.Fatalf("expected assistant tool_call-only message preserved, got %#v", normalized) + } + content, _ := normalized[0]["content"].(string) + if strings.Count(content, "") != 2 { + t.Fatalf("expected two preserved tool call blocks, got %q", content) + } + if !strings.Contains(content, "search_web") || !strings.Contains(content, "eval_javascript") { + t.Fatalf("expected both tool names in preserved history, got %q", content) } } @@ -192,8 +202,12 @@ func TestNormalizeOpenAIMessagesForPrompt_PreservesConcatenatedToolArguments(t * } normalized := normalizeOpenAIMessagesForPrompt(raw, "") - if len(normalized) != 0 { - t.Fatalf("expected assistant tool_call-only content omitted, got %#v", normalized) + if len(normalized) != 1 { + t.Fatalf("expected assistant tool_call-only content preserved, got %#v", normalized) + } + content, _ := normalized[0]["content"].(string) + if !strings.Contains(content, `{}{"query":"测试工具调用"}`) { + t.Fatalf("expected concatenated tool arguments preserved, got %q", content) } } @@ -215,7 +229,7 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsMissingNameAreDroppe normalized := normalizeOpenAIMessagesForPrompt(raw, "") if len(normalized) != 0 { - t.Fatalf("expected assistant tool_calls without text omitted, got %#v", normalized) + t.Fatalf("expected assistant tool_calls without text to be dropped when name is missing, got %#v", normalized) } } @@ -237,8 +251,15 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantNilContentDoesNotInjectNullLi } normalized := normalizeOpenAIMessagesForPrompt(raw, "") - if len(normalized) != 0 { - t.Fatalf("expected nil-content assistant tool_call-only message omitted, got %#v", normalized) + if len(normalized) != 1 { + t.Fatalf("expected nil-content assistant tool_call-only message preserved, got %#v", normalized) + } + content, _ := normalized[0]["content"].(string) + if strings.Contains(content, "null") { + t.Fatalf("expected no null literal injection, got %q", content) + } + if !strings.Contains(content, "") { + t.Fatalf("expected assistant tool history in normalized content, got %q", content) } } diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/adapter/openai/prompt_build_test.go index bf8b285..223689b 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/adapter/openai/prompt_build_test.go @@ -47,8 +47,11 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes if !strings.Contains(finalPrompt, `"condition":"sunny"`) { t.Fatalf("handler finalPrompt should preserve tool output content: %q", finalPrompt) } - if strings.Contains(finalPrompt, "[TOOL_CALL_HISTORY]") || strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") { - t.Fatalf("handler finalPrompt should not include synthetic history markers: %q", finalPrompt) + if !strings.Contains(finalPrompt, "") { + t.Fatalf("handler finalPrompt should preserve assistant tool history: %q", finalPrompt) + } + if !strings.Contains(finalPrompt, "get_weather") { + t.Fatalf("handler finalPrompt should include tool name history: %q", finalPrompt) } } diff --git a/internal/adapter/openai/responses_input_items.go b/internal/adapter/openai/responses_input_items.go index c12b58d..6c42b38 100644 --- a/internal/adapter/openai/responses_input_items.go +++ b/internal/adapter/openai/responses_input_items.go @@ -1,11 +1,11 @@ package openai import ( - "encoding/json" "fmt" "strings" "ds2api/internal/config" + "ds2api/internal/prompt" ) func normalizeResponsesInputItem(m map[string]any) map[string]any { @@ -148,7 +148,7 @@ func normalizeResponsesInputItemWithState(m map[string]any, callNameByID map[str functionPayload := map[string]any{ "name": name, - "arguments": stringifyToolCallArguments(argsRaw), + "arguments": prompt.StringifyToolCallArguments(argsRaw), } call := map[string]any{ "type": "function", @@ -211,26 +211,3 @@ func normalizeResponsesFallbackPart(m map[string]any) string { } return strings.TrimSpace(fmt.Sprintf("%v", m)) } - -func stringifyToolCallArguments(v any) string { - switch x := v.(type) { - case nil: - return "{}" - case string: - s := strings.TrimSpace(x) - if s == "" { - return "{}" - } - s = normalizeToolArgumentString(s) - if s == "" { - return "{}" - } - return s - default: - b, err := json.Marshal(x) - if err != nil || len(b) == 0 { - return "{}" - } - return string(b) - } -} diff --git a/internal/adapter/openai/tool_history_sanitize.go b/internal/adapter/openai/tool_history_sanitize.go index b2c740d..fde447e 100644 --- a/internal/adapter/openai/tool_history_sanitize.go +++ b/internal/adapter/openai/tool_history_sanitize.go @@ -4,7 +4,6 @@ import ( "regexp" ) -var leakedToolHistoryPattern = regexp.MustCompile(`(?is)\[TOOL_CALL_HISTORY\][\s\S]*?\[/TOOL_CALL_HISTORY\]|\[TOOL_RESULT_HISTORY\][\s\S]*?\[/TOOL_RESULT_HISTORY\]`) var emptyJSONFencePattern = regexp.MustCompile("(?is)```json\\s*```") var leakedToolCallArrayPattern = regexp.MustCompile(`(?is)\[\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"call[^"]*"\s*,\s*"type"\s*:\s*"function"\s*}\]`) var leakedToolResultBlobPattern = regexp.MustCompile(`(?is)<\s*\|\s*tool\s*\|\s*>\s*\{[\s\S]*?"tool_call_id"\s*:\s*"call[^"]*"\s*}`) @@ -22,8 +21,7 @@ func sanitizeLeakedToolHistory(text string) string { if text == "" { return text } - out := leakedToolHistoryPattern.ReplaceAllString(text, "") - out = emptyJSONFencePattern.ReplaceAllString(out, "") + out := emptyJSONFencePattern.ReplaceAllString(text, "") out = leakedToolCallArrayPattern.ReplaceAllString(out, "") out = leakedToolResultBlobPattern.ReplaceAllString(out, "") out = leakedMetaMarkerPattern.ReplaceAllString(out, "") diff --git a/internal/adapter/openai/tool_history_sanitize_test.go b/internal/adapter/openai/tool_history_sanitize_test.go index 5c12fb0..69063a2 100644 --- a/internal/adapter/openai/tool_history_sanitize_test.go +++ b/internal/adapter/openai/tool_history_sanitize_test.go @@ -2,47 +2,6 @@ package openai import "testing" -func TestSanitizeLeakedToolHistoryRemovesMarkerBlocks(t *testing.T) { - raw := "前缀\n[TOOL_CALL_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]\n后缀" - got := sanitizeLeakedToolHistory(raw) - if got != "前缀\n\n后缀" { - t.Fatalf("unexpected sanitized content: %q", got) - } -} - -func TestSanitizeLeakedToolHistoryPreservesChunkWhitespace(t *testing.T) { - cases := []struct { - name string - raw string - want string - }{ - { - name: "trailing space kept", - raw: "Hello ", - want: "Hello ", - }, - { - name: "leading newline kept", - raw: "\nworld", - want: "\nworld", - }, - { - name: "surrounding whitespace around marker is preserved", - raw: "A \n[TOOL_RESULT_HISTORY]\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]\n B", - want: "A \n\n B", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := sanitizeLeakedToolHistory(tc.raw) - if got != tc.want { - t.Fatalf("unexpected sanitize result, want %q got %q", tc.want, got) - } - }) - } -} - func TestSanitizeLeakedToolHistoryRemovesEmptyJSONFence(t *testing.T) { raw := "before\n```json\n```\nafter" got := sanitizeLeakedToolHistory(raw) @@ -51,32 +10,6 @@ func TestSanitizeLeakedToolHistoryRemovesEmptyJSONFence(t *testing.T) { } } -func TestFlushToolSieveDropsToolHistoryLeak(t *testing.T) { - var state toolStreamSieveState - chunk := "[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]" - evts := processToolSieveChunk(&state, chunk, []string{"exec"}) - if len(evts) != 0 { - t.Fatalf("expected no immediate output before history block is complete, got %+v", evts) - } - flushed := flushToolSieve(&state, []string{"exec"}) - if len(flushed) != 0 { - t.Fatalf("expected history block to be swallowed, got %+v", flushed) - } -} - -func TestFlushToolSieveDropsToolResultHistoryLeak(t *testing.T) { - var state toolStreamSieveState - chunk := "[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]" - evts := processToolSieveChunk(&state, chunk, []string{"exec"}) - if len(evts) != 0 { - t.Fatalf("expected no immediate output before result history block is complete, got %+v", evts) - } - flushed := flushToolSieve(&state, []string{"exec"}) - if len(flushed) != 0 { - t.Fatalf("expected result history block to be swallowed, got %+v", flushed) - } -} - func TestSanitizeLeakedToolHistoryRemovesLeakedWireToolCallAndResult(t *testing.T) { raw := "开始\n[{\"function\":{\"arguments\":\"{\\\"command\\\":\\\"java -version\\\"}\",\"name\":\"exec\"},\"id\":\"callb9a321\",\"type\":\"function\"}]< | Tool | >{\"content\":\"openjdk version 21\",\"tool_call_id\":\"callb9a321\"}\n结束" got := sanitizeLeakedToolHistory(raw) @@ -100,31 +33,3 @@ func TestSanitizeLeakedToolHistoryRemovesAgentXMLLeaks(t *testing.T) { t.Fatalf("unexpected sanitize result for agent XML leak: %q", got) } } - -func TestProcessToolSieveChunkSplitsResultHistoryBoundary(t *testing.T) { - var state toolStreamSieveState - parts := []string{ - "Hello ", - "[TOOL_RESULT_HISTORY]\nstatus: already_called\n", - "function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]", - "world", - } - var events []toolStreamEvent - for _, p := range parts { - events = append(events, processToolSieveChunk(&state, p, []string{"exec"})...) - } - events = append(events, flushToolSieve(&state, []string{"exec"})...) - - var text string - for _, evt := range events { - if evt.Content != "" { - text += evt.Content - } - if len(evt.ToolCalls) > 0 { - t.Fatalf("did not expect parsed tool calls from history leak: %+v", evt.ToolCalls) - } - } - if text != "Hello world" { - t.Fatalf("expected clean text output preserving boundary spaces, got %q", text) - } -} diff --git a/internal/adapter/openai/tool_sieve_core.go b/internal/adapter/openai/tool_sieve_core.go index 9cacea9..9e04d85 100644 --- a/internal/adapter/openai/tool_sieve_core.go +++ b/internal/adapter/openai/tool_sieve_core.go @@ -183,7 +183,7 @@ func findToolSegmentStart(s string) int { return -1 } lower := strings.ToLower(s) - keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:"} bestKeyIdx := -1 for _, kw := range keywords { idx := strings.Index(lower, kw) @@ -240,7 +240,7 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix lower := strings.ToLower(captured) keyIdx := -1 - keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]", "[tool_result_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:"} for _, kw := range keywords { idx := strings.Index(lower, kw) if idx >= 0 && (keyIdx < 0 || idx < keyIdx) { @@ -253,9 +253,6 @@ func consumeToolCapture(state *toolStreamSieveState, toolNames []string) (prefix } start := strings.LastIndex(captured[:keyIdx], "{") if start < 0 { - if blockStart, blockEnd, ok := extractToolHistoryBlock(captured, keyIdx); ok { - return captured[:blockStart], nil, captured[blockEnd:], true - } start = keyIdx } obj, end, ok := extractJSONObjectFrom(captured, start) diff --git a/internal/adapter/openai/tool_sieve_jsonscan.go b/internal/adapter/openai/tool_sieve_jsonscan.go index deb745a..57a808b 100644 --- a/internal/adapter/openai/tool_sieve_jsonscan.go +++ b/internal/adapter/openai/tool_sieve_jsonscan.go @@ -44,31 +44,6 @@ func extractJSONObjectFrom(text string, start int) (string, int, bool) { return "", 0, false } -func extractToolHistoryBlock(captured string, keyIdx int) (start int, end int, ok bool) { - if keyIdx < 0 || keyIdx >= len(captured) { - return 0, 0, false - } - rest := strings.ToLower(captured[keyIdx:]) - switch { - case strings.HasPrefix(rest, "[tool_call_history]"): - closeTag := "[/tool_call_history]" - closeIdx := strings.Index(rest, closeTag) - if closeIdx < 0 { - return 0, 0, false - } - return keyIdx, keyIdx + closeIdx + len(closeTag), true - case strings.HasPrefix(rest, "[tool_result_history]"): - closeTag := "[/tool_result_history]" - closeIdx := strings.Index(rest, closeTag) - if closeIdx < 0 { - return 0, 0, false - } - return keyIdx, keyIdx + closeIdx + len(closeTag), true - default: - return 0, 0, false - } -} - func trimWrappingJSONFence(prefix, suffix string) (string, string) { trimmedPrefix := strings.TrimRight(prefix, " \t\r\n") fenceIdx := strings.LastIndex(trimmedPrefix, "```") diff --git a/internal/js/helpers/stream-tool-sieve/jsonscan.js b/internal/js/helpers/stream-tool-sieve/jsonscan.js index 4f68d92..1141773 100644 --- a/internal/js/helpers/stream-tool-sieve/jsonscan.js +++ b/internal/js/helpers/stream-tool-sieve/jsonscan.js @@ -140,30 +140,6 @@ function extractJSONObjectFrom(text, start) { return { ok: false, end: 0 }; } -function extractToolHistoryBlock(captured, keyIdx) { - if (typeof captured !== 'string' || keyIdx < 0 || keyIdx >= captured.length) { - return { ok: false, start: 0, end: 0 }; - } - const rest = captured.slice(keyIdx).toLowerCase(); - if (rest.startsWith('[tool_call_history]')) { - const closeTag = '[/tool_call_history]'; - const closeIdx = rest.indexOf(closeTag); - if (closeIdx < 0) { - return { ok: false, start: 0, end: 0 }; - } - return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length }; - } - if (rest.startsWith('[tool_result_history]')) { - const closeTag = '[/tool_result_history]'; - const closeIdx = rest.indexOf(closeTag); - if (closeIdx < 0) { - return { ok: false, start: 0, end: 0 }; - } - return { ok: true, start: keyIdx, end: keyIdx + closeIdx + closeTag.length }; - } - return { ok: false, start: 0, end: 0 }; -} - function trimWrappingJSONFence(prefix, suffix) { const rightTrimmedPrefix = (prefix || '').replace(/[ \t\r\n]+$/g, ''); const fenceIdx = rightTrimmedPrefix.lastIndexOf('```'); @@ -192,6 +168,5 @@ module.exports = { parseJSONStringLiteral, skipSpaces, extractJSONObjectFrom, - extractToolHistoryBlock, trimWrappingJSONFence, }; diff --git a/internal/js/helpers/stream-tool-sieve/sieve.js b/internal/js/helpers/stream-tool-sieve/sieve.js index 9fa5b4c..3250c86 100644 --- a/internal/js/helpers/stream-tool-sieve/sieve.js +++ b/internal/js/helpers/stream-tool-sieve/sieve.js @@ -5,7 +5,7 @@ const { insideCodeFenceWithState, } = require('./state'); const { parseStandaloneToolCallsDetailed } = require('./parse'); -const { extractJSONObjectFrom, extractToolHistoryBlock, trimWrappingJSONFence } = require('./jsonscan'); +const { extractJSONObjectFrom, trimWrappingJSONFence } = require('./jsonscan'); const { TOOL_SEGMENT_KEYWORDS, XML_TOOL_SEGMENT_TAGS, @@ -233,17 +233,6 @@ function consumeToolCapture(state, toolNames) { } const start = captured.slice(0, keyIdx).lastIndexOf('{'); const actualStart = start >= 0 ? start : keyIdx; - if (start < 0) { - const history = extractToolHistoryBlock(captured, keyIdx); - if (history.ok) { - return { - ready: true, - prefix: captured.slice(0, history.start), - calls: [], - suffix: captured.slice(history.end), - }; - } - } const obj = extractJSONObjectFrom(captured, actualStart); if (!obj.ok) { return { ready: false, prefix: '', calls: [], suffix: '' }; diff --git a/internal/js/helpers/stream-tool-sieve/tool-keywords.js b/internal/js/helpers/stream-tool-sieve/tool-keywords.js index ea35f1e..29896dc 100644 --- a/internal/js/helpers/stream-tool-sieve/tool-keywords.js +++ b/internal/js/helpers/stream-tool-sieve/tool-keywords.js @@ -4,8 +4,6 @@ const TOOL_SEGMENT_KEYWORDS = [ 'tool_calls', '"function"', 'function.name:', - '[tool_call_history]', - '[tool_result_history]', ]; const XML_TOOL_SEGMENT_TAGS = [ diff --git a/internal/prompt/tool_calls.go b/internal/prompt/tool_calls.go new file mode 100644 index 0000000..718c48b --- /dev/null +++ b/internal/prompt/tool_calls.go @@ -0,0 +1,124 @@ +package prompt + +import ( + "encoding/json" + "strings" +) + +// FormatToolCallsForPrompt renders a tool_calls slice into the canonical +// prompt-visible history block used across adapters. +func FormatToolCallsForPrompt(raw any) string { + calls, ok := raw.([]any) + if !ok || len(calls) == 0 { + return "" + } + + blocks := make([]string, 0, len(calls)) + for _, item := range calls { + call, ok := item.(map[string]any) + if !ok { + continue + } + block := formatToolCallForPrompt(call) + if block != "" { + blocks = append(blocks, block) + } + } + if len(blocks) == 0 { + return "" + } + return "\n" + strings.Join(blocks, "\n") + "\n" +} + +// StringifyToolCallArguments normalizes tool arguments into a compact string +// while preserving raw concatenated payloads when they already look like model +// output rather than a single JSON object. +func StringifyToolCallArguments(v any) string { + switch x := v.(type) { + case nil: + return "{}" + case string: + s := strings.TrimSpace(x) + if s == "" { + return "{}" + } + s = normalizeToolArgumentString(s) + if s == "" { + return "{}" + } + return s + default: + b, err := json.Marshal(x) + if err != nil || len(b) == 0 { + return "{}" + } + return string(b) + } +} + +func formatToolCallForPrompt(call map[string]any) string { + if call == nil { + return "" + } + + name := strings.TrimSpace(asString(call["name"])) + fn, _ := call["function"].(map[string]any) + if name == "" && fn != nil { + name = strings.TrimSpace(asString(fn["name"])) + } + if name == "" { + return "" + } + + argsRaw := call["arguments"] + if argsRaw == nil { + argsRaw = call["input"] + } + if argsRaw == nil && fn != nil { + argsRaw = fn["arguments"] + if argsRaw == nil { + argsRaw = fn["input"] + } + } + + return " \n" + + " " + name + "\n" + + " " + StringifyToolCallArguments(argsRaw) + "\n" + + " " +} + +func normalizeToolArgumentString(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if looksLikeConcatenatedJSON(trimmed) { + // Keep the original payload to avoid silently rewriting model output. + return raw + } + return trimmed +} + +func looksLikeConcatenatedJSON(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return false + } + if strings.Contains(trimmed, "}{") || strings.Contains(trimmed, "][") { + return true + } + dec := json.NewDecoder(strings.NewReader(trimmed)) + var first any + if err := dec.Decode(&first); err != nil { + return false + } + var second any + return dec.Decode(&second) == nil +} + +func asString(v any) string { + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/internal/prompt/tool_calls_test.go b/internal/prompt/tool_calls_test.go new file mode 100644 index 0000000..8ad4407 --- /dev/null +++ b/internal/prompt/tool_calls_test.go @@ -0,0 +1,28 @@ +package prompt + +import "testing" + +func TestStringifyToolCallArgumentsPreservesConcatenatedJSON(t *testing.T) { + got := StringifyToolCallArguments(`{}{"query":"测试工具调用"}`) + if got != `{}{"query":"测试工具调用"}` { + t.Fatalf("expected raw concatenated JSON to be preserved, got %q", got) + } +} + +func TestFormatToolCallsForPromptXML(t *testing.T) { + got := FormatToolCallsForPrompt([]any{ + map[string]any{ + "id": "call_1", + "function": map[string]any{ + "name": "search_web", + "arguments": map[string]any{"query": "latest"}, + }, + }, + }) + if got == "" { + t.Fatal("expected non-empty formatted tool calls") + } + if got != "\n \n search_web\n {\"query\":\"latest\"}\n \n" { + t.Fatalf("unexpected formatted tool call XML: %q", got) + } +} diff --git a/internal/util/toolcalls_candidates.go b/internal/util/toolcalls_candidates.go index cbce3ef..d7dfe92 100644 --- a/internal/util/toolcalls_candidates.go +++ b/internal/util/toolcalls_candidates.go @@ -64,7 +64,7 @@ func extractToolCallObjects(text string) []string { lower := strings.ToLower(text) out := []string{} offset := 0 - keywords := []string{"tool_calls", "\"function\"", "function.name:", "[tool_call_history]"} + keywords := []string{"tool_calls", "\"function\"", "function.name:"} for { bestIdx := -1 matchedKeyword := "" diff --git a/internal/util/toolcalls_textkv_test.go b/internal/util/toolcalls_textkv_test.go index 337c4a5..91ed807 100644 --- a/internal/util/toolcalls_textkv_test.go +++ b/internal/util/toolcalls_textkv_test.go @@ -6,14 +6,12 @@ import ( func TestParseTextKVToolCalls_Basic(t *testing.T) { text := ` -[TOOL_CALL_HISTORY] status: already_called origin: assistant not_user_input: true tool_call_id: call_3fcd15235eb94f7eae3a8de5a9cfa36b 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... ` diff --git a/tests/node/stream-tool-sieve.test.js b/tests/node/stream-tool-sieve.test.js index 936a815..33b666e 100644 --- a/tests/node/stream-tool-sieve.test.js +++ b/tests/node/stream-tool-sieve.test.js @@ -98,10 +98,8 @@ test('parseToolCalls ignores tool_call payloads that exist only inside fenced co 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']); @@ -254,56 +252,6 @@ test('sieve keeps plain text intact in tool mode when no tool call appears', () assert.equal(leakedText, '你好,这是普通文本回复。请继续。'); }); -test('sieve swallows leaked TOOL_CALL_HISTORY marker blocks', () => { - const events = runSieve( - [ - '前置文本。', - '[TOOL_CALL_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_CALL_HISTORY]', - '后置文本。', - ], - ['exec'], - ); - const leakedText = collectText(events); - const hasToolCall = events.some((evt) => evt.type === 'tool_calls'); - assert.equal(hasToolCall, false); - assert.equal(leakedText.includes('前置文本。'), true); - assert.equal(leakedText.includes('后置文本。'), true); - assert.equal(leakedText.includes('[TOOL_CALL_HISTORY]'), false); -}); - -test('sieve swallows leaked TOOL_RESULT_HISTORY marker blocks', () => { - const events = runSieve( - [ - '前置文本。', - '[TOOL_RESULT_HISTORY]\nstatus: already_called\nfunction.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]', - '后置文本。', - ], - ['exec'], - ); - const leakedText = collectText(events); - const hasToolCall = events.some((evt) => evt.type === 'tool_calls'); - assert.equal(hasToolCall, false); - assert.equal(leakedText.includes('前置文本。'), true); - assert.equal(leakedText.includes('后置文本。'), true); - assert.equal(leakedText.includes('[TOOL_RESULT_HISTORY]'), false); -}); - -test('sieve preserves text spacing when TOOL_RESULT_HISTORY spans chunks', () => { - const events = runSieve( - [ - 'Hello ', - '[TOOL_RESULT_HISTORY]\nstatus: already_called\n', - 'function.name: exec\nfunction.arguments: {}\n[/TOOL_RESULT_HISTORY]', - 'world', - ], - ['exec'], - ); - const leakedText = collectText(events); - const hasToolCall = events.some((evt) => evt.type === 'tool_calls' && evt.calls?.length > 0); - assert.equal(hasToolCall, false); - assert.equal(leakedText, 'Hello world'); -}); - test('sieve emits unknown tool payload (no args) as executable tool call', () => { const events = runSieve( ['{"tool_calls":[{"name":"not_in_schema"}]}', '后置正文G。'],