diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 26e1ba8..a84dd0f 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -147,6 +147,10 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` 3. 再附上统一的 XML tool call 格式约束。 4. 把这整段内容并入 system prompt。 +工具调用正例仍只示范 canonical XML:`` → `` → ``。 +正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。 +对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。 + OpenAI 路径实现: [internal/promptcompat/tool_prompt.go](../internal/promptcompat/tool_prompt.go) diff --git a/internal/toolcall/tool_prompt.go b/internal/toolcall/tool_prompt.go index 6b1ce0e..7f405d2 100644 --- a/internal/toolcall/tool_prompt.go +++ b/internal/toolcall/tool_prompt.go @@ -9,31 +9,6 @@ import "strings" // 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", "exec_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 - } - } - ex1Params := exampleReadParams(ex1) - ex2Params := exampleWriteOrExecParams(ex2) - ex3Params := exampleInteractiveParams(ex3) - return `TOOL CALL FORMAT — FOLLOW EXACTLY: @@ -70,49 +45,106 @@ Wrong 2 — Markdown code fences: Remember: The ONLY valid way to use tools is the ... XML block at the end of your response. -【CORRECT EXAMPLES】: +` + buildCorrectToolExamples(toolNames) +} -Example A — Single tool: - - -` + indentPromptParameters(ex1Params, " ") + ` - - +type promptToolExample struct { + name string + params string +} -Example B — Two tools in parallel: - - -` + indentPromptParameters(ex1Params, " ") + ` - - -` + indentPromptParameters(ex2Params, " ") + ` - - - ` + promptCDATA("/abs/path/to/another-file.txt") + ` - - +func buildCorrectToolExamples(toolNames []string) string { + names := uniqueToolNames(toolNames) + examples := make([]string, 0, 4) -Example C — Tool with nested XML parameters: - - -` + indentPromptParameters(ex3Params, " ") + ` - - + if single, ok := firstBasicExample(names); ok { + examples = append(examples, "Example A — Single tool:\n"+renderToolExampleBlock([]promptToolExample{single})) + } -Example D — Tool with long script using CDATA (RELIABLE FOR CODE/SCRIPTS): - - - ` + promptCDATA("script.sh") + ` - - - + if parallel := firstNBasicExamples(names, 2); len(parallel) >= 2 { + examples = append(examples, "Example B — Two tools in parallel:\n"+renderToolExampleBlock(parallel)) + } -` + if nested, ok := firstNestedExample(names); ok { + examples = append(examples, "Example C — Tool with nested XML parameters:\n"+renderToolExampleBlock([]promptToolExample{nested})) + } + + if script, ok := firstScriptExample(names); ok { + examples = append(examples, "Example D — Tool with long script using CDATA (RELIABLE FOR CODE/SCRIPTS):\n"+renderToolExampleBlock([]promptToolExample{script})) + } + + if len(examples) == 0 { + return "" + } + return "【CORRECT EXAMPLES】:\n\n" + strings.Join(examples, "\n\n") + "\n\n" +} + +func uniqueToolNames(toolNames []string) []string { + names := make([]string, 0, len(toolNames)) + seen := map[string]bool{} + for _, name := range toolNames { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + return names +} + +func firstBasicExample(names []string) (promptToolExample, bool) { + for _, name := range names { + if params, ok := exampleBasicParams(name); ok { + return promptToolExample{name: name, params: params}, true + } + } + return promptToolExample{}, false +} + +func firstNBasicExamples(names []string, count int) []promptToolExample { + out := make([]promptToolExample, 0, count) + for _, name := range names { + if params, ok := exampleBasicParams(name); ok { + out = append(out, promptToolExample{name: name, params: params}) + if len(out) == count { + return out + } + } + } + return out +} + +func firstNestedExample(names []string) (promptToolExample, bool) { + for _, name := range names { + if params, ok := exampleNestedParams(name); ok { + return promptToolExample{name: name, params: params}, true + } + } + return promptToolExample{}, false +} + +func firstScriptExample(names []string) (promptToolExample, bool) { + for _, name := range names { + if params, ok := exampleScriptParams(name); ok { + return promptToolExample{name: name, params: params}, true + } + } + return promptToolExample{}, false +} + +func renderToolExampleBlock(calls []promptToolExample) string { + var b strings.Builder + b.WriteString("\n") + for _, call := range calls { + b.WriteString(` \n") + b.WriteString(indentPromptParameters(call.params, " ")) + b.WriteString("\n \n") + } + b.WriteString("") + return b.String() } func indentPromptParameters(body, indent string) string { @@ -134,48 +166,70 @@ func wrapParameter(name, inner string) string { return `` + inner + `` } -func exampleReadParams(name string) string { +func exampleBasicParams(name string) (string, bool) { switch strings.TrimSpace(name) { case "Read": - return wrapParameter("file_path", promptCDATA("README.md")) + return wrapParameter("file_path", promptCDATA("README.md")), true case "Glob": - return wrapParameter("pattern", promptCDATA("**/*.go")) + "\n" + wrapParameter("path", promptCDATA(".")) - default: - return wrapParameter("path", promptCDATA("src/main.go")) - } -} - -func exampleWriteOrExecParams(name string) string { - switch strings.TrimSpace(name) { + return wrapParameter("pattern", promptCDATA("**/*.go")) + "\n" + wrapParameter("path", promptCDATA(".")), true + case "read_file": + return wrapParameter("path", promptCDATA("src/main.go")), true + case "list_files": + return wrapParameter("path", promptCDATA(".")), true + case "search_files": + return wrapParameter("query", promptCDATA("tool call parser")), true case "Bash", "execute_command": - return wrapParameter("command", promptCDATA("pwd")) + return wrapParameter("command", promptCDATA("pwd")), true case "exec_command": - return wrapParameter("cmd", promptCDATA("pwd")) + return wrapParameter("cmd", promptCDATA("pwd")), true + case "Write": + return wrapParameter("file_path", promptCDATA("notes.txt")) + "\n" + wrapParameter("content", promptCDATA("Hello world")), true + case "write_to_file": + return wrapParameter("path", promptCDATA("notes.txt")) + "\n" + wrapParameter("content", promptCDATA("Hello world")), true case "Edit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")) + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + wrapParameter("old_string", promptCDATA("foo")) + "\n" + wrapParameter("new_string", promptCDATA("bar")), true case "MultiEdit": - return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `` + promptCDATA("foo") + `` + promptCDATA("bar") + `` - default: - return wrapParameter("path", promptCDATA("output.txt")) + "\n" + wrapParameter("content", promptCDATA("Hello world")) + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true } + return "", false } -func exampleInteractiveParams(name string) string { +func exampleNestedParams(name string) (string, bool) { switch strings.TrimSpace(name) { + case "MultiEdit": + return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `` + promptCDATA("foo") + `` + promptCDATA("bar") + ``, true case "Task": - return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")) - default: - return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `` + promptCDATA("Option A") + `` + promptCDATA("Option B") + `` + return wrapParameter("description", promptCDATA("Investigate flaky tests")) + "\n" + wrapParameter("prompt", promptCDATA("Run targeted tests and summarize failures")), true + case "ask_followup_question": + return wrapParameter("question", promptCDATA("Which approach do you prefer?")) + "\n" + `` + promptCDATA("Option A") + `` + promptCDATA("Option B") + ``, true } + return "", false } -func matchAny(name string, candidates ...string) bool { - for _, c := range candidates { - if name == c { - return true - } +func exampleScriptParams(name string) (string, bool) { + scriptCommand := `cat > /tmp/test_escape.sh <<'EOF' +#!/bin/bash +echo 'single "double"' +echo "literal dollar: \$HOME" +EOF +bash /tmp/test_escape.sh` + scriptContent := `#!/bin/bash +echo 'single "double"' +echo "literal dollar: $HOME"` + + switch strings.TrimSpace(name) { + case "Bash": + return wrapParameter("command", promptCDATA(scriptCommand)) + "\n" + wrapParameter("description", promptCDATA("Test shell escaping")), true + case "execute_command": + return wrapParameter("command", promptCDATA(scriptCommand)), true + case "exec_command": + return wrapParameter("cmd", promptCDATA(scriptCommand)), true + case "Write": + return wrapParameter("file_path", promptCDATA("test_escape.sh")) + "\n" + wrapParameter("content", promptCDATA(scriptContent)), true + case "write_to_file": + return wrapParameter("path", promptCDATA("test_escape.sh")) + "\n" + wrapParameter("content", promptCDATA(scriptContent)), true } - return false + return "", false } func promptCDATA(text string) string { diff --git a/internal/toolcall/tool_prompt_test.go b/internal/toolcall/tool_prompt_test.go index c063bca..8b0e8cf 100644 --- a/internal/toolcall/tool_prompt_test.go +++ b/internal/toolcall/tool_prompt_test.go @@ -24,3 +24,107 @@ func TestBuildToolCallInstructions_ExecuteCommandUsesCommandExample(t *testing.T t.Fatalf("expected command parameter example for execute_command, got: %s", out) } } + +func TestBuildToolCallInstructions_BashUsesCommandAndDescriptionExamples(t *testing.T) { + out := BuildToolCallInstructions([]string{"Bash"}) + blocks := findInvokeBlocks(out, "Bash") + if len(blocks) == 0 { + t.Fatalf("expected Bash examples, got: %s", out) + } + + sawDescription := false + for _, block := range blocks { + if !strings.Contains(block, ``) { + t.Fatalf("expected every Bash example to use command parameter, got: %s", block) + } + if strings.Contains(block, ``) || strings.Contains(block, ``) { + t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block) + } + if strings.Contains(block, ``) { + sawDescription = true + } + } + if !sawDescription { + t.Fatalf("expected Bash long-script example to include description, got: %s", out) + } + if strings.Contains(out, ``) { + t.Fatalf("expected examples to avoid unavailable hard-coded Read tool, got: %s", out) + } +} + +func TestBuildToolCallInstructions_ExecuteCommandLongScriptUsesCommand(t *testing.T) { + out := BuildToolCallInstructions([]string{"execute_command"}) + blocks := findInvokeBlocks(out, "execute_command") + if len(blocks) == 0 { + t.Fatalf("expected execute_command examples, got: %s", out) + } + + for _, block := range blocks { + if !strings.Contains(block, ``) { + t.Fatalf("expected execute_command examples to use command parameter, got: %s", block) + } + if strings.Contains(block, ``) || strings.Contains(block, ``) { + t.Fatalf("expected execute_command examples not to use file write parameters, got: %s", block) + } + } + if !strings.Contains(out, `test_escape.sh`) { + t.Fatalf("expected execute_command long-script example, got: %s", out) + } +} + +func TestBuildToolCallInstructions_ExecCommandLongScriptUsesCmd(t *testing.T) { + out := BuildToolCallInstructions([]string{"exec_command"}) + blocks := findInvokeBlocks(out, "exec_command") + if len(blocks) == 0 { + t.Fatalf("expected exec_command examples, got: %s", out) + } + + for _, block := range blocks { + if !strings.Contains(block, ``) { + t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block) + } + if strings.Contains(block, ``) || strings.Contains(block, ``) || strings.Contains(block, ``) { + t.Fatalf("expected exec_command examples not to use command or file write parameters, got: %s", block) + } + } + if !strings.Contains(out, `test_escape.sh`) { + t.Fatalf("expected exec_command long-script example, got: %s", out) + } +} + +func TestBuildToolCallInstructions_WriteUsesFilePathAndContent(t *testing.T) { + out := BuildToolCallInstructions([]string{"Write"}) + blocks := findInvokeBlocks(out, "Write") + if len(blocks) == 0 { + t.Fatalf("expected Write examples, got: %s", out) + } + + for _, block := range blocks { + if !strings.Contains(block, ``) || !strings.Contains(block, ``) { + t.Fatalf("expected Write examples to use file_path and content, got: %s", block) + } + if strings.Contains(block, ``) { + t.Fatalf("expected Write examples not to use path, got: %s", block) + } + } +} + +func findInvokeBlocks(text, name string) []string { + open := `` + remaining := text + blocks := []string{} + for { + start := strings.Index(remaining, open) + if start < 0 { + return blocks + } + remaining = remaining[start:] + end := strings.Index(remaining, ``) + if end < 0 { + return blocks + } + end += len(``) + blocks = append(blocks, remaining[:end]) + remaining = remaining[end:] + } +}