Files
AskaEth f347681034
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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绿+交叉编译
2026-09-08 22:37:05 +08:00

419 lines
14 KiB
Go

package agent
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// mockAdapter is a configurable model.Adapter for agent tests. It captures the
// request it was handed so tests can assert on routing/tool advertising.
type mockAdapter struct {
name string
resp model.Response
err error
gotReq model.Request
calls int
}
func (m *mockAdapter) Name() string { return m.name }
func (m *mockAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
m.gotReq = req
m.calls++
return m.resp, m.err
}
func (m *mockAdapter) ChatStream(_ context.Context, _ model.Request, _ func(model.StreamEvent)) (model.Response, error) {
return m.resp, m.err
}
// mockGuard is a configurable guardrail.Guardrail for agent tests.
type mockGuard struct {
inputVerdict *guardrail.Verdict
outputVerdict *guardrail.Verdict
}
func (m *mockGuard) CheckInput(_ context.Context, _ *model.Request) (*guardrail.Verdict, error) {
return m.inputVerdict, nil
}
func (m *mockGuard) CheckOutput(_ context.Context, _ *model.Response) (*guardrail.Verdict, error) {
return m.outputVerdict, nil
}
// mockTool is a minimal sdk.Tool used only to populate a Dispatcher.
type mockTool struct {
name string
desc string
params map[string]any
}
func (m mockTool) Name() string { return m.name }
func (m mockTool) Description() string { return m.desc }
func (m mockTool) Parameters() (map[string]any, error) { return m.params, nil }
func (m mockTool) Execute(context.Context, any) (any, error) { return nil, nil }
// dispatcherWith builds a Dispatcher backed by a fresh registry and registers
// the given tools.
func dispatcherWith(t *testing.T, tools ...sdk.Tool) *tool.Dispatcher {
t.Helper()
reg := registry.New()
d, err := tool.New(reg)
if err != nil {
t.Fatalf("tool.New: %v", err)
}
for _, tk := range tools {
if err := d.Register(tk); err != nil {
t.Fatalf("Register %q: %v", tk.Name(), err)
}
}
return d
}
// newTestAgent builds an Agent with the given adapter, guardrails and extra
// options, wiring WithModel(adapter, nil) by default.
func newTestAgent(t *testing.T, adapter model.Adapter, guards *guardrail.Pipeline, opts ...Option) *Agent {
t.Helper()
base := []Option{WithModel(adapter, nil)}
base = append(base, opts...)
a, err := New("You are a helpful assistant.", nil, guards, base...)
if err != nil {
t.Fatalf("New: %v", err)
}
return a
}
func TestNew_EmptyInstructions(t *testing.T) {
t.Parallel()
if _, err := New("", nil, nil); !errors.Is(err, ErrEmptyInstructions) {
t.Fatalf("New(\"\") error = %v, want ErrEmptyInstructions", err)
}
}
func TestNew_BadOptions(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opts Options
}{
{name: "temperature below range", opts: Options{Temperature: -0.1}},
{name: "temperature above range", opts: Options{Temperature: 2.5}},
{name: "negative max rounds", opts: Options{MaxRounds: -1}},
{name: "negative max tokens", opts: Options{MaxTokens: -1}},
}
for _, tt := range cases {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if _, err := New("instr", nil, nil, WithOptions(tt.opts)); !errors.Is(err, ErrBadOptions) {
t.Fatalf("New error = %v, want ErrBadOptions", err)
}
})
}
}
func TestNew_DefaultsMaxRounds(t *testing.T) {
t.Parallel()
a, err := New("instr", nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if a.MaxRounds() != defaultMaxRounds {
t.Fatalf("MaxRounds = %d, want %d", a.MaxRounds(), defaultMaxRounds)
}
if a.Purpose() != defaultPurpose {
t.Fatalf("Purpose = %q, want %q", a.Purpose(), defaultPurpose)
}
}
func TestRunStep_NoToolCalls_FinalText(t *testing.T) {
t.Parallel()
ad := &mockAdapter{name: "mock", resp: model.Response{Model: "gpt-test", Content: "Hello!", TotalTokens: 7}}
a := newTestAgent(t, ad, nil)
res, err := a.RunStep(context.Background(), "sess-1", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("RunStep: %v", err)
}
if !res.Final {
t.Fatalf("Final = false, want true")
}
if res.Text != "Hello!" {
t.Fatalf("Text = %q, want Hello!", res.Text)
}
if res.StopReason != StopReasonStop {
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonStop)
}
if len(res.ToolCalls) != 0 {
t.Fatalf("ToolCalls = %v, want none", res.ToolCalls)
}
}
func TestRunStep_ToolCalls(t *testing.T) {
t.Parallel()
content := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{resp: model.Response{Model: "m", Content: content}}
a := newTestAgent(t, ad, nil)
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("RunStep: %v", err)
}
if res.Final {
t.Fatalf("Final = true, want false")
}
if res.StopReason != StopReasonToolCalls {
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonToolCalls)
}
if len(res.ToolCalls) != 1 {
t.Fatalf("ToolCalls = %v, want 1", res.ToolCalls)
}
call := res.ToolCalls[0]
if call.Name != "echo" {
t.Fatalf("ToolCall.Name = %q, want echo", call.Name)
}
if !reflect.DeepEqual(call.Args, map[string]any{"q": "hi"}) {
t.Fatalf("ToolCall.Args = %v, want {q: hi}", call.Args)
}
}
func TestRunStep_ToolCallPlaceholder(t *testing.T) {
t.Parallel()
// The model.Message placeholder convention: a single call with no args.
ad := &mockAdapter{resp: model.Response{
Model: "m",
Content: "",
Message: model.Message{Role: model.RoleAssistant, ToolCallName: "weather"},
}}
a := newTestAgent(t, ad, nil)
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("RunStep: %v", err)
}
if res.Final {
t.Fatalf("Final = true, want false")
}
if len(res.ToolCalls) != 1 || res.ToolCalls[0].Name != "weather" {
t.Fatalf("ToolCalls = %v, want single weather call", res.ToolCalls)
}
}
func TestRunStep_GuardrailInputBlocked(t *testing.T) {
t.Parallel()
g := &mockGuard{inputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked in"}}
pipe := guardrail.New(guardrail.WithGuard(g))
ad := &mockAdapter{resp: model.Response{Content: "should not run"}}
a := newTestAgent(t, ad, pipe)
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("RunStep: %v", err)
}
if !res.Final {
t.Fatalf("Final = false, want true")
}
if res.Text != "blocked in" {
t.Fatalf("Text = %q, want blocked in", res.Text)
}
if res.StopReason != StopReasonGuardrailInput {
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonGuardrailInput)
}
if ad.calls != 0 {
t.Fatalf("adapter Chat calls = %d, want 0 (blocked before the model)", ad.calls)
}
}
func TestRunStep_GuardrailOutputBlocked(t *testing.T) {
t.Parallel()
g := &mockGuard{outputVerdict: &guardrail.Verdict{Allow: false, Reason: "blocked out"}}
pipe := guardrail.New(guardrail.WithGuard(g))
ad := &mockAdapter{resp: model.Response{Content: "model says this"}}
a := newTestAgent(t, ad, pipe)
res, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")})
if err != nil {
t.Fatalf("RunStep: %v", err)
}
if !res.Final {
t.Fatalf("Final = false, want true")
}
if res.Text != "blocked out" {
t.Fatalf("Text = %q, want blocked out", res.Text)
}
if res.StopReason != StopReasonGuardrailOutput {
t.Fatalf("StopReason = %q, want %q", res.StopReason, StopReasonGuardrailOutput)
}
}
func TestRunStep_MultimodalRouting(t *testing.T) {
t.Parallel()
router := model.NewRouter("default",
model.WithRoleModel(model.RoleUser, "user-model"),
model.WithVisionModel("vision-model"),
)
ad := &mockAdapter{resp: model.Response{Model: "vision-model", Content: "ok"}}
a := newTestAgent(t, ad, nil, WithModel(ad, router))
msg := model.ImageMessage(model.RoleUser, "what is this", "data:image/png;base64,abc")
if _, err := a.RunStep(context.Background(), "s", []model.Message{msg}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if ad.gotReq.Model != "vision-model" {
t.Fatalf("req.Model = %q, want vision-model (multimodal routing)", ad.gotReq.Model)
}
}
func TestRunStep_CallRecord(t *testing.T) {
t.Parallel()
var recs []model.CallRecord
ad := &mockAdapter{resp: model.Response{Model: "m", Content: "hi", TotalTokens: 9}}
a := newTestAgent(t, ad, nil,
WithRecorder(func(cr model.CallRecord) { recs = append(recs, cr) }),
WithOptions(Options{Purpose: "test"}),
)
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if len(recs) != 1 {
t.Fatalf("records = %d, want 1", len(recs))
}
rec := recs[0]
if rec.Model != "m" || rec.Purpose != "test" || rec.Tokens != 9 || rec.TookMs < 0 {
t.Fatalf("record = %+v, want model=m purpose=test tokens=9 took_ms>=0", rec)
}
}
func TestRunStep_NoAdapter(t *testing.T) {
t.Parallel()
a, err := New("instr", nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := a.RunStep(context.Background(), "s", nil); !errors.Is(err, ErrNoAdapter) {
t.Fatalf("RunStep error = %v, want ErrNoAdapter", err)
}
}
func TestRunStep_ChatError(t *testing.T) {
t.Parallel()
boom := errors.New("upstream boom")
ad := &mockAdapter{err: boom}
a := newTestAgent(t, ad, nil)
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, boom) {
t.Fatalf("RunStep error = %v, want to wrap %v", err, boom)
}
}
func TestRunStep_PrependsInstructions(t *testing.T) {
t.Parallel()
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
a := newTestAgent(t, ad, nil)
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
msgs := ad.gotReq.Messages
if len(msgs) != 2 {
t.Fatalf("len(messages) = %d, want 2 (instruction + user)", len(msgs))
}
if msgs[0].Role != model.RoleSystem || len(msgs[0].Content) != 1 || !strings.Contains(msgs[0].Content[0].Text, "helpful") {
t.Fatalf("first message = %+v, want leading system instruction", msgs[0])
}
}
func TestRunStep_AdvertisesToolSchemas(t *testing.T) {
t.Parallel()
td := dispatcherWith(t, mockTool{name: "echo", desc: "echo the input", params: map[string]any{"type": "object"}})
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
a, err := New("instr", td, nil, WithModel(ad, nil))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if len(ad.gotReq.Tools) != 1 {
t.Fatalf("req.Tools = %v, want 1 advertised schema", ad.gotReq.Tools)
}
if ad.gotReq.Tools[0].Name != "echo" {
t.Fatalf("req.Tools[0].Name = %q, want echo", ad.gotReq.Tools[0].Name)
}
}
func TestRunStep_NoDispatcherNoTools(t *testing.T) {
t.Parallel()
ad := &mockAdapter{resp: model.Response{Content: "hi"}}
a := newTestAgent(t, ad, nil)
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil {
t.Fatalf("RunStep: %v", err)
}
if ad.gotReq.Tools != nil {
t.Fatalf("req.Tools = %v, want nil", ad.gotReq.Tools)
}
}
func TestRunStep_ContextCanceled(t *testing.T) {
t.Parallel()
ad := &mockAdapter{err: context.Canceled}
a := newTestAgent(t, ad, nil)
if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); !errors.Is(err, context.Canceled) {
t.Fatalf("RunStep error = %v, want context.Canceled", err)
}
}
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")
}
}
func TestHandoff_Placeholder(t *testing.T) {
t.Parallel()
a := newTestAgent(t, &mockAdapter{resp: model.Response{Content: "hi"}}, nil, WithHandoff(NewHandoff("engineer", "needs code work")))
h := a.Handoff()
if h == nil || h.Target != "engineer" || h.Reason != "needs code work" {
t.Fatalf("Handoff = %+v, want engineer/needs code work", h)
}
}
func TestAgent_Accessors(t *testing.T) {
t.Parallel()
td := dispatcherWith(t, mockTool{name: "echo"})
pipe := guardrail.New(guardrail.WithGuard(&mockGuard{}))
ad := &mockAdapter{name: "mock"}
a, err := New("instr", td, pipe, WithModel(ad, nil), WithOptions(Options{
Model: "gpt-4", Temperature: 0.7, MaxRounds: 4, Purpose: "chat", MaxTokens: 512,
}))
if err != nil {
t.Fatalf("New: %v", err)
}
if a.Instructions() != "instr" || a.Tools() != td || a.Guardrails() != pipe || a.Adapter() != ad {
t.Fatalf("accessor mismatch for instructions/tools/guardrails/adapter")
}
if a.Model() != "gpt-4" || a.Temperature() != 0.7 || a.MaxRounds() != 4 || a.Purpose() != "chat" || a.MaxTokens() != 512 {
t.Fatalf("accessor mismatch for config: %+v", a.Options())
}
}
func TestTemperature_ZeroMapsToNil(t *testing.T) {
t.Parallel()
a, err := New("instr", nil, nil, WithOptions(Options{Temperature: 1.2}))
if err != nil {
t.Fatalf("New: %v", err)
}
if a.temperature() == nil || *a.temperature() != 1.2 {
t.Fatalf("temperature() = %v, want 1.2", a.temperature())
}
a2, err := New("instr", nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if a2.temperature() != nil {
t.Fatalf("temperature() = %v, want nil for zero", a2.temperature())
}
}