mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-02 07:25:26 +08:00
refactor(toolcall): 动态生成工具调用示例,基于实际可用工具名
- 将硬编码的工具示例名改为从请求实际声明的工具名中选取 - 按类别(读取/写入执行/交互/嵌套)智能匹配示例工具 - 执行类工具脚本内容使用正确的参数名(command/cmd),避免误用文件写入参数 - 当工具不足时自动省略对应的示例段落,避免把不可用工具名写入 prompt - 同步更新 prompt-compatibility.md 文档说明
This commit is contained in:
@@ -147,6 +147,10 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools`
|
||||
3. 再附上统一的 XML tool call 格式约束。
|
||||
4. 把这整段内容并入 system prompt。
|
||||
|
||||
工具调用正例仍只示范 canonical XML:`<tool_calls>` → `<invoke name="...">` → `<parameter name="...">`。
|
||||
正例中的工具名只会来自当前请求实际声明的工具;如果当前请求没有足够的已知工具形态,就省略对应的单工具、多工具或嵌套示例,避免把不可用工具名写进 prompt。
|
||||
对执行类工具,脚本内容必须进入执行参数本身:`Bash` / `execute_command` 使用 `command`,`exec_command` 使用 `cmd`;不要把脚本示范成 `path` / `content` 文件写入参数。
|
||||
|
||||
OpenAI 路径实现:
|
||||
[internal/promptcompat/tool_prompt.go](../internal/promptcompat/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:
|
||||
|
||||
<tool_calls>
|
||||
@@ -70,49 +45,106 @@ Wrong 2 — Markdown code fences:
|
||||
|
||||
Remember: The ONLY valid way to use tools is the <tool_calls>...</tool_calls> XML block at the end of your response.
|
||||
|
||||
【CORRECT EXAMPLES】:
|
||||
` + buildCorrectToolExamples(toolNames)
|
||||
}
|
||||
|
||||
Example A — Single tool:
|
||||
<tool_calls>
|
||||
<invoke name="` + ex1 + `">
|
||||
` + indentPromptParameters(ex1Params, " ") + `
|
||||
</invoke>
|
||||
</tool_calls>
|
||||
type promptToolExample struct {
|
||||
name string
|
||||
params string
|
||||
}
|
||||
|
||||
Example B — Two tools in parallel:
|
||||
<tool_calls>
|
||||
<invoke name="` + ex1 + `">
|
||||
` + indentPromptParameters(ex1Params, " ") + `
|
||||
</invoke>
|
||||
<invoke name="` + ex2 + `">
|
||||
` + indentPromptParameters(ex2Params, " ") + `
|
||||
</invoke>
|
||||
<invoke name="Read">
|
||||
<parameter name="file_path">` + promptCDATA("/abs/path/to/another-file.txt") + `</parameter>
|
||||
</invoke>
|
||||
</tool_calls>
|
||||
func buildCorrectToolExamples(toolNames []string) string {
|
||||
names := uniqueToolNames(toolNames)
|
||||
examples := make([]string, 0, 4)
|
||||
|
||||
Example C — Tool with nested XML parameters:
|
||||
<tool_calls>
|
||||
<invoke name="` + ex3 + `">
|
||||
` + indentPromptParameters(ex3Params, " ") + `
|
||||
</invoke>
|
||||
</tool_calls>
|
||||
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):
|
||||
<tool_calls>
|
||||
<invoke name="` + ex2 + `">
|
||||
<parameter name="path">` + promptCDATA("script.sh") + `</parameter>
|
||||
<parameter name="content"><![CDATA[
|
||||
#!/bin/bash
|
||||
if [ "$1" == "test" ]; then
|
||||
echo "Success!"
|
||||
fi
|
||||
]]></parameter>
|
||||
</invoke>
|
||||
</tool_calls>
|
||||
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("<tool_calls>\n")
|
||||
for _, call := range calls {
|
||||
b.WriteString(` <invoke name="`)
|
||||
b.WriteString(call.name)
|
||||
b.WriteString("\">\n")
|
||||
b.WriteString(indentPromptParameters(call.params, " "))
|
||||
b.WriteString("\n </invoke>\n")
|
||||
}
|
||||
b.WriteString("</tool_calls>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func indentPromptParameters(body, indent string) string {
|
||||
@@ -134,48 +166,70 @@ func wrapParameter(name, inner string) string {
|
||||
return `<parameter name="` + name + `">` + inner + `</parameter>`
|
||||
}
|
||||
|
||||
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" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`
|
||||
default:
|
||||
return wrapParameter("path", promptCDATA("output.txt")) + "\n" + wrapParameter("content", promptCDATA("Hello world"))
|
||||
return wrapParameter("file_path", promptCDATA("README.md")) + "\n" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, 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" + `<parameter name="edits"><item><old_string>` + promptCDATA("foo") + `</old_string><new_string>` + promptCDATA("bar") + `</new_string></item></parameter>`, 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" + `<parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></parameter>`
|
||||
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" + `<parameter name="follow_up"><item><text>` + promptCDATA("Option A") + `</text></item><item><text>` + promptCDATA("Option B") + `</text></item></parameter>`, 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 {
|
||||
|
||||
@@ -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, `<parameter name="command">`) {
|
||||
t.Fatalf("expected every Bash example to use command parameter, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
|
||||
t.Fatalf("expected Bash examples not to use file write parameters, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, `<parameter name="description">`) {
|
||||
sawDescription = true
|
||||
}
|
||||
}
|
||||
if !sawDescription {
|
||||
t.Fatalf("expected Bash long-script example to include description, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, `<invoke name="Read">`) {
|
||||
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, `<parameter name="command">`) {
|
||||
t.Fatalf("expected execute_command examples to use command parameter, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
|
||||
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, `<parameter name="cmd">`) {
|
||||
t.Fatalf("expected exec_command examples to use cmd parameter, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, `<parameter name="command">`) || strings.Contains(block, `<parameter name="path">`) || strings.Contains(block, `<parameter name="content">`) {
|
||||
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, `<parameter name="file_path">`) || !strings.Contains(block, `<parameter name="content">`) {
|
||||
t.Fatalf("expected Write examples to use file_path and content, got: %s", block)
|
||||
}
|
||||
if strings.Contains(block, `<parameter name="path">`) {
|
||||
t.Fatalf("expected Write examples not to use path, got: %s", block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findInvokeBlocks(text, name string) []string {
|
||||
open := `<invoke name="` + name + `">`
|
||||
remaining := text
|
||||
blocks := []string{}
|
||||
for {
|
||||
start := strings.Index(remaining, open)
|
||||
if start < 0 {
|
||||
return blocks
|
||||
}
|
||||
remaining = remaining[start:]
|
||||
end := strings.Index(remaining, `</invoke>`)
|
||||
if end < 0 {
|
||||
return blocks
|
||||
}
|
||||
end += len(`</invoke>`)
|
||||
blocks = append(blocks, remaining[:end])
|
||||
remaining = remaining[end:]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user