package agent import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "go-micro.dev/v6/ai" "go-micro.dev/v6/registry" "go-micro.dev/v6/store" "go-micro.dev/v6/wrapper/x402" ) // toolContent runs a tool call through a handler and returns the content // shown to the model — the part these tests assert on. func toolContent(h ai.ToolHandler, name string, input map[string]any) string { return h(context.Background(), ai.ToolCall{Name: name, Input: input}).Content } // MaxSteps refuses tool calls once the per-Ask limit is exceeded; plan // is bookkeeping and is never counted. func TestMaxStepsStopsActions(t *testing.T) { a := newTestAgent(Name("limited"), MaxSteps(2)) h := a.toolHandler() // plan must not consume a step. a.steps = 0 toolContent(h, toolPlan, map[string]any{"steps": []any{}}) if a.steps != 0 { t.Fatalf("plan consumed a step: steps=%d", a.steps) } // First two actions are allowed (they fall through to RPC, which // fails harmlessly — we only care they weren't refused by the limit). for i := 1; i <= 2; i++ { content := toolContent(h, "demo_Svc_Do", map[string]any{}) if strings.Contains(content, "step limit") { t.Fatalf("action %d wrongly hit the step limit", i) } } // Third action exceeds MaxSteps(2) and must be refused. content := toolContent(h, "demo_Svc_Do", map[string]any{}) if !strings.Contains(content, "step limit") { t.Errorf("third action should hit the step limit; got %q", content) } } // ApproveTool blocks an action when the hook denies it, and the denial // reason is surfaced to the model. func TestApproveToolBlocks(t *testing.T) { var sawTool string a := newTestAgent(Name("gated"), ApproveTool(func(tool string, input map[string]any) (bool, string) { sawTool = tool return false, "needs sign-off" }), ) content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{}) if sawTool != "demo_Svc_Do" { t.Errorf("approver saw %q, want demo_Svc_Do", sawTool) } if !strings.Contains(content, "not approved") || !strings.Contains(content, "needs sign-off") { t.Errorf("blocked call should surface the reason; got %q", content) } } // A denying approver must not gate the internal plan tool. func TestApproveToolDoesNotGatePlan(t *testing.T) { mem := store.NewMemoryStore() a := New( Name("gated"), Provider("fake"), WithRegistry(registry.NewMemoryRegistry()), WithStore(mem), ApproveTool(func(tool string, input map[string]any) (bool, string) { return false, "deny everything" }), ).(*agentImpl) a.setup() content := toolContent(a.toolHandler(), toolPlan, map[string]any{ "steps": []any{map[string]any{"task": "x", "status": "pending"}}, }) if strings.Contains(content, "not approved") { t.Errorf("plan must not be gated by ApproveTool; got %q", content) } if recs, _ := store.Scope(mem, "agent", "gated").Read(planKey); len(recs) == 0 { t.Error("plan should have been persisted despite the denying approver") } } func TestMaxSpendAllowsPaidToolWithinBudget(t *testing.T) { calls := 0 a := newTestAgent(Name("paid-within-budget"), MaxSpend(10), ToolSpend("paid.lookup", 7), WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) { calls++ return `{"ok":true}`, nil }), ) res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}}) if calls != 1 { t.Fatalf("paid tool was not executed") } if res.Refused != "" { t.Fatalf("paid tool was refused: %+v", res) } if res.Content != `{"ok":true}` { t.Fatalf("content = %q, want paid result", res.Content) } } func TestMaxSpendRefusesPaidToolBeforePaymentWhenBudgetExceeded(t *testing.T) { calls := 0 a := newTestAgent(Name("paid-over-budget"), MaxSpend(5), ToolSpend("paid.lookup", 7), WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) { calls++ return `{"ok":true}`, nil }), ) res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}}) if calls != 0 { t.Fatalf("paid tool ran despite budget refusal") } if res.Refused != ai.RefusedSpendBudget { t.Fatalf("Refused = %q, want %q (result %+v)", res.Refused, ai.RefusedSpendBudget, res) } if !strings.Contains(res.Content, "x402 spend budget exceeded") { t.Fatalf("content = %q, want inspectable budget refusal", res.Content) } } func TestMaxSpendRollsBackFailedPaidToolReservation(t *testing.T) { calls := 0 a := newTestAgent(Name("paid-rollback"), MaxSpend(10), ToolSpend("paid.lookup", 7), WithTool("paid.lookup", "paid lookup", nil, func(context.Context, map[string]any) (string, error) { calls++ if calls == 1 { return "", context.Canceled } return `{"ok":true}`, nil }), ) h := a.toolHandler() first := h(context.Background(), ai.ToolCall{ID: "paid-1", Name: "paid.lookup", Input: map[string]any{}}) if first.Refused != "" || !strings.Contains(first.Content, "context canceled") { t.Fatalf("first result = %+v, want tool error without guardrail refusal", first) } second := h(context.Background(), ai.ToolCall{ID: "paid-2", Name: "paid.lookup", Input: map[string]any{}}) if second.Refused != "" || second.Content != `{"ok":true}` { t.Fatalf("second result = %+v, want reservation rollback to allow retry", second) } } func TestNestedTextToolCallArgumentsAreRefused(t *testing.T) { called := false a := newTestAgent(Name("nested-tool-arg"), WithTool("task.add", "add task", nil, func(context.Context, map[string]any) (string, error) { called = true return "created", nil }), ) content := toolContent(a.toolHandler(), "task.add", map[string]any{ "title": `Continue the launch plan. {"steps":[{"task":"Design","status":"pending"}]}`, }) if called { t.Fatal("tool handler ran despite nested text tool-call markup in arguments") } if !strings.Contains(content, "nested text tool-call markup") { t.Fatalf("content = %q, want nested tool-call refusal", content) } } type agentMockPayer struct{ calls int } func (p *agentMockPayer) Pay(ctx context.Context, req x402.Requirements) (string, error) { p.calls++ return "paid", nil } func TestAgentPayerPaysX402ToolResultAndRetries(t *testing.T) { paid := false srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get(x402.PaymentHeader) == "paid" { paid = true _, _ = w.Write([]byte(`{"ok":true}`)) return } w.WriteHeader(http.StatusPaymentRequired) json.NewEncoder(w).Encode(map[string]any{ "x402Version": x402.Version, "accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}}, }) })) defer srv.Close() payer := &agentMockPayer{} st := store.NewMemoryStore() a := newTestAgent(Name("x402-payer"), WithStore(st), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(body), nil })) ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "run-paid", Agent: "x402-payer"}) res := a.toolHandler()(ctx, ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}}) if !paid || payer.calls != 1 { t.Fatalf("payment not made: paid=%v payer.calls=%d", paid, payer.calls) } if res.Content != `{"ok":true}` || res.Attempts != 2 { t.Fatalf("result = %+v, want paid response with retry attempt", res) } events, err := LoadRunEvents(st, "x402-payer", "run-paid") if err != nil { t.Fatal(err) } if len(events) != 1 || events[0].Spent != 7 || events[0].ToolSpend != 7 { t.Fatalf("spend events = %#v, want one tool event with spent/tool_spend 7", events) } } func TestAgentPayerRefusesX402OverBudget(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPaymentRequired) json.NewEncoder(w).Encode(map[string]any{ "x402Version": x402.Version, "accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "70", Resource: r.URL.String(), PayTo: "0xmerchant"}}, }) })) defer srv.Close() payer := &agentMockPayer{} a := newTestAgent(Name("x402-over-budget"), Payer(payer), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(body), nil })) res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}}) if payer.calls != 0 { t.Fatalf("payer called despite over-budget refusal") } if res.Refused != ai.RefusedSpendBudget || !strings.Contains(res.Content, "would exceed budget") { t.Fatalf("result = %+v, want budget refusal", res) } } func TestAgentPayerRequiredWithoutPayerReturnsClearError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPaymentRequired) json.NewEncoder(w).Encode(map[string]any{ "x402Version": x402.Version, "accepts": []x402.Requirements{{Scheme: "exact", Network: "base", MaxAmountRequired: "7", Resource: r.URL.String(), PayTo: "0xmerchant"}}, }) })) defer srv.Close() a := newTestAgent(Name("x402-no-payer"), Budget(10), WithTool("paid.http", "paid http", nil, func(ctx context.Context, input map[string]any) (string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) if err != nil { return "", err } resp, err := http.DefaultClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(body), nil })) res := a.toolHandler()(context.Background(), ai.ToolCall{ID: "pay-1", Name: "paid.http", Input: map[string]any{"url": srv.URL}}) if !strings.Contains(res.Content, "no Payer configured") { t.Fatalf("content = %q, want no payer error", res.Content) } }