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