Skip to content

Commit 70d293d

Browse files
committed
Add support for bare function calls in tool call recovery
1 parent 7b7437d commit 70d293d

3 files changed

Lines changed: 73 additions & 1 deletion

File tree

internal/defaults/defaults.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@ CORE BEHAVIOR
1111
- Reply in the same language as the user unless explicitly asked otherwise.
1212
- Always obey constraints declared in [RUNTIME_MODE]. If any instruction conflicts, [RUNTIME_MODE] wins.
1313
14+
TOOL CALLING (OPENAI-COMPATIBLE)
15+
- When tools are provided in the request, you MUST invoke them via OpenAI-style tool_calls (functions) instead of encoding tool usage inside assistant content.
16+
- For every tool invocation, populate the tool_calls list with:
17+
- type = "function"
18+
- function.name set to the tool name
19+
- function.arguments as a strict JSON object containing only the arguments for that tool.
20+
- Assistant message content (the "content" field) MUST NOT contain surrogate tool markup such as <tool_call>, <function=...>, <parameter=...>, XML-like tags, or JSON blobs that represent tool calls.
21+
- Natural language in "content" should describe your reasoning, next steps, and results. Use tool_calls as the ONLY channel to actually invoke tools.
22+
- If no tools are provided, answer normally and do NOT fabricate tool_calls.
23+
- These rules apply regardless of the underlying model provider; always follow this contract when tools are present.
24+
1425
REQUEST TRIAGE (AVOID OVER-PLANNING)
1526
- Classify the request before acting:
1627
- Utility/factual request (e.g., time, timezone, conversion, quick calculation, one-off command output).

internal/orchestrator/orchestrator_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,26 @@ func TestRecoverToolCallsFromContent_TaggedFunction(t *testing.T) {
856856
}
857857
}
858858

859+
func TestRecoverToolCallsFromContent_BareTaggedFunction(t *testing.T) {
860+
content := "I'll help you install Python.\n<function=bash>\n<parameter=command>\nuname -s\n</parameter>\n</function>\n</tool_call>"
861+
defs := []chat.ToolDef{
862+
{Type: "function", Function: chat.ToolFunction{Name: "bash", Parameters: map[string]any{"type": "object"}}},
863+
}
864+
calls, cleaned := recoverToolCallsFromContent(content, defs)
865+
if len(calls) != 1 {
866+
t.Fatalf("recovered calls=%d, want 1", len(calls))
867+
}
868+
if calls[0].Function.Name != "bash" {
869+
t.Fatalf("unexpected tool name: %+v", calls[0])
870+
}
871+
if calls[0].Function.Arguments != `{"command":"uname -s"}` {
872+
t.Fatalf("unexpected args: %s", calls[0].Function.Arguments)
873+
}
874+
if strings.Contains(cleaned, "<function=bash>") {
875+
t.Fatalf("expected cleaned content without function block, got %q", cleaned)
876+
}
877+
}
878+
859879
func TestRecoverToolCallsFromContent_JSONStyle(t *testing.T) {
860880
content := "<tool_call>{\"name\":\"bash\",\"arguments\":{\"command\":\"uname -a\"}}</tool_call>"
861881
defs := []chat.ToolDef{

internal/orchestrator/toolcall_recovery.go

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ func recoverToolCallsFromContent(content string, defs []chat.ToolDef) ([]chat.To
3737

3838
matches := toolCallBlockPattern.FindAllStringSubmatchIndex(content, -1)
3939
if len(matches) == 0 {
40-
return nil, content
40+
// 一些模型(例如部分 Qwen 兼容服务)可能只输出裸露的 <function=...> 标签,
41+
// 而不会包在 <tool_call>...</tool_call> 块里。为保持兼容性,这里做一次降级恢复。
42+
return recoverBareFunctionCallsFromContent(content, allowed)
4143
}
4244

4345
calls := make([]chat.ToolCall, 0, len(matches))
@@ -110,6 +112,45 @@ func parseJSONStyleToolCall(inner string, allowed map[string]struct{}, seq int)
110112
}, true
111113
}
112114

115+
// recoverBareFunctionCallsFromContent 处理没有显式 <tool_call> 包裹的 <function=...> 块。
116+
// 例如:
117+
// <function=bash>
118+
// <parameter=command>
119+
// uname -s
120+
// </parameter>
121+
// </function>
122+
func recoverBareFunctionCallsFromContent(content string, allowed map[string]struct{}) ([]chat.ToolCall, string) {
123+
matches := functionCallPattern.FindAllStringSubmatchIndex(content, -1)
124+
if len(matches) == 0 {
125+
return nil, content
126+
}
127+
128+
calls := make([]chat.ToolCall, 0, len(matches))
129+
var cleaned strings.Builder
130+
last := 0
131+
132+
for i, m := range matches {
133+
if len(m) < 2 {
134+
continue
135+
}
136+
start, end := m[0], m[1]
137+
// 保留前面的普通文本
138+
cleaned.WriteString(content[last:start])
139+
last = end
140+
141+
snippet := strings.TrimSpace(content[start:end])
142+
call, ok := parseTaggedFunctionToolCall(snippet, allowed, i+1)
143+
if !ok {
144+
// 无法解析时保留原始片段,避免丢信息
145+
cleaned.WriteString(content[start:end])
146+
continue
147+
}
148+
calls = append(calls, call)
149+
}
150+
cleaned.WriteString(content[last:])
151+
return calls, strings.TrimSpace(cleaned.String())
152+
}
153+
113154
func parseTaggedFunctionToolCall(inner string, allowed map[string]struct{}, seq int) (chat.ToolCall, bool) {
114155
m := functionCallPattern.FindStringSubmatch(inner)
115156
if len(m) != 3 {

0 commit comments

Comments
 (0)