返回 DeepSeek-Reasonix
arguments.go
根目录 / internal / tool / arguments.go
1 package tool
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "strconv"
12 "strings"
13 "sync"
14
15 jsonschema "github.com/santhosh-tekuri/jsonschema/v6"
16 "github.com/santhosh-tekuri/jsonschema/v6/kind"
17 )
18
19 const maxArgumentViolations = 8
20
21 // ArgumentValidator adds call-dependent checks that cannot be represented by
22 // one provider-visible JSON Schema. JSON Schema validation always runs first.
23 type ArgumentValidator interface {
24 ValidateArguments(json.RawMessage) []ArgumentViolation
25 }
26
27 // CapabilityArgumentContract is the effective inner contract exposed when a
28 // stable proxy injects target-specific fields such as a skill name.
29 type CapabilityArgumentContract struct {
30 Schema json.RawMessage `json:"input_schema"`
31 Example json.RawMessage `json:"call_example,omitempty"`
32 }
33
34 // CapabilityArgumentProvider lets an inspect action show the actual inner
35 // contract instead of the proxy's generic arguments object.
36 type CapabilityArgumentProvider interface {
37 CapabilityArguments(capabilityID string) (CapabilityArgumentContract, bool)
38 }
39
40 // ArgumentViolation is a value-free description of one invalid argument. It
41 // intentionally contains schema expectations, never the supplied value.
42 type ArgumentViolation struct {
43 Path string `json:"path"`
44 Keyword string `json:"keyword"`
45 Expected string `json:"expected"`
46 }
47
48 // ArgumentValidationResult is the host-side result for one concrete target.
49 // Skipped is only safe for third-party MCP schemas that cannot be compiled;
50 // built-in schema failures are returned in CompileErr.
51 type ArgumentValidationResult struct {
52 Fingerprint string
53 Violations []ArgumentViolation
54 Skipped bool
55 CompileErr error
56 }
57
58 type compiledArgumentSchema struct {
59 schema *jsonschema.Schema
60 err error
61 }
62
63 var argumentSchemaCache sync.Map // map[string]compiledArgumentSchema
64
65 // NormalizeArguments preserves the historical empty/null-to-object
66 // compatibility without guessing fields, coercing types, or rewriting values.
67 func NormalizeArguments(raw json.RawMessage) json.RawMessage {
68 trimmed := bytes.TrimSpace(raw)
69 if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
70 return json.RawMessage(`{}`)
71 }
72 return append(json.RawMessage(nil), trimmed...)
73 }
74
75 // ValidateArguments validates args against the concrete tool's real schema and
76 // then runs its optional conditional validator. Compiled validators are shared
77 // by schema fingerprint and never resolve filesystem or network references.
78 func ValidateArguments(target Tool, raw json.RawMessage) ArgumentValidationResult {
79 if target == nil {
80 return ArgumentValidationResult{CompileErr: fmt.Errorf("argument validation target is nil")}
81 }
82 result := ValidateJSONSchemaValue(target.Schema(), NormalizeArguments(raw))
83 if result.CompileErr != nil {
84 if _, thirdParty := target.(MCPMetadata); thirdParty {
85 result.Skipped = true
86 result.CompileErr = nil
87 return result
88 }
89 return result
90 }
91
92 if conditional, ok := target.(ArgumentValidator); ok && len(result.Violations) < maxArgumentViolations {
93 remaining := maxArgumentViolations - len(result.Violations)
94 extra := conditional.ValidateArguments(NormalizeArguments(raw))
95 if len(extra) > remaining {
96 extra = extra[:remaining]
97 }
98 result.Violations = append(result.Violations, extra...)
99 }
100 return result
101 }
102
103 // ValidateJSONSchemaValue validates an arbitrary JSON value against a schema.
104 // It is used for third-party MCP outputSchema telemetry as well as argument
105 // contracts; callers decide whether a compile failure is fatal or advisory.
106 func ValidateJSONSchemaValue(schemaRaw, raw json.RawMessage) ArgumentValidationResult {
107 schemaRaw = bytes.TrimSpace(schemaRaw)
108 fingerprint := schemaFingerprint(schemaRaw)
109 result := ArgumentValidationResult{Fingerprint: fingerprint}
110 compiled := loadCompiledArgumentSchema(fingerprint, schemaRaw)
111 if compiled.err != nil {
112 result.CompileErr = compiled.err
113 return result
114 }
115 var value any
116 decoder := json.NewDecoder(bytes.NewReader(raw))
117 decoder.UseNumber()
118 if err := decoder.Decode(&value); err != nil {
119 result.Violations = []ArgumentViolation{{Path: "", Keyword: "json", Expected: "one valid JSON object"}}
120 return result
121 }
122 if err := decoder.Decode(&struct{}{}); err != io.EOF {
123 result.Violations = []ArgumentViolation{{Path: "", Keyword: "json", Expected: "one valid JSON object"}}
124 return result
125 }
126 if err := compiled.schema.Validate(value); err != nil {
127 result.Violations = validationViolations(err)
128 }
129 return result
130 }
131
132 func schemaFingerprint(raw []byte) string {
133 sum := sha256.Sum256(raw)
134 return hex.EncodeToString(sum[:])
135 }
136
137 // SchemaFingerprint returns a deterministic digest for diagnostics and cache
138 // invalidation without returning the schema itself.
139 func SchemaFingerprint(raw json.RawMessage) string {
140 return schemaFingerprint(bytes.TrimSpace(raw))
141 }
142
143 // InvalidateArgumentSchemas drops compiled validators after a catalog change.
144 func InvalidateArgumentSchemas(fingerprints []string) {
145 for _, fingerprint := range fingerprints {
146 if fingerprint != "" {
147 argumentSchemaCache.Delete(fingerprint)
148 }
149 }
150 }
151
152 func loadCompiledArgumentSchema(fingerprint string, raw []byte) compiledArgumentSchema {
153 if cached, ok := argumentSchemaCache.Load(fingerprint); ok {
154 return cached.(compiledArgumentSchema)
155 }
156 compiled := compileArgumentSchema(fingerprint, raw)
157 actual, _ := argumentSchemaCache.LoadOrStore(fingerprint, compiled)
158 return actual.(compiledArgumentSchema)
159 }
160
161 func compileArgumentSchema(fingerprint string, raw []byte) compiledArgumentSchema {
162 var doc any
163 decoder := json.NewDecoder(bytes.NewReader(raw))
164 decoder.UseNumber()
165 if err := decoder.Decode(&doc); err != nil {
166 return compiledArgumentSchema{err: fmt.Errorf("invalid JSON schema: %w", err)}
167 }
168 if err := decoder.Decode(&struct{}{}); err != io.EOF {
169 return compiledArgumentSchema{err: fmt.Errorf("invalid JSON schema: multiple values")}
170 }
171 obj, ok := doc.(map[string]any)
172 if !ok {
173 return compiledArgumentSchema{err: fmt.Errorf("JSON schema root must be an object")}
174 }
175 _, explicitDialect := obj["$schema"]
176 compile := func(draft *jsonschema.Draft) (*jsonschema.Schema, error) {
177 compiler := jsonschema.NewCompiler()
178 compiler.UseLoader(nil)
179 compiler.DefaultDraft(draft)
180 resource := "urn:reasonix:argument-schema:" + fingerprint
181 if err := compiler.AddResource(resource, doc); err != nil {
182 return nil, err
183 }
184 return compiler.Compile(resource)
185 }
186 compiled, err := compile(jsonschema.Draft2020)
187 if err != nil && !explicitDialect {
188 compiled, err = compile(jsonschema.Draft7)
189 }
190 if err != nil {
191 return compiledArgumentSchema{err: fmt.Errorf("compile JSON schema: %w", err)}
192 }
193 return compiledArgumentSchema{schema: compiled}
194 }
195
196 func validationViolations(err error) []ArgumentViolation {
197 var validationErr *jsonschema.ValidationError
198 if !errors.As(err, &validationErr) {
199 return []ArgumentViolation{{Path: "", Keyword: "schema", Expected: "arguments satisfying the tool schema"}}
200 }
201 leaves := make([]*jsonschema.ValidationError, 0, maxArgumentViolations)
202 collectValidationLeaves(validationErr, &leaves)
203 violations := make([]ArgumentViolation, 0, len(leaves))
204 for _, leaf := range leaves {
205 keyword := "schema"
206 if path := leaf.ErrorKind.KeywordPath(); len(path) > 0 {
207 keyword = path[len(path)-1]
208 }
209 violations = append(violations, ArgumentViolation{
210 Path: jsonPointer(leaf.InstanceLocation),
211 Keyword: keyword,
212 Expected: expectedForErrorKind(leaf.ErrorKind),
213 })
214 }
215 if len(violations) == 0 {
216 violations = append(violations, ArgumentViolation{Path: "", Keyword: "schema", Expected: "arguments satisfying the tool schema"})
217 }
218 return violations
219 }
220
221 func collectValidationLeaves(err *jsonschema.ValidationError, out *[]*jsonschema.ValidationError) {
222 if err == nil || len(*out) >= maxArgumentViolations {
223 return
224 }
225 if len(err.Causes) == 0 {
226 *out = append(*out, err)
227 return
228 }
229 for _, cause := range err.Causes {
230 collectValidationLeaves(cause, out)
231 if len(*out) >= maxArgumentViolations {
232 return
233 }
234 }
235 }
236
237 func expectedForErrorKind(errorKind jsonschema.ErrorKind) string {
238 switch k := errorKind.(type) {
239 case *kind.Type:
240 return strings.Join(k.Want, " or ")
241 case *kind.Required:
242 return "required properties: " + strings.Join(k.Missing, ", ")
243 case *kind.AdditionalProperties:
244 return "only declared properties; remove: " + strings.Join(k.Properties, ", ")
245 case *kind.Enum:
246 return "one of: " + boundedSchemaValues(k.Want)
247 case *kind.Const:
248 return "constant: " + boundedSchemaValue(k.Want)
249 case *kind.MinProperties:
250 return "at least " + strconv.Itoa(k.Want) + " properties"
251 case *kind.MaxProperties:
252 return "at most " + strconv.Itoa(k.Want) + " properties"
253 case *kind.MinItems:
254 return "at least " + strconv.Itoa(k.Want) + " items"
255 case *kind.MaxItems:
256 return "at most " + strconv.Itoa(k.Want) + " items"
257 default:
258 return "value satisfying " + lastKeyword(errorKind.KeywordPath())
259 }
260 }
261
262 func boundedSchemaValues(values []any) string {
263 parts := make([]string, 0, len(values))
264 for _, value := range values {
265 parts = append(parts, boundedSchemaValue(value))
266 if len(strings.Join(parts, ", ")) >= 512 {
267 break
268 }
269 }
270 return truncateASCII(strings.Join(parts, ", "), 512)
271 }
272
273 func boundedSchemaValue(value any) string {
274 b, err := json.Marshal(value)
275 if err != nil {
276 return "declared schema value"
277 }
278 return truncateASCII(string(b), 256)
279 }
280
281 func lastKeyword(path []string) string {
282 if len(path) == 0 {
283 return "the schema"
284 }
285 return path[len(path)-1]
286 }
287
288 func jsonPointer(tokens []string) string {
289 var b strings.Builder
290 for _, token := range tokens {
291 b.WriteByte('/')
292 b.WriteString(strings.ReplaceAll(strings.ReplaceAll(token, "~", "~0"), "/", "~1"))
293 }
294 return b.String()
295 }
296
297 func truncateASCII(value string, limit int) string {
298 if len(value) <= limit {
299 return value
300 }
301 if limit <= 3 {
302 return value[:limit]
303 }
304 return value[:limit-3] + "..."
305 }
306
306 lines GO