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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user