fix(loop): 原生工具调用从 Response.ToolCalls 解码 + 逐次回填工具结果 — 修复工具消息无回复
- 根因: loop/agent.decodeToolCalls 只读 resp.Message.ToolCallName/resp.Content JSON信封, 从不看 api 原生解析出的 resp.ToolCalls(Anthropic tool_use / OpenAI tool_calls) → 模型返回 thinking+tool_use(无text) 被判定'无工具调用' → 空 Content 当最终 text → res.Text="" → no reply produced; 纯聊天消息不调工具直接出 text, 故表现为'部分消息无回复' - agent/step.go: ToolCall 增 ID; ExtractToolCalls 导出并优先 resp.ToolCalls(保留原生id+归一化nil Args), 回落两旧约定 - loop/request.go: decodeToolCalls 委托 agent.ExtractToolCalls(流式/非流式共用) - loop/tool_history.go(新): 原生批次回填 assistant tool_use + 带tool_use_id的tool_result; 旧JSON信封批次保持 RoleTool 纯文本(向后兼容) - openai.go: tool_call parts 编成 tool_calls + tool_call_id - 新增 native_toolcall_test + native_toolcall_http_test(httptest假Anthropic SSE: round1 tool_use(无text)→round2含tool_use_id/tool_result→res.Text非空) - build/vet/test绿+交叉编译
This commit is contained in:
1 parent
57d5d22d87
commit
f347681034
7 files changed
+332
-33
No files matched your search
@@ -0,0 +1,131 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// nativeDatetimeCall is a provider-native tool call response: the adapters put
|
||||
// the call in Response.ToolCalls (not in Content), which is exactly the shape a
|
||||
// thinking model returns for "what time is it" when the datetime tool is
|
||||
// advertised. Content is empty, so a loop that ignores ToolCalls produces a
|
||||
// silent empty reply.
|
||||
func nativeDatetimeCall() model.Response {
|
||||
return model.Response{
|
||||
Model: "m",
|
||||
Content: "",
|
||||
ToolCalls: []model.ToolCall{{
|
||||
ID: "call_native_1",
|
||||
Name: "datetime",
|
||||
Args: map[string]any{"timezone": "Asia/Shanghai"},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// assertNativeFeedBack checks that req carries the assistant tool_use turn and a
|
||||
// correlated tool result for toolCallID.
|
||||
func assertNativeFeedBack(t *testing.T, req model.Request, toolCallID string) {
|
||||
t.Helper()
|
||||
for i := 0; i < len(req.Messages)-1; i++ {
|
||||
m := req.Messages[i]
|
||||
if m.Role != model.RoleAssistant || len(m.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
if m.Content[0].Type != model.ContentTypeToolCall || m.Content[0].ToolCallID != toolCallID {
|
||||
continue
|
||||
}
|
||||
next := req.Messages[i+1]
|
||||
if next.Role != model.RoleTool {
|
||||
t.Fatalf("message after assistant tool_use has role %q, want tool", next.Role)
|
||||
}
|
||||
if next.ToolCallID != toolCallID {
|
||||
t.Fatalf("tool result ToolCallID = %q, want %q", next.ToolCallID, toolCallID)
|
||||
}
|
||||
if len(next.Content) == 0 || next.Content[0].Type != model.ContentTypeToolResult {
|
||||
t.Fatalf("tool result content = %+v, want a tool_result part", next.Content)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("no native assistant tool_use + tool_result pair for %q in %+v", toolCallID, req.Messages)
|
||||
}
|
||||
|
||||
// TestRun_NativeToolCall_Streaming covers the real chat path (the loop drives
|
||||
// ChatStream when OnStream is set): a native provider tool call with empty
|
||||
// Content must be executed and fed back as native tool_use + tool_result, and
|
||||
// the run must finish with the model's follow-up text instead of an empty reply.
|
||||
func TestRun_NativeToolCall_Streaming(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{stream: []mgResp{
|
||||
{resp: nativeDatetimeCall()},
|
||||
{resp: model.Response{Model: "m", Content: "现在是 10:34。"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}})
|
||||
a := newTestAgent(t, ad, td)
|
||||
l := New(Options{Agent: a, MaxRounds: 8, OnStream: func(model.StreamEvent) {}})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "瞅瞅当前系统时间是多少")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "现在是 10:34。" {
|
||||
t.Fatalf("Text = %q, want the follow-up answer (empty means the native call was dropped)", res.Text)
|
||||
}
|
||||
if res.Steps != 2 {
|
||||
t.Fatalf("Steps = %d, want 2", res.Steps)
|
||||
}
|
||||
if res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted)
|
||||
}
|
||||
if len(ad.streamReqs) < 2 {
|
||||
t.Fatalf("stream requests = %d, want >= 2", len(ad.streamReqs))
|
||||
}
|
||||
assertNativeFeedBack(t, ad.streamReqs[1], "call_native_1")
|
||||
}
|
||||
|
||||
// TestRun_NativeToolCall_NonStreaming covers the muted/internal path where the
|
||||
// loop delegates to agent.RunStep (adapter.Chat), which must decode the same
|
||||
// native tool call.
|
||||
func TestRun_NativeToolCall_NonStreaming(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := &mockAdapter{chat: []mgResp{
|
||||
{resp: nativeDatetimeCall()},
|
||||
{resp: model.Response{Model: "m", Content: "现在是 10:34。"}},
|
||||
}}
|
||||
td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}})
|
||||
a := newTestAgent(t, ad, td)
|
||||
l := New(Options{Agent: a, MaxRounds: 8})
|
||||
|
||||
res, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "几点了")})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Text != "现在是 10:34。" || res.ToolCallsExecuted != 1 {
|
||||
t.Fatalf("Text = %q, ToolCallsExecuted = %d; want follow-up text + 1 call", res.Text, res.ToolCallsExecuted)
|
||||
}
|
||||
if len(ad.reqs) < 2 {
|
||||
t.Fatalf("chat requests = %d, want >= 2", len(ad.reqs))
|
||||
}
|
||||
assertNativeFeedBack(t, ad.reqs[1], "call_native_1")
|
||||
}
|
||||
|
||||
// TestExtractToolCalls_PrefersNative pins the decoder contract: provider-native
|
||||
// ToolCalls win over a Content envelope and preserve the provider id, so the
|
||||
// feed-back can correlate the result.
|
||||
func TestExtractToolCalls_PrefersNative(t *testing.T) {
|
||||
t.Parallel()
|
||||
resp := nativeDatetimeCall()
|
||||
resp.Content = `{"tool_calls":[{"name":"legacy","args":{}}]}`
|
||||
calls, ok := agent.ExtractToolCalls(resp)
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("ExtractToolCalls = (%+v, %v), want 1 native call", calls, ok)
|
||||
}
|
||||
if calls[0].ID != "call_native_1" || calls[0].Name != "datetime" {
|
||||
t.Fatalf("native call = %+v, want id=call_native_1 name=datetime", calls[0])
|
||||
}
|
||||
if calls[0].Args["timezone"] != "Asia/Shanghai" {
|
||||
t.Fatalf("native args = %+v, want timezone=Asia/Shanghai", calls[0].Args)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user