返回 DeepSeek-Reasonix
argument_validation_test.go
根目录 / internal / agent / argument_validation_test.go
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
197 lines GO