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