package agent import ( "context" "fmt" "strings" "testing" "go-micro.dev/v6/ai" "go-micro.dev/v6/registry" "go-micro.dev/v6/store" ) // A registered wrapper runs around every tool call and can observe and // modify the result. func TestWrapToolWraps(t *testing.T) { var saw string wrap := func(next ai.ToolHandler) ai.ToolHandler { return func(ctx context.Context, call ai.ToolCall) ai.ToolResult { saw = call.Name res := next(ctx, call) res.Content = "wrapped:" + res.Content return res } } a := newTestAgent(Name("wrapped"), WrapTool(wrap)) content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{}) if saw != "demo_Svc_Do" { t.Errorf("wrapper saw %q, want demo_Svc_Do", saw) } if !strings.HasPrefix(content, "wrapped:") { t.Errorf("wrapper did not modify the result; got %q", content) } } // Multiple wrappers compose outermost-first: the first registered wrapper // is the outer layer, so it runs first on the way in and last on the way // out. func TestWrapToolOrder(t *testing.T) { var order []string mk := func(tag string) ai.ToolWrapper { return func(next ai.ToolHandler) ai.ToolHandler { return func(ctx context.Context, call ai.ToolCall) ai.ToolResult { order = append(order, "in:"+tag) res := next(ctx, call) order = append(order, "out:"+tag) return res } } } a := newTestAgent(Name("ordered"), WrapTool(mk("a"), mk("b"))) toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{}) want := "in:a in:b out:b out:a" if got := strings.Join(order, " "); got != want { t.Errorf("wrapper order = %q, want %q", got, want) } } // Wrappers run outside the built-in guardrails, so they observe a refused // call and its refusal result rather than being short-circuited. func TestWrapToolSeesGuardrailRefusal(t *testing.T) { var sawResult string wrap := func(next ai.ToolHandler) ai.ToolHandler { return func(ctx context.Context, call ai.ToolCall) ai.ToolResult { res := next(ctx, call) sawResult = res.Content return res } } a := newTestAgent(Name("gated-wrap"), ApproveTool(func(tool string, input map[string]any) (bool, string) { return false, "denied" }), WrapTool(wrap), ) toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{}) if !strings.Contains(sawResult, "not approved") { t.Errorf("wrapper should observe the guardrail refusal; got %q", sawResult) } } // A guardrail refusal carries a structured reason a wrapper can switch on, // so reliability tooling (e.g. loop handling) needn't parse the message. func TestWrapToolSeesRefusedReason(t *testing.T) { a := newTestAgent(Name("looper"), LoopLimit(2)) h := a.toolHandler() var last ai.ToolResult for i := 0; i < 3; i++ { last = h(context.Background(), ai.ToolCall{ID: "x", Name: "demo_Svc_Do", Input: map[string]any{"q": "same"}}) } if last.Refused != ai.RefusedLoop { t.Errorf("Refused = %q, want %q", last.Refused, ai.RefusedLoop) } } // ctxMock is a model that forwards the Generate context to the tool // handler (as real providers do), so a wrapper can read ai.RunInfo. type ctxMock struct{ opts ai.Options } func (m *ctxMock) Init(opts ...ai.Option) error { for _, o := range opts { o(&m.opts) } return nil } func (m *ctxMock) Options() ai.Options { return m.opts } func (m *ctxMock) String() string { return "ctxmock" } func (m *ctxMock) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) { return nil, fmt.Errorf("no stream") } func (m *ctxMock) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) { if m.opts.ToolHandler != nil { m.opts.ToolHandler(ctx, ai.ToolCall{ID: "c1", Name: "demo_Svc_Do", Input: map[string]any{}}) } return &ai.Response{Answer: "done"}, nil } // During an Ask, a wrapper sees RunInfo on the context: a correlation id // for the run and the agent's name. func TestWrapToolSeesRunInfo(t *testing.T) { ai.Register("ctxmock", func(opts ...ai.Option) ai.Model { m := &ctxMock{} _ = m.Init(opts...) return m }) var got ai.RunInfo var ok bool a := New( Name("runner"), Provider("ctxmock"), WithRegistry(registry.NewMemoryRegistry()), WithStore(store.NewMemoryStore()), WrapTool(func(next ai.ToolHandler) ai.ToolHandler { return func(ctx context.Context, call ai.ToolCall) ai.ToolResult { got, ok = ai.RunInfoFrom(ctx) return next(ctx, call) } }), ) resp, err := a.Ask(context.Background(), "go") if err != nil { t.Fatalf("Ask: %v", err) } if !ok { t.Fatal("wrapper did not see RunInfo on the context") } if got.Agent != "runner" { t.Errorf("RunInfo.Agent = %q, want runner", got.Agent) } if got.RunID == "" { t.Error("RunInfo.RunID is empty") } if resp.RunID != got.RunID { t.Errorf("Response.RunID = %q, want wrapper RunID %q", resp.RunID, got.RunID) } if resp.ParentID != "" { t.Errorf("Response.ParentID = %q, want empty", resp.ParentID) } } // call.Scan decodes a tool call's input into a typed struct. func TestToolCallScan(t *testing.T) { call := ai.ToolCall{Input: map[string]any{"query": "hello", "limit": 5}} var args struct { Query string `json:"query"` Limit int `json:"limit"` } if err := call.Scan(&args); err != nil { t.Fatalf("Scan: %v", err) } if args.Query != "hello" || args.Limit != 5 { t.Errorf("Scan decoded %+v, want {hello 5}", args) } }