| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "strings" |
| 9 | "sync/atomic" |
| 10 | "testing" |
| 11 | |
| 12 | "reasonix/internal/agent/testutil" |
| 13 | "reasonix/internal/capability" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/plugin" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/skill" |
| 18 | "reasonix/internal/tool" |
| 19 | ) |
| 20 | |
| 21 | func TestRunSkillMissingNestedArgumentsDoesNotStartSubagent(t *testing.T) { |
| 22 | store := skillStoreWithArchitect(t) |
| 23 | var started atomic.Int32 |
| 24 | reg := tool.NewRegistry() |
| 25 | reg.Add(skill.NewRunSkillTool(store, func(context.Context, skill.Skill, string, skill.SubagentRunOptions) (string, error) { |
| 26 | started.Add(1) |
| 27 | return "ran", nil |
| 28 | })) |
| 29 | proxy := NewUseCapabilityTool(context.Background(), nil, nil, reg, nil, nil, func() capability.Catalog { |
| 30 | return capability.Catalog{Entries: []capability.Entry{{ID: "skill:team-architect", Kind: capability.KindSkill, Name: "team-architect"}}} |
| 31 | }) |
| 32 | reg.Add(proxy) |
| 33 | a := New(nil, reg, NewSession("sys"), Options{}, event.Discard) |
| 34 | out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{ |
| 35 | ID: "bad-1", Name: "use_capability", |
| 36 | Arguments: `{"action":"call","capability_id":"skill:team-architect","arguments":{}}`, |
| 37 | }) |
| 38 | if started.Load() != 0 { |
| 39 | t.Fatal("subagent started despite missing inner arguments") |
| 40 | } |
| 41 | if !strings.Contains(out.output, `"arguments":{"arguments"`) && !strings.Contains(out.output, `"arguments":"specific`) { |
| 42 | t.Fatalf("missing nested example:\n%s", out.output) |
| 43 | } |
| 44 | if strings.Contains(out.output, "remote_dispatched=true") { |
| 45 | t.Fatal("validation error must not claim remote dispatch") |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | func TestMCPInvalidArgumentsDoNotCallRemote(t *testing.T) { |
| 50 | t.Setenv("REASONIX_CACHE_HOME", t.TempDir()) |
| 51 | var toolCalls atomic.Int32 |
| 52 | server := requiredQueryMCPServer(t, &toolCalls) |
| 53 | defer server.Close() |
| 54 | ctx := context.Background() |
| 55 | host := plugin.NewHost() |
| 56 | defer host.Close() |
| 57 | spec := plugin.Spec{Name: "svc", Type: "http", URL: server.URL, Authorized: true} |
| 58 | runtime := NewMCPCapabilityRuntime(ctx, host, []plugin.Spec{spec}, tool.NewRegistry(), nil) |
| 59 | proxy := runtime.NewFrontend(capability.NewLedger(), nil) |
| 60 | reg := tool.NewRegistry() |
| 61 | reg.Add(proxy) |
| 62 | audit := &capability.Audit{} |
| 63 | a := New(nil, reg, NewSession("sys"), Options{CapabilityAudit: audit}, event.Discard) |
| 64 | out := a.executeOne(ctx, &a.turn, provider.ToolCall{ |
| 65 | ID: "mcp-bad", Name: "use_capability", |
| 66 | Arguments: `{"action":"call","capability_id":"mcp-tool:svc/search","arguments":{}}`, |
| 67 | }) |
| 68 | if toolCalls.Load() != 0 { |
| 69 | t.Fatalf("tools/call = %d, want 0", toolCalls.Load()) |
| 70 | } |
| 71 | if !strings.Contains(out.output, "argument validation failed") && !strings.Contains(out.output, "required") { |
| 72 | t.Fatalf("expected validation failure, got %q", out.output) |
| 73 | } |
| 74 | if got := audit.Snapshot().Arguments.RemoteDispatch; got != 0 { |
| 75 | t.Fatalf("remote dispatch audit = %d, want 0", got) |
| 76 | } |
| 77 | valid := a.executeOne(ctx, &a.turn, provider.ToolCall{ |
| 78 | ID: "mcp-good", Name: "use_capability", |
| 79 | Arguments: `{"action":"call","capability_id":"mcp-tool:svc/search","arguments":{"q":"reasonix"}}`, |
| 80 | }) |
| 81 | if valid.errMsg != "" { |
| 82 | t.Fatalf("valid call = %+v", valid) |
| 83 | } |
| 84 | if got := audit.Snapshot().Arguments.RemoteDispatch; got != 1 { |
| 85 | t.Fatalf("remote dispatch audit = %d, want 1", got) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestUnavailableMCPCallPreservesResolutionReason(t *testing.T) { |
| 90 | runtime := NewMCPCapabilityRuntime(t.Context(), nil, nil, tool.NewRegistry(), nil) |
| 91 | proxy := runtime.NewFrontend(capability.NewLedger(), nil) |
| 92 | reg := tool.NewRegistry() |
| 93 | reg.Add(proxy) |
| 94 | a := New(nil, reg, NewSession("sys"), Options{}, event.Discard) |
| 95 | out := a.executeOne(t.Context(), &a.turn, provider.ToolCall{ |
| 96 | ID: "mcp-missing", Name: "use_capability", |
| 97 | Arguments: `{"action":"call","capability_id":"mcp-tool:never-configured/ping","arguments":{}}`, |
| 98 | }) |
| 99 | if !strings.Contains(out.output, `MCP server "never-configured" is not registered in this session`) { |
| 100 | t.Fatalf("unavailable output = %q", out.output) |
| 101 | } |
| 102 | if strings.Contains(out.output, "argument validation failed") { |
| 103 | t.Fatalf("unavailable resolution was validated as a concrete tool: %q", out.output) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func requiredQueryMCPServer(t *testing.T, toolCalls *atomic.Int32) *httptest.Server { |
| 108 | t.Helper() |
| 109 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 110 | var request struct { |
| 111 | ID *int `json:"id"` |
| 112 | Method string `json:"method"` |
| 113 | } |
| 114 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { |
| 115 | http.Error(w, "bad request", http.StatusBadRequest) |
| 116 | return |
| 117 | } |
| 118 | if request.ID == nil { |
| 119 | w.WriteHeader(http.StatusAccepted) |
| 120 | return |
| 121 | } |
| 122 | var result any |
| 123 | switch request.Method { |
| 124 | case "initialize": |
| 125 | result = map[string]any{"protocolVersion": "2024-11-05", "serverInfo": map[string]any{"name": "svc", "version": "1"}} |
| 126 | case "tools/list": |
| 127 | result = map[string]any{"tools": []map[string]any{{ |
| 128 | "name": "search", "description": "search", |
| 129 | "inputSchema": map[string]any{ |
| 130 | "type": "object", |
| 131 | "properties": map[string]any{"q": map[string]any{"type": "string"}}, |
| 132 | "required": []string{"q"}, |
| 133 | }, |
| 134 | "annotations": map[string]any{"readOnlyHint": true}, |
| 135 | }}} |
| 136 | case "tools/call": |
| 137 | toolCalls.Add(1) |
| 138 | result = map[string]any{"content": []map[string]any{{"type": "text", "text": "nope"}}} |
| 139 | } |
| 140 | w.Header().Set("Content-Type", "application/json") |
| 141 | _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": *request.ID, "result": result}) |
| 142 | })) |
| 143 | } |
| 144 | |
| 145 | // A malformed call is an unexecuted tool result, not a failed network attempt. |
| 146 | // A legal call in the same batch stays completed while the model corrects it. |
| 147 | func TestToolArgumentsCorrectedAfterStopWithoutRepeatingSuccess(t *testing.T) { |
| 148 | good := provider.ToolCall{ID: "good", Name: "echo", Arguments: `{"text":"first"}`} |
| 149 | bad := provider.ToolCall{ID: "bad", Name: "echo", Arguments: `{"text":123}`} |
| 150 | fixed := provider.ToolCall{ID: "fixed", Name: "echo", Arguments: `{"text":"second"}`} |
| 151 | p := &argumentBudgetProvider{MockProvider: testutil.NewMock("test", testutil.Turn{Chunks: []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &good}, {Type: provider.ChunkToolCall, ToolCall: &bad}, {Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop"}}, {Type: provider.ChunkDone}}}, testutil.Turn{ToolCalls: []provider.ToolCall{fixed}}, testutil.Turn{Text: "done"})} |
| 152 | sink := &recordSink{} |
| 153 | a := New(p, echoRegistry(), NewSession("system"), Options{ModelRef: t.Name()}, sink) |
| 154 | if err := a.Run(withNoClosedLoop(context.Background()), "use echo"); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | if p.CallCount() != 3 || len(sink.kinds(event.Retrying)) != 0 { |
| 158 | t.Fatal("argument correction used network retry") |
| 159 | } |
| 160 | // Execution output precedes host guidance; both must survive independently. |
| 161 | results := map[string]string{} |
| 162 | for _, e := range sink.kinds(event.ToolResult) { |
| 163 | if _, exists := results[e.Tool.ID]; exists { |
| 164 | t.Fatal("duplicate execution result") |
| 165 | } |
| 166 | results[e.Tool.ID] = e.Tool.Output |
| 167 | } |
| 168 | recorded := map[string]bool{} |
| 169 | for _, m := range a.Session().Snapshot() { |
| 170 | if m.Role == provider.RoleTool { |
| 171 | if recorded[m.ToolCallID] { |
| 172 | t.Fatal("duplicate result") |
| 173 | } |
| 174 | recorded[m.ToolCallID] = true |
| 175 | } |
| 176 | } |
| 177 | if len(recorded) != 3 { |
| 178 | t.Fatalf("recorded=%v", recorded) |
| 179 | } |
| 180 | if results["good"] != "echoed: first" || results["fixed"] != "echoed: second" || !strings.Contains(results["bad"], "text") { |
| 181 | t.Fatalf("results=%+v", results) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // The stream boundary runs after turn initialization, without a clock dependency. |
| 186 | type argumentBudgetProvider struct { |
| 187 | *testutil.MockProvider |
| 188 | before func() |
| 189 | } |
| 190 | |
| 191 | func (p *argumentBudgetProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) { |
| 192 | if p.CallCount() == 0 && p.before != nil { |
| 193 | p.before() |
| 194 | } |
| 195 | return p.MockProvider.Stream(ctx, req) |
| 196 | } |
| 197 |