- 根因: 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绿+交叉编译
132 lines
4.9 KiB
Go
132 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|