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()) } }