返回 DeepSeek-Reasonix
argument_validation.go
根目录 / internal / agent / argument_validation.go
1 package agent
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "strings"
8 "unicode/utf8"
9
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 const maxArgumentValidationMessageBytes = 4 << 10
15
16 // applyArgumentValidation runs after a proxy has resolved to its concrete
17 // target and before hooks, permission, leases, subagents, or MCP tools/call.
18 func (a *Agent) applyResolvedTargetGates(plan *toolCallPlan) (toolOutcome, bool) {
19 if blocked, early := a.applyDispatchGenerationGate(plan); early {
20 return blocked, true
21 }
22 return a.applyArgumentValidation(plan)
23 }
24
25 func (a *Agent) applyArgumentValidation(plan *toolCallPlan) (toolOutcome, bool) {
26 if plan == nil || plan.execTool == nil {
27 return toolOutcome{}, false
28 }
29 normalized := tool.NormalizeArguments(plan.execArgs)
30 // Legacy shell aliases predate pwsh's required display description. Adapt
31 // only that call surface; canonical pwsh calls still use the strict schema.
32 if plan.execTool.Name() == "pwsh" && plan.call.Name != "pwsh" && tool.IsShellToolName(plan.call.Name) {
33 var args map[string]json.RawMessage
34 if json.Unmarshal(normalized, &args) == nil && args != nil {
35 if _, present := args["description"]; !present {
36 args["description"] = json.RawMessage(`"Run legacy PowerShell command"`)
37 normalized, _ = json.Marshal(args)
38 }
39 }
40 }
41 plan.execArgs = normalized
42 plan.permArgs = normalized
43 plan.evidenceArgs = normalized
44 result := tool.ValidateArguments(plan.execTool, normalized)
45 failed := result.CompileErr != nil || len(result.Violations) > 0
46 if a.capabilityAudit != nil {
47 a.capabilityAudit.RecordArgumentValidation(failed, result.Skipped, false)
48 }
49 if result.Skipped || (result.CompileErr == nil && len(result.Violations) == 0) {
50 return toolOutcome{}, false
51 }
52 return a.argumentValidationFailure(plan, result), true
53 }
54
55 // argumentValidationFailure reports an unexecuted call, never a permission
56 // refusal. Repeated failures are owned by the batch storm breaker.
57 func (a *Agent) argumentValidationFailure(plan *toolCallPlan, result tool.ArgumentValidationResult) toolOutcome {
58 category := "schema"
59 if result.CompileErr == nil {
60 category = result.Violations[0].Keyword
61 }
62 msg := argumentValidationMessage(plan, result)
63 a.noteCapabilityInvocation(plan.call.Name, json.RawMessage(plan.call.Arguments), errors.New(msg))
64 return toolOutcome{output: msg, errMsg: argumentValidationSignature(plan.permName, result.Fingerprint, category)}
65 }
66
67 // diagnoseCapabilityInputFailure runs only after the resolver identifies an
68 // input error. Successful resolution and unavailable/authorization errors keep
69 // their historical behavior, even if an ignored envelope field is invalid.
70 func (a *Agent) diagnoseCapabilityInputFailure(plan *toolCallPlan, err error) toolOutcome {
71 result := tool.ValidateArguments(plan.tool, json.RawMessage(plan.call.Arguments))
72 if !result.Skipped && (result.CompileErr != nil || len(result.Violations) > 0) {
73 if a.capabilityAudit != nil {
74 a.capabilityAudit.RecordArgumentValidation(true, false, false)
75 }
76 return a.argumentValidationFailure(plan, result)
77 }
78 return toolOutcome{
79 output: truncateValidationMessage(fmt.Sprintf("error: %v\nThe capability call was not executed. Correct the indicated input and retry; normal permission checks still apply.", err)),
80 errMsg: firstLine(err.Error()),
81 }
82 }
83
84 func hostValidateBeforeDispatch(target tool.Tool, args json.RawMessage, capabilityID string) (bool, string) {
85 result := tool.ValidateArguments(target, args)
86 if result.Skipped || (result.CompileErr == nil && len(result.Violations) == 0) {
87 return false, ""
88 }
89 return true, argumentValidationMessage(&toolCallPlan{
90 permName: target.Name(), execTool: target, execArgs: args,
91 call: provider.ToolCall{Name: "use_capability"},
92 resolved: tool.ResolvedCall{CapabilityID: capabilityID},
93 }, result)
94 }
95
96 func argumentValidationMessage(plan *toolCallPlan, result tool.ArgumentValidationResult) string {
97 if result.CompileErr != nil {
98 return truncateValidationMessage(fmt.Sprintf("host configuration error: tool %q has an invalid argument schema (schema fingerprint %s); execution was not dispatched. The host schema must be corrected; rewriting call arguments cannot fix it.", plan.permName, shortSchemaFingerprint(result.Fingerprint)))
99 }
100 var b strings.Builder
101 fmt.Fprintf(&b, "argument validation failed for %q (schema fingerprint %s; remote_dispatched=false):", plan.permName, shortSchemaFingerprint(result.Fingerprint))
102 for _, violation := range result.Violations {
103 path := violation.Path
104 if path == "" {
105 path = "/"
106 }
107 fmt.Fprintf(&b, "\n- %s: %s; expected %s", path, violation.Keyword, violation.Expected)
108 }
109 if id := strings.TrimSpace(plan.resolved.CapabilityID); id != "" {
110 fmt.Fprintf(&b, "\nThe target was not executed. Correct the target parameters inside %s.arguments; keep the outer capability call envelope.", plan.call.Name)
111 if strings.HasPrefix(id, "skill:") && plan.permName == "run_skill" {
112 b.WriteString("\nUse this exact nested call shape:\n")
113 b.WriteString(`{"action":"call","capability_id":"`)
114 b.WriteString(escapeJSONString(id))
115 b.WriteString(`","arguments":{"arguments":"specific review or implementation task"}}`)
116 } else {
117 fmt.Fprintf(&b, "\nInspect %q for its exact argument schema, if needed, then retry action=call with a JSON object matching it.", id)
118 }
119 } else {
120 fmt.Fprintf(&b, "\nThe call was not executed. Pass the parameters for %s directly at the root of its input object, correct the indicated errors and retry.", plan.permName)
121 }
122 b.WriteString("\nNormal permission checks still apply.")
123 if hasRedundantArgumentWrapper(plan.execTool, plan.execArgs) {
124 b.WriteString("\nThe sole \"arguments\" wrapper does not match this tool's schema; its inner object matches the expected parameters. Remove that one wrapper from the target parameters when retrying; keep any outer capability call envelope.")
125 }
126 return truncateValidationMessage(b.String())
127 }
128
129 // hasRedundantArgumentWrapper is a conservative, value-free hint, not a
130 // transformation. Call only after the original arguments failed validation.
131 func hasRedundantArgumentWrapper(target tool.Tool, raw json.RawMessage) bool {
132 if target == nil {
133 return false
134 }
135 var schema map[string]json.RawMessage
136 if json.Unmarshal(target.Schema(), &schema) != nil || string(schema["type"]) != `"object"` {
137 return false
138 }
139 for _, key := range []string{"$ref", "$dynamicRef", "$recursiveRef", "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "patternProperties", "dependencies", "dependentSchemas"} {
140 if _, exists := schema[key]; exists {
141 return false
142 }
143 }
144 var props map[string]json.RawMessage
145 if json.Unmarshal(schema["properties"], &props) != nil || props == nil {
146 return false
147 }
148 if _, exists := props["arguments"]; exists {
149 return false
150 }
151 var outer map[string]json.RawMessage
152 if json.Unmarshal(raw, &outer) != nil || len(outer) != 1 {
153 return false
154 }
155 inner, exists := outer["arguments"]
156 if !exists {
157 return false
158 }
159 var object map[string]json.RawMessage
160 if json.Unmarshal(inner, &object) != nil || object == nil {
161 return false
162 }
163 result := tool.ValidateArguments(target, inner)
164 return !result.Skipped && result.CompileErr == nil && len(result.Violations) == 0
165 }
166
167 func argumentValidationSignature(target, fingerprint, category string) string {
168 return "argument_validation:" + target + ":" + shortSchemaFingerprint(fingerprint) + ":" + category
169 }
170
171 func shortSchemaFingerprint(fingerprint string) string {
172 if len(fingerprint) <= 16 {
173 return fingerprint
174 }
175 return fingerprint[:16]
176 }
177
178 func escapeJSONString(value string) string {
179 b, _ := json.Marshal(value)
180 if len(b) < 2 {
181 return ""
182 }
183 return string(b[1 : len(b)-1])
184 }
185
186 func truncateValidationMessage(message string) string {
187 if len(message) <= maxArgumentValidationMessageBytes {
188 return message
189 }
190 end := maxArgumentValidationMessageBytes - len("\n[truncated]")
191 for end > 0 && !utf8.RuneStart(message[end]) {
192 end--
193 }
194 return message[:end] + "\n[truncated]"
195 }
196
196 lines GO