diff --git a/internal/adapter/claude/handler_util_test.go b/internal/adapter/claude/handler_util_test.go index 9ad10e3..ad467c0 100644 --- a/internal/adapter/claude/handler_util_test.go +++ b/internal/adapter/claude/handler_util_test.go @@ -248,14 +248,14 @@ func TestBuildClaudeToolPromptSingleTool(t *testing.T) { if !containsStr(prompt, "Search the web") { t.Fatalf("expected description in prompt") } - if !containsStr(prompt, "tool_use") { - t.Fatalf("expected tool_use instruction in prompt") + 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, "Do not print tool-call JSON in text") { - t.Fatalf("expected prompt to keep no tool-call-json instruction") + if !containsStr(prompt, "TOOL CALL FORMAT") { + t.Fatalf("expected tool call format header in prompt") } } @@ -301,12 +301,9 @@ func TestBuildClaudeToolPromptSupportsOpenAIStyleFunctionTool(t *testing.T) { func TestBuildClaudeToolPromptSkipsNonMap(t *testing.T) { tools := []any{"not a map"} prompt := buildClaudeToolPrompt(tools) - if prompt == "" { - t.Fatal("expected non-empty prompt even with invalid tools") - } - // Should still contain the intro and instruction - if !containsStr(prompt, "You are Claude") { - t.Fatalf("expected intro in prompt") + // No valid tools → empty prompt + if prompt != "" { + t.Fatalf("expected empty prompt for non-map tools, got: %q", prompt) } } diff --git a/internal/adapter/claude/handler_utils.go b/internal/adapter/claude/handler_utils.go index 97327b4..4aa28a1 100644 --- a/internal/adapter/claude/handler_utils.go +++ b/internal/adapter/claude/handler_utils.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "ds2api/internal/util" ) func normalizeClaudeMessages(messages []any) []any { @@ -70,22 +72,27 @@ func normalizeClaudeMessages(messages []any) []any { } func buildClaudeToolPrompt(tools []any) string { - parts := []string{"You are Claude, a helpful AI assistant. You have access to these tools:"} + toolSchemas := make([]string, 0, len(tools)) + names := make([]string, 0, len(tools)) for _, t := range tools { m, ok := t.(map[string]any) if !ok { continue } name, desc, schemaObj := extractClaudeToolMeta(m) + if name == "" { + continue + } + names = append(names, name) schema, _ := json.Marshal(schemaObj) - parts = append(parts, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) + toolSchemas = append(toolSchemas, fmt.Sprintf("Tool: %s\nDescription: %s\nParameters: %s", name, desc, schema)) } - parts = append(parts, - "When you need a tool, respond with Claude-native tool use (tool_use) using the provided tool schema. Do not print tool-call JSON in text.", - "Tool roundtrip context is included directly in the conversation messages (assistant tool_use/tool_calls and tool results).", - "After receiving a valid tool result, continue with final answer instead of repeating the same call unless required fields are still missing.", - ) - return strings.Join(parts, "\n\n") + if len(toolSchemas) == 0 { + return "" + } + return "You have access to these tools:\n\n" + + strings.Join(toolSchemas, "\n\n") + "\n\n" + + util.BuildToolCallInstructions(names) } func formatClaudeToolResultForPrompt(block map[string]any) string { diff --git a/internal/adapter/claude/stream_runtime_finalize.go b/internal/adapter/claude/stream_runtime_finalize.go index d7994b4..0aff357 100644 --- a/internal/adapter/claude/stream_runtime_finalize.go +++ b/internal/adapter/claude/stream_runtime_finalize.go @@ -1,6 +1,7 @@ package claude import ( + "encoding/json" "fmt" "time" @@ -60,9 +61,20 @@ func (s *claudeStreamRuntime) finalize(stopReason string) { "type": "tool_use", "id": fmt.Sprintf("toolu_%d_%d", time.Now().Unix(), idx), "name": tc.Name, - "input": tc.Input, + "input": map[string]any{}, }, }) + + inputBytes, _ := json.Marshal(tc.Input) + s.send("content_block_delta", map[string]any{ + "type": "content_block_delta", + "index": idx, + "delta": map[string]any{ + "type": "input_json_delta", + "partial_json": string(inputBytes), + }, + }) + s.send("content_block_stop", map[string]any{ "type": "content_block_stop", "index": idx, diff --git a/internal/adapter/openai/handler_toolcall_format.go b/internal/adapter/openai/handler_toolcall_format.go index 0b10b3d..c11a3c7 100644 --- a/internal/adapter/openai/handler_toolcall_format.go +++ b/internal/adapter/openai/handler_toolcall_format.go @@ -73,90 +73,11 @@ func injectToolPrompt(messages []map[string]any, tools []any, policy util.ToolCh return messages, names } -// buildToolCallInstructions generates the tool-calling instruction block -// with attention-optimized structure: rules → negative examples → positive -// examples (using real tool names) → anchor. This ordering exploits the -// transformer's recency bias: the last tokens before generation starts -// carry the strongest influence on the model's first output tokens. +// buildToolCallInstructions delegates to the shared util implementation. func buildToolCallInstructions(toolNames []string) string { - // Pick real tool names for examples; fall back to generic names. - ex1 := "read_file" - ex2 := "write_to_file" - ex3 := "ask_followup_question" - used := map[string]bool{} - for _, n := range toolNames { - switch { - case !used["ex1"] && (n == "read_file" || n == "list_files" || n == "search_files"): - ex1 = n - used["ex1"] = true - case !used["ex2"] && (n == "write_to_file" || n == "apply_diff" || n == "execute_command"): - ex2 = n - used["ex2"] = true - case !used["ex3"] && (n == "ask_followup_question" || n == "attempt_completion" || n == "update_todo_list"): - ex3 = n - used["ex3"] = true - } - } - - return `TOOL CALL FORMAT — FOLLOW EXACTLY: - -When calling tools, emit ONLY raw XML. No text before, no text after, no markdown fences. - - - - TOOL_NAME_HERE - {"key":"value"} - - - -RULES: -1) Output ONLY the XML above when calling tools. Do NOT mix tool XML with regular text. -2) MUST contain a strict JSON object. All JSON keys and strings use double quotes. -3) Multiple tools → multiple blocks inside ONE root. -4) Do NOT wrap the XML in markdown code fences (no triple backticks). -5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient. -6) If you want to say something AND call a tool, output text first, then the XML block on its own. - -❌ WRONG — Do NOT do these: -Wrong 1 — mixed text and XML: - I'll read the file for you. ... -Wrong 2 — code fence wrapping: - ` + "```xml\n ...\n ```" + ` -Wrong 3 — missing wrapper: - ` + ex1 + `{} - -✅ CORRECT EXAMPLES: - -Example A — Single tool: - - - ` + ex1 + ` - {"path":"src/main.go"} - - - -Example B — Two tools in parallel: - - - ` + ex1 + ` - {"path":"config.json"} - - - ` + ex2 + ` - {"path":"output.txt","content":"Hello world"} - - - -Example C — Tool with complex JSON parameters (newlines in values use \n): - - - ` + ex3 + ` - {"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]} - - - -Remember: Output ONLY the ... XML block when calling tools.` + return util.BuildToolCallInstructions(toolNames) } + func formatIncrementalStreamToolCallDeltas(deltas []toolCallDelta, ids map[int]string) []map[string]any { if len(deltas) == 0 { return nil diff --git a/internal/util/tool_prompt.go b/internal/util/tool_prompt.go new file mode 100644 index 0000000..a1dbbfd --- /dev/null +++ b/internal/util/tool_prompt.go @@ -0,0 +1,99 @@ +package util + +// BuildToolCallInstructions generates the unified tool-calling instruction block +// used by all adapters (OpenAI, Claude, Gemini). It uses attention-optimized +// structure: rules → negative examples → positive examples → anchor. +// +// The toolNames slice should contain the actual tool names available in the +// current request; the function picks real names for examples. +func BuildToolCallInstructions(toolNames []string) string { + // Pick real tool names for examples; fall back to generic names. + ex1 := "read_file" + ex2 := "write_to_file" + ex3 := "ask_followup_question" + used := map[string]bool{} + for _, n := range toolNames { + switch { + // Read/query-type tools + case !used["ex1"] && matchAny(n, "read_file", "list_files", "search_files", "Read", "Glob"): + ex1 = n + used["ex1"] = true + // Write/execute-type tools + case !used["ex2"] && matchAny(n, "write_to_file", "apply_diff", "execute_command", "Write", "Edit", "MultiEdit", "Bash"): + ex2 = n + used["ex2"] = true + // Interactive/meta tools + case !used["ex3"] && matchAny(n, "ask_followup_question", "attempt_completion", "update_todo_list", "Task"): + ex3 = n + used["ex3"] = true + } + } + + return `TOOL CALL FORMAT — FOLLOW EXACTLY: + +When calling tools, emit ONLY raw XML. No text before, no text after, no markdown fences. + + + + TOOL_NAME_HERE + {"key":"value"} + + + +RULES: +1) Output ONLY the XML above when calling tools. Do NOT mix tool XML with regular text. +2) MUST contain a strict JSON object. All JSON keys and strings use double quotes. +3) Multiple tools → multiple blocks inside ONE root. +4) Do NOT wrap the XML in markdown code fences (no triple backticks). +5) After receiving a tool result, use it directly. Only call another tool if the result is insufficient. +6) If you want to say something AND call a tool, output text first, then the XML block on its own. + +❌ WRONG — Do NOT do these: +Wrong 1 — mixed text and XML: + I'll read the file for you. ... +Wrong 2 — describing tool calls in text: + [调用 Bash] {"command": "ls"} +Wrong 3 — missing wrapper: + ` + ex1 + `{} + +✅ CORRECT EXAMPLES: + +Example A — Single tool: + + + ` + ex1 + ` + {"path":"src/main.go"} + + + +Example B — Two tools in parallel: + + + ` + ex1 + ` + {"path":"config.json"} + + + ` + ex2 + ` + {"path":"output.txt","content":"Hello world"} + + + +Example C — Tool with complex nested JSON parameters: + + + ` + ex3 + ` + {"question":"Which approach do you prefer?","follow_up":[{"text":"Option A"},{"text":"Option B"}]} + + + +Remember: Output ONLY the ... XML block when calling tools.` +} + +func matchAny(name string, candidates ...string) bool { + for _, c := range candidates { + if name == c { + return true + } + } + return false +}