diff --git a/internal/runtime/agent/agent_test.go b/internal/runtime/agent/agent_test.go index ca4895f..40309e4 100644 --- a/internal/runtime/agent/agent_test.go +++ b/internal/runtime/agent/agent_test.go @@ -366,8 +366,8 @@ func TestRunStep_ContextCanceled(t *testing.T) { func TestExtractToolCalls_NonJSONContent(t *testing.T) { t.Parallel() - if _, ok := extractToolCalls(model.Response{Content: "plain text"}); ok { - t.Fatalf("extractToolCalls(plain) ok = true, want false") + if _, ok := ExtractToolCalls(model.Response{Content: "plain text"}); ok { + t.Fatalf("ExtractToolCalls(plain) ok = true, want false") } } diff --git a/internal/runtime/agent/step.go b/internal/runtime/agent/step.go index 99cdae8..fcc13e0 100644 --- a/internal/runtime/agent/step.go +++ b/internal/runtime/agent/step.go @@ -25,6 +25,11 @@ const ( // ToolCall is a single function call the assistant requested, decoded from a // step. Args holds the tool arguments as a JSON object. type ToolCall struct { + // ID is the provider tool_use/tool_call id, so the loop can feed the result + // back as a native tool_result correlated to the exact call (Anthropic + // tool_use_id / OpenAI tool_call_id). Empty for the legacy JSON-envelope + // convention, where the provider emitted no id. + ID string `json:"id,omitempty"` Name string `json:"name"` Args map[string]any `json:"args"` } @@ -95,15 +100,17 @@ func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model. } } - if calls, ok := extractToolCalls(resp); ok { + if calls, ok := ExtractToolCalls(resp); ok { return &StepResult{Final: false, ToolCalls: calls, StopReason: StopReasonToolCalls}, nil } return &StepResult{Final: true, Text: resp.Content, StopReason: StopReasonStop}, nil } -// extractToolCalls decodes tool calls from a model.Response. The current model -// layer does not parse provider tool_calls into a structured field, so the agent -// uses two conventions in priority order: +// ExtractToolCalls decodes tool calls from a model.Response. It prefers the +// provider-native ToolCalls the adapters populate (Anthropic tool_use / OpenAI +// tool_calls), which carry the provider id needed to correlate the result and +// are the only shape a real native-tool provider emits. It then falls back to +// two legacy conventions used by prompt-engineered providers: // // 1. The normalized assistant-message placeholder: when resp.Message carries a // ToolCallName it is a single call with no argument payload. @@ -112,7 +119,10 @@ func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model. // // It reports ok=true (with a non-empty slice) when a call is decoded, and // ok=false otherwise. -func extractToolCalls(resp model.Response) ([]ToolCall, bool) { +func ExtractToolCalls(resp model.Response) ([]ToolCall, bool) { + if len(resp.ToolCalls) > 0 { + return nativeToolCalls(resp.ToolCalls), true + } if resp.Message.Role == model.RoleAssistant && resp.Message.ToolCallName != "" { return []ToolCall{{Name: resp.Message.ToolCallName, Args: map[string]any{}}}, true } @@ -131,3 +141,18 @@ func extractToolCalls(resp model.Response) ([]ToolCall, bool) { } return payload.ToolCalls, true } + +// nativeToolCalls converts the adapter-decoded provider tool calls into the +// agent's ToolCall, preserving the provider id and normalizing a nil Args to an +// empty object so downstream fingerprinting and invocation never see nil. +func nativeToolCalls(calls []model.ToolCall) []ToolCall { + out := make([]ToolCall, 0, len(calls)) + for _, c := range calls { + args := c.Args + if args == nil { + args = map[string]any{} + } + out = append(out, ToolCall{ID: c.ID, Name: c.Name, Args: args}) + } + return out +} diff --git a/internal/runtime/loop/native_toolcall_http_test.go b/internal/runtime/loop/native_toolcall_http_test.go new file mode 100644 index 0000000..c2ec637 --- /dev/null +++ b/internal/runtime/loop/native_toolcall_http_test.go @@ -0,0 +1,110 @@ +package loop + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// anthropicToolRoundSSE is a native Anthropic stream that thinks, then emits a +// tool_use block with an empty text part — the exact shape that starved the old +// loop (which only looked for a JSON envelope in Content). +const anthropicToolRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":10}}} + +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"checking the clock"}} + +data: {"type":"content_block_stop","index":0} + +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_1","name":"datetime","input":{}}} + +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"timezone\":\"Asia/Shanghai\"}"}} + +data: {"type":"content_block_stop","index":1} + +data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":20}} + +data: {"type":"message_stop"} + +` + +// anthropicTextRoundSSE is the follow-up stream: a plain text answer after the +// tool result was fed back. +const anthropicTextRoundSSE = `data: {"type":"message_start","message":{"model":"m","usage":{"input_tokens":30}}} + +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"现在是 10:34。"}} + +data: {"type":"content_block_stop","index":0} + +data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":8}} + +data: {"type":"message_stop"} + +` + +// TestRun_NativeToolCall_AnthropicAdapter_HTTP exercises the real loop path end +// to end through the real AnthropicChatAdapter against a stub /v1/messages +// server: round 1 streams a thinking + tool_use block with no text, the loop +// must execute it and re-ask, and round 2 must carry the assistant tool_use turn +// plus a correlated tool_result and return the model's text (not an empty reply). +func TestRun_NativeToolCall_AnthropicAdapter_HTTP(t *testing.T) { + t.Parallel() + var mu sync.Mutex + round := 0 + var secondBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + round++ + n := round + if n == 2 { + secondBody = string(body) + } + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + if n == 1 { + _, _ = io.WriteString(w, anthropicToolRoundSSE) + return + } + _, _ = io.WriteString(w, anthropicTextRoundSSE) + })) + t.Cleanup(srv.Close) + + ad := model.NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: "m"}) + td := dispatcherWith(t, &mockTool{name: "datetime", result: map[string]any{"now": "2026-09-08 22:34:00"}}) + ag, err := agent.New("你是昔涟。", td, nil, agent.WithModel(ad, nil)) + if err != nil { + t.Fatalf("agent.New: %v", err) + } + l := New(Options{Agent: ag, 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 tool call was dropped)", res.Text) + } + if res.ToolCallsExecuted != 1 { + t.Fatalf("ToolCallsExecuted = %d, want 1", res.ToolCallsExecuted) + } + if secondBody == "" { + t.Fatal("adapter never made a second request (tool result was not fed back)") + } + for _, want := range []string{`"type":"tool_use"`, `"id":"toolu_1"`, `"type":"tool_result"`, `"tool_use_id":"toolu_1"`} { + if !strings.Contains(secondBody, want) { + t.Fatalf("second request missing %s:\n%s", want, secondBody) + } + } +} diff --git a/internal/runtime/loop/native_toolcall_test.go b/internal/runtime/loop/native_toolcall_test.go new file mode 100644 index 0000000..84fe3f8 --- /dev/null +++ b/internal/runtime/loop/native_toolcall_test.go @@ -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) + } +} diff --git a/internal/runtime/loop/request.go b/internal/runtime/loop/request.go index 468d43c..7a2ebff 100644 --- a/internal/runtime/loop/request.go +++ b/internal/runtime/loop/request.go @@ -6,7 +6,6 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" - "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool" ) // buildRequest assembles a model.Request from the agent's public surface, @@ -164,26 +163,11 @@ func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []c return items } -// decodeToolCalls decodes tool calls from a model.Response using the same two -// conventions as the agent package: the normalized RoleTool placeholder, then a -// JSON envelope of the form {"tool_calls":[...]} in Content. It reports ok=true -// (with a non-empty slice) when a call is decoded. +// decodeToolCalls decodes tool calls from a model.Response via the agent's shared +// decoder, so the streaming and non-streaming paths agree on the provider-native +// tool calls the adapters emit (Anthropic tool_use / OpenAI tool_calls) as well +// as the legacy JSON-envelope convention. It reports ok=true (with a non-empty +// slice) when a call is decoded. func decodeToolCalls(resp model.Response) ([]agent.ToolCall, bool) { - if resp.Message.Role == model.RoleAssistant && resp.Message.ToolCallName != "" { - return []agent.ToolCall{{Name: resp.Message.ToolCallName, Args: map[string]any{}}}, true - } - var payload struct { - ToolCalls []agent.ToolCall `json:"tool_calls"` - } - // Lenient decode repairs common model-emitted malformations (code fences, - // trailing commas) so a slightly broken tool-call envelope does not turn into - // a text answer; an unrecoverable payload still yields ok=false and the round - // proceeds as text. - if err := tool.LenientJSONUnmarshal([]byte(resp.Content), &payload); err != nil { - return nil, false - } - if len(payload.ToolCalls) == 0 { - return nil, false - } - return payload.ToolCalls, true + return agent.ExtractToolCalls(resp) } diff --git a/internal/runtime/loop/tool_history.go b/internal/runtime/loop/tool_history.go new file mode 100644 index 0000000..901a43a --- /dev/null +++ b/internal/runtime/loop/tool_history.go @@ -0,0 +1,49 @@ +package loop + +import ( + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// appendToolCallHistory renders the assistant tool-call turn followed by every +// tool result, in call order. When every call carries a provider id (native +// Anthropic tool_use / OpenAI tool_calls), the turn is emitted as native +// tool_use + tool_result blocks so the provider can correlate each result to its +// call; a batch without ids (the legacy JSON-envelope convention) keeps the +// plain RoleTool text feed-back shape. +func appendToolCallHistory(messages []model.Message, calls []agent.ToolCall, outcomes []ToolOutcome) []model.Message { + native := nativeModelToolCalls(calls) + if len(native) == 0 { + for i := range calls { + messages = append(messages, toolResultMessage(calls[i], outcomes[i])) + } + return messages + } + messages = append(messages, model.AssistantToolCallMessage(native)) + for i := range calls { + isError := outcomes[i].Err != nil || outcomes[i].Skipped + messages = append(messages, model.ToolResultMessage(calls[i].ID, toolResultText(calls[i], outcomes[i]), isError)) + } + return messages +} + +// nativeModelToolCalls converts a batch into model.ToolCall only when every call +// carries a provider id; a batch without ids (the legacy JSON-envelope +// convention) returns nil so the caller keeps the plain-text feed-back shape. +func nativeModelToolCalls(calls []agent.ToolCall) []model.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]model.ToolCall, 0, len(calls)) + for _, c := range calls { + if c.ID == "" { + return nil + } + args := c.Args + if args == nil { + args = map[string]any{} + } + out = append(out, model.ToolCall{ID: c.ID, Name: c.Name, Args: args}) + } + return out +} diff --git a/internal/runtime/loop/tools.go b/internal/runtime/loop/tools.go index b836f76..a7be672 100644 --- a/internal/runtime/loop/tools.go +++ b/internal/runtime/loop/tools.go @@ -214,10 +214,10 @@ func (l *Loop) runToolCalls(ctx context.Context, messages []model.Message, calls res.terminate = true } - // Render results in the original call order so the model sees a stable feed. - for i := range calls { - res.messages = append(res.messages, toolResultMessage(calls[i], outcomes[i])) - } + // Render the assistant tool-call turn (native providers require it so the + // following tool_result can be correlated) plus every result, in call order so + // the model sees a stable feed. + res.messages = appendToolCallHistory(res.messages, calls, outcomes) return res, nil }