mistri 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +469 -4
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +289 -385
  5. data/SECURITY.md +40 -0
  6. data/UPGRADING.md +640 -0
  7. data/assets/logo-animated.svg +30 -0
  8. data/assets/logo-dark.svg +14 -0
  9. data/assets/logo-light.svg +14 -0
  10. data/assets/logo.svg +14 -0
  11. data/assets/social-preview.png +0 -0
  12. data/docs/README.md +87 -0
  13. data/docs/context-and-workspaces.md +378 -0
  14. data/docs/mcp.md +366 -0
  15. data/docs/reliability.md +450 -0
  16. data/docs/sessions.md +295 -0
  17. data/docs/sub-agents.md +401 -0
  18. data/docs/tool-contracts.md +324 -0
  19. data/examples/approval.rb +36 -0
  20. data/examples/browser.rb +27 -0
  21. data/examples/page_editor.rb +31 -0
  22. data/examples/quickstart.rb +21 -0
  23. data/lib/generators/mistri/install/install_generator.rb +7 -3
  24. data/lib/generators/mistri/install/templates/migration.rb.tt +2 -2
  25. data/lib/generators/mistri/mcp/templates/migration.rb.tt +1 -1
  26. data/lib/generators/mistri/mcp/templates/model.rb.tt +15 -8
  27. data/lib/mistri/agent.rb +575 -55
  28. data/lib/mistri/budget.rb +26 -1
  29. data/lib/mistri/child.rb +72 -16
  30. data/lib/mistri/compaction.rb +26 -10
  31. data/lib/mistri/compactor.rb +34 -11
  32. data/lib/mistri/console.rb +28 -7
  33. data/lib/mistri/content.rb +9 -3
  34. data/lib/mistri/dispatchers.rb +14 -12
  35. data/lib/mistri/errors.rb +83 -4
  36. data/lib/mistri/event.rb +24 -8
  37. data/lib/mistri/event_delivery.rb +60 -0
  38. data/lib/mistri/locks.rb +3 -3
  39. data/lib/mistri/mcp/client.rb +74 -19
  40. data/lib/mistri/mcp/egress.rb +216 -0
  41. data/lib/mistri/mcp/oauth.rb +476 -127
  42. data/lib/mistri/mcp/wires.rb +115 -23
  43. data/lib/mistri/mcp.rb +42 -8
  44. data/lib/mistri/message.rb +21 -11
  45. data/lib/mistri/models.rb +160 -22
  46. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  47. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  48. data/lib/mistri/providers/anthropic.rb +29 -6
  49. data/lib/mistri/providers/fake.rb +26 -10
  50. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  51. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  52. data/lib/mistri/providers/gemini.rb +31 -5
  53. data/lib/mistri/providers/openai/assembler.rb +337 -60
  54. data/lib/mistri/providers/openai/serializer.rb +13 -12
  55. data/lib/mistri/providers/openai.rb +29 -5
  56. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  57. data/lib/mistri/result.rb +1 -1
  58. data/lib/mistri/schema.rb +893 -75
  59. data/lib/mistri/session.rb +560 -48
  60. data/lib/mistri/sinks/coalesced.rb +17 -10
  61. data/lib/mistri/spawner.rb +111 -61
  62. data/lib/mistri/sse.rb +57 -14
  63. data/lib/mistri/stores/active_record.rb +1 -1
  64. data/lib/mistri/stores/memory.rb +21 -2
  65. data/lib/mistri/sub_agent/execution.rb +81 -0
  66. data/lib/mistri/sub_agent/runtime.rb +297 -0
  67. data/lib/mistri/sub_agent.rb +124 -87
  68. data/lib/mistri/task_output.rb +24 -6
  69. data/lib/mistri/tool.rb +93 -13
  70. data/lib/mistri/tool_arguments.rb +377 -0
  71. data/lib/mistri/tool_call.rb +43 -9
  72. data/lib/mistri/tool_context.rb +4 -2
  73. data/lib/mistri/tool_executor.rb +117 -26
  74. data/lib/mistri/tool_result.rb +15 -10
  75. data/lib/mistri/tools/edit_file.rb +62 -8
  76. data/lib/mistri/tools.rb +41 -4
  77. data/lib/mistri/transport.rb +149 -44
  78. data/lib/mistri/usage.rb +65 -13
  79. data/lib/mistri/version.rb +1 -1
  80. data/lib/mistri/workspace/active_record.rb +183 -3
  81. data/lib/mistri/workspace/directory.rb +28 -8
  82. data/lib/mistri/workspace/memory.rb +34 -9
  83. data/lib/mistri/workspace/single.rb +62 -5
  84. data/lib/mistri/workspace.rb +39 -0
  85. data/lib/mistri.rb +6 -1
  86. data/mistri.gemspec +34 -0
  87. metadata +31 -3
data/lib/mistri/schema.rb CHANGED
@@ -1,24 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "uri"
5
+
6
+ require_relative "tool_arguments"
7
+
3
8
  module Mistri
4
- # A small builder for tool argument schemas, so a tool declares its inputs
5
- # in Ruby instead of hand-writing JSON Schema. It emits the object schema
6
- # every provider accepts:
7
- #
8
- # Schema.build do
9
- # string :city, "City name", required: true
10
- # string :units, "Temperature units", enum: %w[celsius fahrenheit]
11
- # integer :days, "Forecast length"
12
- # end
13
- #
14
- # A raw JSON Schema hash is always accepted directly for anything the
15
- # builder does not cover, so the DSL is a convenience, never a ceiling.
9
+ # Builds tool schemas and validates the portable JSON Schema subset Mistri owns.
16
10
  class Schema
17
- # instance_exec, not instance_eval: it binds self to the builder without
18
- # passing an argument, so a zero-arity lambda works as naturally as a proc.
19
- def self.build(&)
11
+ DIALECT = "https://json-schema.org/draft/2020-12/schema"
12
+ MAX_VIOLATIONS = 8
13
+ MAX_DEPTH = 64
14
+ MAX_NODES = 10_000
15
+ MAX_BYTES = 8 * 1024 * 1024
16
+ JSON_TYPES = %w[object array string integer number boolean null].freeze
17
+
18
+ # instance_exec binds self to the builder without passing an argument, so
19
+ # zero-arity lambdas and procs behave the same way.
20
+ def self.build(&block)
21
+ raise ConfigurationError, "schema needs a block" unless block
22
+
20
23
  builder = new
21
- builder.instance_exec(&)
24
+ builder.instance_exec(&block)
22
25
  builder.to_h
23
26
  end
24
27
 
@@ -46,8 +49,8 @@ module Mistri
46
49
  nil
47
50
  end
48
51
 
49
- def object(name, description = nil, required: false, &)
50
- prop = self.class.build(&)
52
+ def object(name, description = nil, required: false, &block)
53
+ prop = block ? self.class.build(&block) : self.class.new.to_h
51
54
  prop[:description] = description if description
52
55
  @properties[name.to_s] = prop
53
56
  @required << name.to_s if required
@@ -61,102 +64,917 @@ module Mistri
61
64
  end
62
65
 
63
66
  class << self
64
- # Violations of a value against the schema subset the harness emits and
65
- # providers constrain: types (including type arrays), object properties
66
- # with required and additionalProperties: false, array items, enum.
67
- # Empty means valid; entries are human-readable, written to be fed back
68
- # to a model for one-shot correction.
67
+ # Definition checks cover the complete JSON document. Unsupported
68
+ # assertion keywords remain provider guidance in this general API.
69
+ def validate_definition!(schema)
70
+ Compiled.new(schema)
71
+ schema
72
+ end
73
+
74
+ # Returns bounded, model-readable failures for the subset Mistri owns:
75
+ # type, enum, required, properties, additionalProperties, items, and
76
+ # prefixItems. The input is first normalized to the JSON value providers see.
69
77
  def violations(value, schema, path = "$")
70
- spec = schema.transform_keys(&:to_s)
71
- mismatch = type_violation(value, spec, path)
72
- return [mismatch] if mismatch
78
+ Compiled.new(schema).violations(value, path)
79
+ end
73
80
 
74
- errors = []
75
- if (enum = spec["enum"]) && !enum.include?(value)
76
- errors << "#{path} must be one of: #{enum.join(", ")}"
77
- end
78
- case value
79
- when Hash then errors.concat(object_violations(value, spec, path))
80
- when Array then errors.concat(array_violations(value, spec, path))
81
- end
82
- errors
81
+ # A Tool keeps this plan for its lifetime. complete is explicit authority
82
+ # for a host validator to own schema interactions core cannot represent.
83
+ def tool_validator(schema, complete: false)
84
+ Compiled.new(schema, tool: true, complete: complete)
85
+ end
86
+
87
+ # MCP schemas get the same stance as host-authored tools: directly
88
+ # reachable portable constraints are enforced locally, while unsupported
89
+ # applicator subtrees stay server guidance. Complete validators own the
90
+ # whole local contract; external references are rejected in either mode.
91
+ def validate_mcp!(schema, complete: false)
92
+ compiled = Compiled.new(schema, tool: true, complete: complete)
93
+ AssertionContract.new(complete:, context: "MCP input schema",
94
+ allow_guidance: true).call(compiled.schema)
95
+ compiled.schema
96
+ end
97
+
98
+ # Paths to standard assertions that the zero-dependency validator does
99
+ # not implement. An empty list means local validation owns the contract.
100
+ def unsupported_assertions(schema)
101
+ compiled = Compiled.new(schema)
102
+ AssertionScanner.new.call(compiled.schema).map(&:first).freeze
83
103
  end
84
104
 
85
- # Prepares a schema for constrained decoding: every object gains
86
- # additionalProperties: false (Anthropic and OpenAI both demand it),
87
- # and all_required marks every property required (OpenAI strict mode's
88
- # rule). String keys throughout, ready for the wire.
105
+ # Task mode validates only schemas whose complete assertion contract is
106
+ # implemented locally; provider constrained decoding is an optimization.
107
+ def task_violations(value, schema, path = "$")
108
+ task_plan(schema).violations(value, path)
109
+ end
110
+
111
+ # One task plan owns the strict prompt and validation schema. Providers
112
+ # may derive a compatible native constraint, but local validation remains
113
+ # the guarantee when their structured-output subset is narrower.
114
+ def task_plan(schema, all_required: false)
115
+ source = compile_task_schema(schema)
116
+ strict = strict_at(source.schema, all_required:, path: "$")
117
+ TaskPlan.new(Compiled.new(strict))
118
+ end
119
+
120
+ # Prepares a schema for constrained decoding without turning a freeform
121
+ # object into one that accepts only {}. Definition compilation happens
122
+ # first so recursive transformation never sees a cyclic or hostile graph.
89
123
  def strict(schema, all_required: false)
124
+ source = Compiled.new(schema)
125
+ strict_at(source.schema, all_required:, path: "$")
126
+ end
127
+
128
+ private
129
+
130
+ def compile_task_schema(schema)
131
+ compiled = Compiled.new(schema)
132
+ AssertionContract.new(complete: false, context: "task output schema").call(
133
+ compiled.schema
134
+ )
135
+ compiled
136
+ end
137
+
138
+ def strict_at(schema, all_required:, path:)
139
+ return schema if schema.equal?(true) || schema.equal?(false)
140
+
90
141
  spec = schema.transform_keys(&:to_s)
91
142
  out = spec.dup
92
143
  if spec["type"] == "object" || spec.key?("properties")
93
- props = (spec["properties"] || {}).to_h do |key, member|
94
- [key.to_s, strict(member, all_required: all_required)]
95
- end
144
+ raw_props = spec["properties"] || {}
145
+ reject_open_object!(spec, raw_props, path)
146
+ props = raw_props.to_h do |key, member|
147
+ member_path = "#{path}.#{key}"
148
+ [key.to_s, strict_at(member, all_required: all_required, path: member_path)]
149
+ end.freeze
96
150
  out["properties"] = props
97
151
  out["additionalProperties"] = false
98
- out["required"] = all_required ? props.keys : Array(spec["required"]).map(&:to_s)
152
+ required = all_required ? props.keys : Array(spec["required"]).map(&:to_s)
153
+ out["required"] = required.freeze
99
154
  end
100
- if spec["items"].is_a?(Hash)
101
- out["items"] =
102
- strict(spec["items"], all_required: all_required)
155
+ if schema_value?(spec["items"])
156
+ out["items"] = strict_at(
157
+ spec["items"], all_required: all_required, path: "#{path}[]"
158
+ )
103
159
  end
104
- out
160
+ strict_prefix_items(spec, out, all_required, path)
161
+ out.freeze
162
+ end
163
+
164
+ def strict_prefix_items(spec, out, all_required, path)
165
+ return unless spec["prefixItems"].is_a?(Array)
166
+
167
+ out["prefixItems"] = spec["prefixItems"].each_with_index.map do |member, index|
168
+ strict_at(member, all_required: all_required, path: "#{path}[#{index}]")
169
+ end.freeze
170
+ end
171
+
172
+ def schema_value?(value)
173
+ value.is_a?(Hash) || value.equal?(true) || value.equal?(false)
174
+ end
175
+
176
+ def reject_open_object!(spec, properties, path)
177
+ if spec.key?("additionalProperties") && spec["additionalProperties"] != false
178
+ raise ConfigurationError,
179
+ "#{path} is open and cannot be represented by a strict schema"
180
+ end
181
+ return unless properties.empty? && spec["additionalProperties"] != false
182
+
183
+ raise ConfigurationError,
184
+ "#{path} is freeform and cannot be represented by a strict schema"
185
+ end
186
+ end
187
+
188
+ # Shared JSON semantics keep definition and instance handling identical.
189
+ module ValidationSupport
190
+ IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
191
+ MAX_KEY_BYTES = 64
192
+ MAX_PATH_BYTES = 240
193
+ MAX_SEGMENT_BYTES = 96
194
+
195
+ module_function
196
+
197
+ def root_path(path)
198
+ bounded_utf8(path.to_s, MAX_PATH_BYTES).gsub(/[[:cntrl:]]/, "?")
199
+ end
200
+
201
+ def child_path(path, key)
202
+ key_text = bounded_utf8(key, MAX_KEY_BYTES)
203
+ segment = key_text.match?(IDENTIFIER) ? ".#{key_text}" : "[#{JSON.generate(key_text)}]"
204
+ segment = "[\"...\"]" if segment.bytesize > MAX_SEGMENT_BYTES
205
+ joined = "#{path}#{segment}"
206
+ joined.bytesize <= MAX_PATH_BYTES ? joined : "$...#{segment}"
207
+ end
208
+
209
+ def index_path(path, index)
210
+ segment = "[#{index}]"
211
+ joined = "#{path}#{segment}"
212
+ joined.bytesize <= MAX_PATH_BYTES ? joined : "$...#{segment}"
213
+ end
214
+
215
+ def item_path(path) = "#{path}[]"
216
+
217
+ def json_number?(value)
218
+ value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
219
+ end
220
+
221
+ def json_integer?(value)
222
+ value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value.modulo(1).zero?)
223
+ end
224
+
225
+ def json_type(value)
226
+ case value
227
+ when Hash then "object"
228
+ when Array then "array"
229
+ when String then "string"
230
+ when Integer then "integer"
231
+ when Float then "number"
232
+ when true, false then "boolean"
233
+ when nil then "null"
234
+ else "non-JSON value"
235
+ end
236
+ end
237
+
238
+ def bounded_utf8(value, max_bytes)
239
+ source = value.to_s
240
+ truncated = source.bytesize > max_bytes
241
+ source = source.byteslice(0, max_bytes) if truncated
242
+ text = source.encode(Encoding::UTF_8, invalid: :replace,
243
+ undef: :replace, replace: "?")
244
+ return text if !truncated && text.bytesize <= max_bytes
245
+
246
+ prefix = text.byteslice(0, max_bytes - 3).dup.force_encoding(Encoding::UTF_8).scrub
247
+ "#{prefix}..."
248
+ end
249
+ private_class_method :bounded_utf8
250
+ end
251
+ private_constant :ValidationSupport
252
+
253
+ # Canonicalization creates the exact immutable JSON document provider
254
+ # serializers receive, preventing encoding and post-definition mutation drift.
255
+ class DefinitionCompiler
256
+ def initialize
257
+ @nodes = 0
258
+ @bytes = 0
259
+ @active = {}.compare_by_identity
260
+ end
261
+
262
+ def call(schema)
263
+ owned = copy(schema, "$", 0)
264
+ unless ToolArguments.serialized_size_within_limit?(owned, limit: MAX_BYTES)
265
+ configuration!("$", "exceeds the schema byte limit")
266
+ end
267
+ DefinitionValidator.new.call(owned)
268
+ owned
269
+ end
270
+
271
+ private
272
+
273
+ def copy(value, path, depth)
274
+ count!(path, depth)
275
+ case value
276
+ when Hash then copy_hash(value, path, depth)
277
+ when Array then copy_array(value, path, depth)
278
+ when String then copy_string(value, path)
279
+ when Symbol then copy_string(value.to_s, path)
280
+ when Integer, true, false, nil then value
281
+ when Float
282
+ configuration!(path, "must contain only finite JSON numbers") unless value.finite?
283
+ value
284
+ else configuration!(path, "must contain only JSON values")
285
+ end
286
+ end
287
+
288
+ def copy_hash(value, path, depth)
289
+ within(value, path) do
290
+ value.each_with_object({}) do |(key, member), out|
291
+ unless key.is_a?(String) || key.is_a?(Symbol)
292
+ configuration!(path, "must use string or symbol keys")
293
+ end
294
+ name = copy_string(key.to_s, path)
295
+ configuration!(path, "contains duplicate JSON keys") if out.key?(name)
296
+ out[name] = copy(member, ValidationSupport.child_path(path, name), depth + 1)
297
+ end.freeze
298
+ end
299
+ end
300
+
301
+ def copy_array(value, path, depth)
302
+ within(value, path) do
303
+ value.each_with_index.map do |member, index|
304
+ copy(member, ValidationSupport.index_path(path, index), depth + 1)
305
+ end.freeze
306
+ end
307
+ end
308
+
309
+ def copy_string(value, path)
310
+ if value.encoding == Encoding::UTF_8
311
+ configuration!(path, "must use valid UTF-8 strings") unless value.valid_encoding?
312
+ string = value.dup
313
+ else
314
+ string = value.encode(Encoding::UTF_8)
315
+ end
316
+ @bytes += string.bytesize
317
+ configuration!(path, "exceeds the schema byte limit") if @bytes > MAX_BYTES
318
+ string.freeze
319
+ rescue EncodingError
320
+ configuration!(path, "must use valid UTF-8 strings")
321
+ end
322
+
323
+ def within(value, path)
324
+ configuration!(path, "contains a cycle") if @active[value]
325
+
326
+ @active[value] = true
327
+ inserted = true
328
+ yield
329
+ ensure
330
+ @active.delete(value) if inserted
331
+ end
332
+
333
+ def count!(path, depth)
334
+ configuration!(path, "exceeds the schema depth limit") if depth > MAX_DEPTH
335
+ @nodes += 1
336
+ configuration!(path, "exceeds the schema size limit") if @nodes > MAX_NODES
337
+ end
338
+
339
+ def configuration!(path, problem)
340
+ raise ConfigurationError, "#{path} #{problem}"
341
+ end
342
+ end
343
+ private_constant :DefinitionCompiler
344
+
345
+ # Keyword shape checks are distinct from the full-document JSON scan so
346
+ # ignored extensions cannot evade ownership and resource limits.
347
+ class DefinitionValidator
348
+ STRING_KEYWORDS = %w[
349
+ title description pattern format contentEncoding contentMediaType $comment
350
+ ].freeze
351
+ BOOLEAN_KEYWORDS = %w[uniqueItems deprecated readOnly writeOnly].freeze
352
+ NUMBER_KEYWORDS = %w[maximum exclusiveMaximum minimum exclusiveMinimum].freeze
353
+ NONNEGATIVE_INTEGER_KEYWORDS = %w[
354
+ maxLength minLength maxItems minItems maxContains minContains
355
+ maxProperties minProperties
356
+ ].freeze
357
+ URI_KEYWORDS = %w[$id $ref $dynamicRef].freeze
358
+ ANCHOR = /\A[A-Za-z_][-A-Za-z0-9._]*\z/
359
+ SCHEMA_MAPS = %w[$defs definitions dependentSchemas].freeze
360
+ SCHEMA_ARRAYS = %w[allOf anyOf oneOf].freeze
361
+ SCHEMA_VALUES = %w[
362
+ not if then else contains propertyNames unevaluatedProperties
363
+ unevaluatedItems contentSchema
364
+ ].freeze
365
+
366
+ def call(schema)
367
+ walk_schema(schema, "$")
368
+ schema
369
+ end
370
+
371
+ private
372
+
373
+ def walk_schema(schema, path)
374
+ return if schema.equal?(true) || schema.equal?(false)
375
+
376
+ configuration!(path, "must be a schema object or boolean") unless schema.is_a?(Hash)
377
+ validate_dialect(schema, path)
378
+ validate_core_keywords(schema, path)
379
+ validate_annotation_keywords(schema, path)
380
+ validate_assertion_shapes(schema, path)
381
+ validate_type(schema, path)
382
+ validate_enum(schema, path)
383
+ validate_required(schema, path)
384
+ validate_schema_map(schema, "properties", path)
385
+ validate_schema_map(schema, "patternProperties", path)
386
+ validate_items(schema, path)
387
+ validate_prefix_items(schema, path)
388
+ validate_additional_properties(schema, path)
389
+ validate_dependent_required(schema, path)
390
+ validate_dependencies(schema, path)
391
+ SCHEMA_MAPS.each { |keyword| validate_schema_map(schema, keyword, path) }
392
+ SCHEMA_ARRAYS.each { |keyword| validate_schema_array(schema, keyword, path) }
393
+ SCHEMA_VALUES.each { |keyword| validate_schema_value(schema, keyword, path) }
394
+ end
395
+
396
+ def validate_dialect(spec, path)
397
+ return unless spec.key?("$schema")
398
+ return if spec["$schema"] == DIALECT
399
+
400
+ configuration!("#{path}.$schema", "must declare JSON Schema 2020-12")
401
+ end
402
+
403
+ def validate_core_keywords(spec, path)
404
+ validate_uri_keywords(spec, path)
405
+ validate_anchor_keywords(spec, path)
406
+ validate_vocabulary(spec, path)
407
+ end
408
+
409
+ def validate_uri_keywords(spec, path)
410
+ URI_KEYWORDS.each do |keyword|
411
+ next unless spec.key?(keyword)
412
+
413
+ uri = validate_uri_reference(spec[keyword], "#{path}.#{keyword}")
414
+ next unless keyword == "$id" && uri.fragment && !uri.fragment.empty?
415
+
416
+ configuration!("#{path}.$id", "must not contain a non-empty fragment")
417
+ end
418
+ end
419
+
420
+ def validate_anchor_keywords(spec, path)
421
+ %w[$anchor $dynamicAnchor].each do |keyword|
422
+ next unless spec.key?(keyword)
423
+ next if spec[keyword].is_a?(String) && spec[keyword].match?(ANCHOR)
424
+
425
+ configuration!("#{path}.#{keyword}", "must be a valid plain-name anchor")
426
+ end
427
+ end
428
+
429
+ def validate_vocabulary(spec, path)
430
+ return unless spec.key?("$vocabulary")
431
+
432
+ vocabulary = spec["$vocabulary"]
433
+ unless vocabulary.is_a?(Hash) && vocabulary.values.all? do |required|
434
+ required.equal?(true) || required.equal?(false)
435
+ end
436
+ configuration!("#{path}.$vocabulary", "must map URI strings to booleans")
437
+ end
438
+ vocabulary.each_key do |uri|
439
+ validate_absolute_normalized_uri(uri, "#{path}.$vocabulary")
440
+ end
441
+ end
442
+
443
+ def validate_annotation_keywords(spec, path)
444
+ STRING_KEYWORDS.each do |keyword|
445
+ next unless spec.key?(keyword)
446
+ next if spec[keyword].is_a?(String)
447
+
448
+ configuration!("#{path}.#{keyword}", "must be a string")
449
+ end
450
+ BOOLEAN_KEYWORDS.each do |keyword|
451
+ next unless spec.key?(keyword)
452
+ next if spec[keyword].equal?(true) || spec[keyword].equal?(false)
453
+
454
+ configuration!("#{path}.#{keyword}", "must be a boolean")
455
+ end
456
+ return unless spec.key?("examples")
457
+ return if spec["examples"].is_a?(Array)
458
+
459
+ configuration!("#{path}.examples", "must be an array")
460
+ end
461
+
462
+ def validate_assertion_shapes(spec, path)
463
+ NUMBER_KEYWORDS.each do |keyword|
464
+ next unless spec.key?(keyword)
465
+ next if ValidationSupport.json_number?(spec[keyword])
466
+
467
+ configuration!("#{path}.#{keyword}", "must be a number")
468
+ end
469
+ NONNEGATIVE_INTEGER_KEYWORDS.each do |keyword|
470
+ next unless spec.key?(keyword)
471
+
472
+ value = spec[keyword]
473
+ next if ValidationSupport.json_integer?(value) && value >= 0
474
+
475
+ configuration!("#{path}.#{keyword}", "must be a non-negative integer")
476
+ end
477
+ return unless spec.key?("multipleOf")
478
+ return if ValidationSupport.json_number?(spec["multipleOf"]) && spec["multipleOf"].positive?
479
+
480
+ configuration!("#{path}.multipleOf", "must be a number greater than zero")
481
+ end
482
+
483
+ def validate_uri_reference(value, path)
484
+ configuration!(path, "must be a URI-reference string") unless value.is_a?(String)
485
+
486
+ URI.parse(value)
487
+ rescue URI::InvalidURIError
488
+ configuration!(path, "must be a valid URI-reference")
489
+ end
490
+
491
+ def validate_absolute_normalized_uri(value, path)
492
+ configuration!(path, "must use absolute normalized URI keys") unless value.is_a?(String)
493
+
494
+ uri = URI.parse(value)
495
+ return uri if uri.absolute? && uri.normalize.to_s == value
496
+
497
+ configuration!(path, "must use absolute normalized URI keys")
498
+ rescue URI::InvalidURIError
499
+ configuration!(path, "must use absolute normalized URI keys")
500
+ end
501
+
502
+ def validate_type(spec, path)
503
+ return unless spec.key?("type")
504
+
505
+ raw = spec["type"]
506
+ names = raw.is_a?(Array) ? raw : [raw]
507
+ valid = !names.empty? && names.all?(String)
508
+ configuration!("#{path}.type", "must be a primitive or non-empty union") unless valid
509
+ unknown = names - JSON_TYPES
510
+ configuration!("#{path}.type", "contains an unknown JSON type") unless unknown.empty?
511
+ configuration!("#{path}.type", "must not repeat a JSON type") if names.uniq != names
512
+ end
513
+
514
+ def validate_enum(spec, path)
515
+ return unless spec.key?("enum")
516
+
517
+ values = spec["enum"]
518
+ configuration!("#{path}.enum", "must be an array") unless values.is_a?(Array)
519
+ end
520
+
521
+ def validate_required(spec, path)
522
+ return unless spec.key?("required")
523
+
524
+ keys = spec["required"]
525
+ unless keys.is_a?(Array) && keys.all?(String)
526
+ configuration!("#{path}.required", "must be an array of unique strings")
527
+ end
528
+ return if keys.uniq == keys
529
+
530
+ configuration!("#{path}.required", "must be an array of unique strings")
105
531
  end
106
532
 
533
+ def validate_dependent_required(spec, path)
534
+ return unless spec.key?("dependentRequired")
535
+
536
+ dependencies = spec["dependentRequired"]
537
+ unless dependencies.is_a?(Hash)
538
+ configuration!("#{path}.dependentRequired", "must be an object")
539
+ end
540
+ dependencies.each do |key, members|
541
+ next if members.is_a?(Array) && members.all?(String) && members.uniq == members
542
+
543
+ member_path = ValidationSupport.child_path("#{path}.dependentRequired", key)
544
+ configuration!(member_path, "must be an array of unique strings")
545
+ end
546
+ end
547
+
548
+ def validate_dependencies(spec, path)
549
+ return unless spec.key?("dependencies")
550
+
551
+ dependencies = spec["dependencies"]
552
+ configuration!("#{path}.dependencies", "must be an object") unless dependencies.is_a?(Hash)
553
+ dependencies.each do |key, member|
554
+ member_path = ValidationSupport.child_path("#{path}.dependencies", key)
555
+ if member.is_a?(Array)
556
+ valid = member.all?(String) && member.uniq == member
557
+ configuration!(member_path, "must be a schema or array of unique strings") unless valid
558
+ else
559
+ walk_schema(member, member_path)
560
+ end
561
+ end
562
+ end
563
+
564
+ def validate_schema_map(spec, keyword, path)
565
+ return unless spec.key?(keyword)
566
+
567
+ members = spec[keyword]
568
+ configuration!("#{path}.#{keyword}", "must be an object") unless members.is_a?(Hash)
569
+ members.each do |key, member|
570
+ walk_schema(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
571
+ end
572
+ end
573
+
574
+ def validate_schema_array(spec, keyword, path)
575
+ return unless spec.key?(keyword)
576
+
577
+ members = spec[keyword]
578
+ unless members.is_a?(Array) && !members.empty?
579
+ configuration!("#{path}.#{keyword}", "must be a non-empty array of schemas")
580
+ end
581
+ members.each_with_index do |member, index|
582
+ walk_schema(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
583
+ end
584
+ end
585
+
586
+ def validate_schema_value(spec, keyword, path)
587
+ return unless spec.key?(keyword)
588
+
589
+ walk_schema(spec[keyword], "#{path}.#{keyword}")
590
+ end
591
+
592
+ def validate_items(spec, path)
593
+ return unless spec.key?("items")
594
+
595
+ items = spec["items"]
596
+ if items.is_a?(Array)
597
+ configuration!("#{path}.items",
598
+ "must be a schema in JSON Schema 2020-12; use prefixItems for tuples")
599
+ end
600
+ walk_schema(items, ValidationSupport.item_path(path))
601
+ end
602
+
603
+ def validate_prefix_items(spec, path)
604
+ return unless spec.key?("prefixItems")
605
+
606
+ members = spec["prefixItems"]
607
+ unless members.is_a?(Array) && !members.empty?
608
+ configuration!("#{path}.prefixItems", "must be a non-empty array of schemas")
609
+ end
610
+ members.each_with_index do |member, index|
611
+ walk_schema(member, ValidationSupport.index_path("#{path}.prefixItems", index))
612
+ end
613
+ end
614
+
615
+ def validate_additional_properties(spec, path)
616
+ return unless spec.key?("additionalProperties")
617
+
618
+ walk_schema(spec["additionalProperties"], "#{path}.additionalProperties")
619
+ end
620
+
621
+ def configuration!(path, problem)
622
+ raise ConfigurationError, "#{path} #{problem}"
623
+ end
624
+ end
625
+ private_constant :DefinitionValidator
626
+
627
+ # Finds standard assertions that the portable validator intentionally
628
+ # leaves to a complete JSON Schema implementation.
629
+ class AssertionScanner
630
+ ASSERTIONS = %w[
631
+ $ref $dynamicRef allOf anyOf oneOf not if then else
632
+ patternProperties dependentSchemas dependentRequired dependencies
633
+ propertyNames unevaluatedProperties unevaluatedItems contains
634
+ multipleOf maximum exclusiveMaximum minimum exclusiveMinimum
635
+ maxLength minLength pattern maxItems minItems uniqueItems
636
+ maxContains minContains maxProperties minProperties const
637
+ ].freeze
638
+ SCHEMA_MAPS = %w[properties patternProperties $defs definitions dependentSchemas].freeze
639
+ SCHEMA_ARRAYS = %w[allOf anyOf oneOf prefixItems].freeze
640
+ SCHEMA_VALUES = %w[
641
+ not if then else contains propertyNames unevaluatedProperties
642
+ unevaluatedItems additionalProperties items
643
+ ].freeze
644
+ REFERENCE_SCHEMA_VALUES = [*SCHEMA_VALUES, "contentSchema"].freeze
645
+
646
+ def call(schema, references_only: false)
647
+ @findings = []
648
+ @references_only = references_only
649
+ walk(schema, "$")
650
+ @findings.freeze
651
+ end
652
+
653
+ private
654
+
655
+ def walk(schema, path)
656
+ return if schema.equal?(true) || schema.equal?(false)
657
+
658
+ record_assertions(schema, path)
659
+ walk_maps(schema, path)
660
+ walk_arrays(schema, path)
661
+ walk_values(schema, path)
662
+ walk_dependencies(schema, path)
663
+ end
664
+
665
+ def record_assertions(schema, path)
666
+ keywords = @references_only ? %w[$ref $dynamicRef] : ASSERTIONS
667
+ keywords.each do |keyword|
668
+ next unless schema.key?(keyword)
669
+ next if ignorable_empty_map?(schema, keyword)
670
+
671
+ @findings << ["#{path}.#{keyword}".freeze, keyword, schema[keyword]].freeze
672
+ end
673
+ end
674
+
675
+ def ignorable_empty_map?(schema, keyword)
676
+ return false unless %w[
677
+ patternProperties dependentSchemas dependentRequired dependencies
678
+ ].include?(keyword)
679
+
680
+ schema[keyword].is_a?(Hash) && schema[keyword].empty?
681
+ end
682
+
683
+ def walk_maps(schema, path)
684
+ SCHEMA_MAPS.each do |keyword|
685
+ schema.fetch(keyword, {}).each do |key, member|
686
+ walk(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
687
+ end
688
+ end
689
+ end
690
+
691
+ def walk_arrays(schema, path)
692
+ SCHEMA_ARRAYS.each do |keyword|
693
+ Array(schema[keyword]).each_with_index do |member, index|
694
+ walk(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
695
+ end
696
+ end
697
+ end
698
+
699
+ def walk_values(schema, path)
700
+ values = @references_only ? REFERENCE_SCHEMA_VALUES : SCHEMA_VALUES
701
+ values.each do |keyword|
702
+ member = schema[keyword]
703
+ walk(member, "#{path}.#{keyword}") if member.is_a?(Hash)
704
+ end
705
+ end
706
+
707
+ def walk_dependencies(schema, path)
708
+ schema.fetch("dependencies", {}).each do |key, member|
709
+ next if member.is_a?(Array)
710
+
711
+ walk(member, ValidationSupport.child_path("#{path}.dependencies", key))
712
+ end
713
+ end
714
+ end
715
+ private_constant :AssertionScanner
716
+
717
+ # Unsupported assertions are safe as MCP server guidance, but not as a
718
+ # claimed local validation boundary. External references are rejected in
719
+ # every mode so validation never implies hidden network or file resolution.
720
+ class AssertionContract
721
+ def initialize(complete:, context:, allow_guidance: false)
722
+ @complete = complete
723
+ @context = context
724
+ @allow_guidance = allow_guidance
725
+ end
726
+
727
+ def call(schema)
728
+ findings = AssertionScanner.new.call(schema)
729
+ references = AssertionScanner.new.call(schema, references_only: true)
730
+ reject_external_references(references)
731
+ return if @complete || @allow_guidance || findings.empty?
732
+
733
+ paths = findings.first(3).map(&:first).join(", ")
734
+ suffix = findings.length > 3 ? ", and #{findings.length - 3} more" : ""
735
+ raise ConfigurationError,
736
+ "#{@context} uses assertions Mistri cannot validate at #{paths}#{suffix}"
737
+ end
738
+
739
+ private
740
+
741
+ def reject_external_references(findings)
742
+ finding = findings.find do |(_, keyword, value)|
743
+ %w[$ref $dynamicRef].include?(keyword) && !value.empty? && !value.start_with?("#")
744
+ end
745
+ return unless finding
746
+
747
+ raise ConfigurationError,
748
+ "#{finding.first} must be empty or a same-document reference beginning with #"
749
+ end
750
+ end
751
+ private_constant :AssertionContract
752
+
753
+ # Core never approximates regex-key semantics. A full validator is explicit
754
+ # authority whenever patternProperties can match an argument key.
755
+ class ToolContract
756
+ def initialize(complete:)
757
+ @complete = complete
758
+ end
759
+
760
+ def call(schema)
761
+ unless schema.is_a?(Hash) && schema["type"] == "object"
762
+ configuration!("$", "must declare type object for tool arguments")
763
+ end
764
+
765
+ walk(schema, "$")
766
+ end
767
+
768
+ private
769
+
770
+ def walk(schema, path)
771
+ return if schema.equal?(true) || schema.equal?(false)
772
+
773
+ require_complete_for_patterns(schema, path)
774
+ each_subschema(schema, path) { |member, member_path| walk(member, member_path) }
775
+ end
776
+
777
+ def require_complete_for_patterns(spec, path)
778
+ patterns = spec["patternProperties"]
779
+ return unless patterns.is_a?(Hash) && !patterns.empty?
780
+ return if @complete
781
+
782
+ configuration!("#{path}.patternProperties",
783
+ "non-empty pattern properties require a complete argument validator")
784
+ end
785
+
786
+ def each_subschema(spec, path, &block)
787
+ %w[properties patternProperties $defs definitions dependentSchemas].each do |keyword|
788
+ spec.fetch(keyword, {}).each do |key, member|
789
+ block.call(member, ValidationSupport.child_path("#{path}.#{keyword}", key))
790
+ end
791
+ end
792
+ %w[allOf anyOf oneOf prefixItems].each do |keyword|
793
+ Array(spec[keyword]).each_with_index do |member, index|
794
+ block.call(member, ValidationSupport.index_path("#{path}.#{keyword}", index))
795
+ end
796
+ end
797
+ %w[
798
+ not if then else contains propertyNames unevaluatedProperties
799
+ unevaluatedItems additionalProperties
800
+ ].each do |keyword|
801
+ member = spec[keyword]
802
+ block.call(member, "#{path}.#{keyword}") if member.is_a?(Hash)
803
+ end
804
+ items = spec["items"]
805
+ block.call(items, ValidationSupport.item_path(path)) if items.is_a?(Hash)
806
+ spec.fetch("dependencies", {}).each do |key, member|
807
+ next if member.is_a?(Array)
808
+
809
+ block.call(member, ValidationSupport.child_path("#{path}.dependencies", key))
810
+ end
811
+ end
812
+
813
+ def configuration!(path, problem)
814
+ raise ConfigurationError, "#{path} #{problem}"
815
+ end
816
+ end
817
+ private_constant :ToolContract
818
+
819
+ # One immutable validation plan is reused for every call to a Tool.
820
+ class Compiled
821
+ attr_reader :schema
822
+
823
+ def initialize(schema, tool: false, complete: false)
824
+ @schema = DefinitionCompiler.new.call(schema)
825
+ ToolContract.new(complete: complete).call(@schema) if tool
826
+ freeze
827
+ end
828
+
829
+ def violations(value, path = "$", owned: false)
830
+ unless owned
831
+ value, error = ToolArguments.canonicalize(value)
832
+ if ToolArguments.resource_error?(error)
833
+ return ["#{ValidationSupport.root_path(path)} validation limit exceeded"]
834
+ end
835
+ return ["#{ValidationSupport.root_path(path)} must be valid JSON"] if error
836
+ end
837
+ PortableValidator.new.call(value, @schema, path)
838
+ end
839
+ end
840
+ private_constant :Compiled
841
+
842
+ # A task's prompt and local validator share one immutable schema. The marker
843
+ # lets providers omit native constraints they cannot represent faithfully.
844
+ class TaskPlan
845
+ attr_reader :schema
846
+
847
+ def initialize(validator)
848
+ @validator = validator
849
+ @schema = validator.schema
850
+ freeze
851
+ end
852
+
853
+ def violations(value, path = "$")
854
+ @validator.violations(value, path)
855
+ end
856
+
857
+ def native_fallback? = true
858
+ end
859
+ private_constant :TaskPlan
860
+
861
+ # Runtime validation is deliberately smaller than JSON Schema. It never
862
+ # approximates pattern semantics; a Tool requiring them supplies a full validator.
863
+ class PortableValidator
107
864
  TYPES = {
108
- "object" => ->(v) { v.is_a?(Hash) }, "array" => ->(v) { v.is_a?(Array) },
109
- "string" => ->(v) { v.is_a?(String) }, "integer" => ->(v) { v.is_a?(Integer) },
110
- "number" => ->(v) { v.is_a?(Numeric) }, "boolean" => ->(v) { [true, false].include?(v) },
865
+ "object" => ->(value) { value.is_a?(Hash) },
866
+ "array" => ->(value) { value.is_a?(Array) },
867
+ "string" => ->(value) { value.is_a?(String) },
868
+ "integer" => ->(value) { ValidationSupport.json_integer?(value) },
869
+ "number" => ->(value) { ValidationSupport.json_number?(value) },
870
+ "boolean" => ->(value) { value.equal?(true) || value.equal?(false) },
111
871
  "null" => lambda(&:nil?)
112
872
  }.freeze
113
873
 
874
+ def initialize
875
+ @errors = []
876
+ @root_path = "$"
877
+ @truncated = false
878
+ end
879
+
880
+ def call(value, schema, path)
881
+ @root_path = ValidationSupport.root_path(path)
882
+ walk(value, schema, @root_path)
883
+ @errors
884
+ end
885
+
114
886
  private
115
887
 
888
+ def walk(value, schema, path)
889
+ return if full? || schema.equal?(true)
890
+ return add("#{path} is not allowed") if schema.equal?(false)
891
+
892
+ mismatch = type_violation(value, schema, path)
893
+ return add(mismatch) if mismatch
894
+
895
+ enum = schema["enum"]
896
+ add("#{path} must match enum") if enum && !enum.include?(value)
897
+ return if full?
898
+
899
+ object_violations(value, schema, path) if value.is_a?(Hash)
900
+ array_violations(value, schema, path) if value.is_a?(Array)
901
+ end
902
+
116
903
  def type_violation(value, spec, path)
117
- names = Array(spec["type"]).map(&:to_s)
118
- return nil if names.empty?
119
- return nil if names.any? { |name| TYPES.fetch(name, ->(_) { true }).call(value) }
904
+ return unless spec.key?("type")
120
905
 
121
- "#{path} must be #{names.join(" or ")}, got #{json_type(value)}"
906
+ names = spec["type"].is_a?(Array) ? spec["type"] : [spec["type"]]
907
+ return if names.any? { |name| TYPES.fetch(name).call(value) }
908
+
909
+ "#{path} must be #{names.join(" or ")}, got #{ValidationSupport.json_type(value)}"
122
910
  end
123
911
 
124
912
  def object_violations(value, spec, path)
125
- props = (spec["properties"] || {}).transform_keys(&:to_s)
126
- errors = Array(spec["required"]).map(&:to_s).filter_map do |key|
127
- "#{path}.#{key} is required" unless value.key?(key)
913
+ properties = spec.fetch("properties", {})
914
+ spec.fetch("required", []).each do |key|
915
+ add("#{ValidationSupport.child_path(path, key)} is required") unless value.key?(key)
916
+ break if full?
128
917
  end
918
+ return if full?
919
+
129
920
  value.each do |key, member|
130
- if (member_schema = props[key.to_s])
131
- errors.concat(violations(member, member_schema, "#{path}.#{key}"))
132
- elsif spec["additionalProperties"] == false
133
- errors << "#{path}.#{key} is not allowed"
921
+ if properties.key?(key)
922
+ walk(member, properties[key], ValidationSupport.child_path(path, key))
923
+ else
924
+ validate_additional(member, key, spec, path)
134
925
  end
926
+ break if full?
927
+ end
928
+ end
929
+
930
+ def validate_additional(member, key, spec, path)
931
+ additional = spec.fetch("additionalProperties", true)
932
+ patterns = spec["patternProperties"]
933
+ return if patterns.is_a?(Hash) && !patterns.empty? && additional != true
934
+ return if additional.equal?(true)
935
+
936
+ member_path = ValidationSupport.child_path(path, key)
937
+ if additional.equal?(false)
938
+ add("#{member_path} is not allowed")
939
+ else
940
+ walk(member, additional, member_path)
135
941
  end
136
- errors
137
942
  end
138
943
 
139
944
  def array_violations(value, spec, path)
945
+ prefix = spec.fetch("prefixItems", [])
946
+ prefix.each_with_index do |member_schema, index|
947
+ break if index >= value.length
948
+
949
+ walk(value[index], member_schema, ValidationSupport.index_path(path, index))
950
+ break if full?
951
+ end
952
+ return if full?
953
+
140
954
  items = spec["items"]
141
- return [] unless items.is_a?(Hash)
955
+ return unless items.is_a?(Hash) || items.equal?(true) || items.equal?(false)
956
+
957
+ index = prefix.length
958
+ while index < value.length
959
+ walk(value[index], items, ValidationSupport.index_path(path, index))
960
+ return if full?
142
961
 
143
- value.each_with_index.flat_map do |member, index|
144
- violations(member, items, "#{path}[#{index}]")
962
+ index += 1
145
963
  end
146
964
  end
147
965
 
148
- def json_type(value)
149
- case value
150
- when Hash then "object"
151
- when Array then "array"
152
- when String then "string"
153
- when Integer then "integer"
154
- when Numeric then "number"
155
- when true, false then "boolean"
156
- when nil then "null"
157
- else value.class.name
966
+ def add(message)
967
+ if @errors.length < MAX_VIOLATIONS - 1
968
+ @errors << message
969
+ else
970
+ @errors << "#{@root_path} additional violations omitted"
971
+ @truncated = true
158
972
  end
159
973
  end
974
+
975
+ def full? = @truncated
160
976
  end
977
+ private_constant :PortableValidator, :MAX_VIOLATIONS, :MAX_DEPTH, :MAX_NODES,
978
+ :MAX_BYTES, :JSON_TYPES
161
979
  end
162
980
  end