diff --git a/internal/adapter/claude/handler.go b/internal/adapter/claude/handler.go index 282b569..593c1bc 100644 --- a/internal/adapter/claude/handler.go +++ b/internal/adapter/claude/handler.go @@ -250,7 +250,7 @@ func normalizeClaudeMessages(messages []any) []any { } } if typeStr == "tool_result" { - parts = append(parts, fmt.Sprintf("%v", b["content"])) + parts = append(parts, formatClaudeToolResultForPrompt(b)) } } copied["content"] = strings.Join(parts, "\n") @@ -272,10 +272,36 @@ func buildClaudeToolPrompt(tools []any) string { schema, _ := json.Marshal(m["input_schema"]) parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) } - parts = append(parts, "When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}") + parts = append(parts, + "When you need to use tools, you can call multiple tools in one response. Output ONLY JSON like {\"tool_calls\":[{\"name\":\"tool\",\"input\":{}}]}", + "History markers in conversation: [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] are your previous tool calls; [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] are runtime tool outputs, not user input.", + "After a valid [TOOL_RESULT_HISTORY], continue with final answer instead of repeating the same call unless required fields are still missing.", + ) return strings.Join(parts, "\n\n") } +func formatClaudeToolResultForPrompt(block map[string]any) string { + if block == nil { + return "" + } + toolCallID := strings.TrimSpace(fmt.Sprintf("%v", block["tool_use_id"])) + if toolCallID == "" { + toolCallID = strings.TrimSpace(fmt.Sprintf("%v", block["tool_call_id"])) + } + if toolCallID == "" { + toolCallID = "unknown" + } + name := strings.TrimSpace(fmt.Sprintf("%v", block["name"])) + if name == "" { + name = "unknown" + } + content := strings.TrimSpace(fmt.Sprintf("%v", block["content"])) + if content == "" { + content = "null" + } + return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content) +} + func hasSystemMessage(messages []any) bool { for _, m := range messages { msg, ok := m.(map[string]any) diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 73d2fab..ae75d8e 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -1,6 +1,7 @@ package claude import ( + "strings" "testing" ) @@ -48,8 +49,9 @@ func TestNormalizeClaudeMessagesToolResult(t *testing.T) { } got := normalizeClaudeMessages(msgs) m := got[0].(map[string]any) - if m["content"] != "tool output" { - t.Fatalf("expected 'tool output', got %q", m["content"]) + content, _ := m["content"].(string) + if !strings.Contains(content, "[TOOL_RESULT_HISTORY]") || !strings.Contains(content, "content: tool output") { + t.Fatalf("expected serialized tool result marker, got %q", content) } } diff --git a/internal/adapter/openai/handler.go b/internal/adapter/openai/handler.go index 28a451c..ac8666f 100644 --- a/internal/adapter/openai/handler.go +++ b/internal/adapter/openai/handler.go @@ -233,7 +233,7 @@ func injectToolPrompt(messages []map[string]any, tools []any) ([]map[string]any, if len(toolSchemas) == 0 { return messages, names } - toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error." + toolPrompt := "You have access to these tools:\n\n" + strings.Join(toolSchemas, "\n\n") + "\n\nWhen you need to use tools, output ONLY this JSON format (no other text):\n{\"tool_calls\": [{\"name\": \"tool_name\", \"input\": {\"param\": \"value\"}}]}\n\nHistory markers in conversation:\n- [TOOL_CALL_HISTORY]...[/TOOL_CALL_HISTORY] means a tool call you already made earlier.\n- [TOOL_RESULT_HISTORY]...[/TOOL_RESULT_HISTORY] means the runtime returned a tool result (not user input).\n\nIMPORTANT:\n1) If calling tools, output ONLY the JSON. The response must start with { and end with }.\n2) After receiving a tool result, you MUST use it to produce the final answer.\n3) Only call another tool when the previous result is missing required data or returned an error.\n4) Do not repeat a tool call that is already satisfied by an existing [TOOL_RESULT_HISTORY] block." for i := range messages { if messages[i]["role"] == "system" { diff --git a/internal/adapter/openai/message_normalize.go b/internal/adapter/openai/message_normalize.go index 3ebd1e7..c0ab7d2 100644 --- a/internal/adapter/openai/message_normalize.go +++ b/internal/adapter/openai/message_normalize.go @@ -86,7 +86,7 @@ func formatAssistantToolCallsForPrompt(msg map[string]any) string { if args == "" { args = "{}" } - entries = append(entries, fmt.Sprintf("Tool call:\n- tool_call_id: %s\n- function.name: %s\n- function.arguments: %s", id, name, args)) + entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: %s\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", id, name, args)) } } @@ -99,7 +99,7 @@ func formatAssistantToolCallsForPrompt(msg map[string]any) string { if args == "" { args = "{}" } - entries = append(entries, fmt.Sprintf("Tool call:\n- tool_call_id: call_legacy\n- function.name: %s\n- function.arguments: %s", name, args)) + entries = append(entries, fmt.Sprintf("[TOOL_CALL_HISTORY]\nstatus: already_called\norigin: assistant\nnot_user_input: true\ntool_call_id: call_legacy\nfunction.name: %s\nfunction.arguments: %s\n[/TOOL_CALL_HISTORY]", name, args)) } return strings.Join(entries, "\n\n") @@ -124,7 +124,7 @@ func formatToolResultForPrompt(msg map[string]any) string { content = "null" } - return fmt.Sprintf("Tool result:\n- tool_call_id: %s\n- name: %s\n- content: %s", toolCallID, name, content) + return fmt.Sprintf("[TOOL_RESULT_HISTORY]\nstatus: already_returned\norigin: tool_runtime\nnot_user_input: true\ntool_call_id: %s\nname: %s\ncontent: %s\n[/TOOL_RESULT_HISTORY]", toolCallID, name, content) } func normalizeOpenAIContentForPrompt(v any) string { diff --git a/internal/adapter/openai/message_normalize_test.go b/internal/adapter/openai/message_normalize_test.go index bb648d3..27849d7 100644 --- a/internal/adapter/openai/message_normalize_test.go +++ b/internal/adapter/openai/message_normalize_test.go @@ -38,18 +38,19 @@ func TestNormalizeOpenAIMessagesForPrompt_AssistantToolCallsAndToolResult(t *tes t.Fatalf("expected 4 normalized messages, got %d", len(normalized)) } assistantContent, _ := normalized[2]["content"].(string) - if !strings.Contains(assistantContent, "tool_call_id: call_1") || + if !strings.Contains(assistantContent, "[TOOL_CALL_HISTORY]") || + !strings.Contains(assistantContent, "tool_call_id: call_1") || !strings.Contains(assistantContent, "function.name: get_weather") || !strings.Contains(assistantContent, "function.arguments: {\"city\":\"beijing\"}") { t.Fatalf("assistant tool call not serialized correctly: %q", assistantContent) } toolContent, _ := normalized[3]["content"].(string) - if !strings.Contains(toolContent, "Tool result:") || !strings.Contains(toolContent, "name: get_weather") { + if !strings.Contains(toolContent, "[TOOL_RESULT_HISTORY]") || !strings.Contains(toolContent, "name: get_weather") { t.Fatalf("tool result not serialized correctly: %q", toolContent) } prompt := util.MessagesPrepare(normalized) - if !strings.Contains(prompt, "tool_call_id: call_1") || !strings.Contains(prompt, "Tool result:") { + if !strings.Contains(prompt, "tool_call_id: call_1") || !strings.Contains(prompt, "[TOOL_RESULT_HISTORY]") { t.Fatalf("expected prompt to include tool call + result semantics: %q", prompt) } } diff --git a/internal/adapter/openai/prompt_build_test.go b/internal/adapter/openai/prompt_build_test.go index 1833860..878af73 100644 --- a/internal/adapter/openai/prompt_build_test.go +++ b/internal/adapter/openai/prompt_build_test.go @@ -46,7 +46,7 @@ func TestBuildOpenAIFinalPrompt_HandlerPathIncludesToolRoundtripSemantics(t *tes } if !strings.Contains(finalPrompt, "tool_call_id: call_1") || !strings.Contains(finalPrompt, "function.name: get_weather") || - !strings.Contains(finalPrompt, "Tool result:") || + !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") || !strings.Contains(finalPrompt, `"condition":"sunny"`) { t.Fatalf("handler finalPrompt missing tool roundtrip semantics: %q", finalPrompt) } @@ -77,4 +77,7 @@ func TestBuildOpenAIFinalPrompt_VercelPreparePathKeepsFinalAnswerInstruction(t * if !strings.Contains(finalPrompt, "Only call another tool when the previous result is missing required data or returned an error.") { t.Fatalf("vercel prepare finalPrompt missing retry guard instruction: %q", finalPrompt) } + if !strings.Contains(finalPrompt, "[TOOL_RESULT_HISTORY]") { + t.Fatalf("vercel prepare finalPrompt missing history marker instruction: %q", finalPrompt) + } }