mistri 0.5.0 → 0.6.1

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 +480 -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 +11 -4
  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 +190 -5
  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/tool.rb CHANGED
@@ -4,50 +4,81 @@ require "json"
4
4
 
5
5
  module Mistri
6
6
  # A tool the agent can call: a name, a description, a JSON Schema for its
7
- # arguments, and a handler. The handler receives the parsed arguments hash
8
- # (string keys, exactly as the model sent them) and returns a String, a Hash
9
- # (serialized as JSON), or content blocks, so a tool can hand back images as
10
- # naturally as text.
7
+ # arguments, and a handler. Model calls reach the handler as canonical
8
+ # argument hashes, optionally normalized by the tool before policy sees
9
+ # them. Trusted direct calls keep ordinary Ruby Hash semantics.
11
10
  class Tool
12
11
  # A no-argument tool still needs a valid object schema; providers reject a
13
12
  # bare empty hash.
14
- EMPTY_SCHEMA = { type: "object", properties: {} }.freeze
13
+ EMPTY_SCHEMA = {
14
+ type: "object", properties: {}.freeze, required: [].freeze,
15
+ additionalProperties: false
16
+ }.freeze
15
17
 
16
18
  attr_reader :name, :description, :input_schema, :timeout
17
19
 
18
- # Define a tool. Give the argument shape as a raw JSON Schema hash via
19
- # input_schema:, or build it in Ruby with a schema: block.
20
+ # Define a tool. Give the argument shape as a JSON Schema value via
21
+ # input_schema:, or build it in Ruby with a schema: block. The result is
22
+ # canonicalized and owned once so provider and local semantics cannot drift.
20
23
  #
21
24
  # Tool.define("get_weather", "Weather for a city",
22
25
  # schema: -> { string :city, "City name", required: true }) do |args|
23
26
  # Weather.for(args["city"])
24
27
  # end
25
28
  def self.define(name, description, input_schema: nil, schema: nil, **, &handler)
26
- input_schema ||= schema ? Schema.build(&schema) : EMPTY_SCHEMA
29
+ raise ArgumentError, "choose input_schema or schema, not both" if schema && !input_schema.nil?
30
+
31
+ input_schema = schema ? Schema.build(&schema) : EMPTY_SCHEMA if input_schema.nil?
27
32
  new(name: name, description: description, input_schema: input_schema, **, &handler)
28
33
  end
29
34
 
30
35
  def initialize(name:, description:, input_schema: EMPTY_SCHEMA, eager_input_streaming: false,
31
- needs_approval: false, ends_turn: false, timeout: nil, &handler)
36
+ needs_approval: false, ends_turn: false, timeout: nil,
37
+ argument_normalizer: nil, argument_validator: nil,
38
+ complete_argument_validator: nil, &handler)
32
39
  raise ArgumentError, "tool #{name.inspect} needs a handler block" unless handler
40
+ unless argument_normalizer.nil? || argument_normalizer.respond_to?(:call)
41
+ raise ArgumentError, "argument_normalizer must be callable"
42
+ end
43
+ unless argument_validator.nil? || argument_validator.respond_to?(:call)
44
+ raise ArgumentError, "argument_validator must be callable"
45
+ end
46
+ unless complete_argument_validator.nil? || complete_argument_validator.respond_to?(:call)
47
+ raise ArgumentError, "complete_argument_validator must be callable"
48
+ end
49
+ if argument_validator && complete_argument_validator
50
+ raise ArgumentError,
51
+ "choose argument_validator or complete_argument_validator, not both"
52
+ end
33
53
 
34
54
  @name = name.to_s
35
55
  @description = description
36
- @input_schema = input_schema
56
+ @schema_validator = Schema.tool_validator(
57
+ input_schema, complete: !complete_argument_validator.nil?
58
+ )
59
+ @input_schema = @schema_validator.schema
37
60
  @eager_input_streaming = eager_input_streaming
38
61
  @needs_approval = needs_approval
39
62
  @ends_turn = ends_turn
40
63
  @timeout = timeout
64
+ @argument_normalizer = argument_normalizer
65
+ @argument_validator = argument_validator
66
+ @complete_argument_validator = complete_argument_validator
41
67
  @handler = handler
42
68
  end
43
69
 
44
- # A handler may return a ToolResult to speak on two channels; its ui
45
- # payload is canonicalized through JSON here so the live event and a
46
- # reloaded session read the identical shape.
70
+ # A handler may return a ToolResult to add host-only UI or declare a
71
+ # model-readable failure. Its ui payload is canonicalized through JSON
72
+ # here so the live event and a reloaded session read the identical shape.
47
73
  #
48
74
  # Handlers receive (arguments, context). A proc that declares one
49
75
  # parameter ignores the context invisibly; a lambda opts in by arity.
76
+ # Direct calls are trusted host invocations: they apply the compatibility
77
+ # normalizer, but do not canonicalize or validate as model calls do. The
78
+ # executor marks arguments the Agent already prepared so subclasses keep
79
+ # the historical #call extension point without running normalization twice.
50
80
  def call(arguments, context = ToolContext.new)
81
+ arguments = normalize_arguments(arguments || {}) unless prepared_context?(context)
51
82
  result = invoke(arguments || {}, context)
52
83
  return serialize_result(result) unless result.is_a?(ToolResult)
53
84
 
@@ -55,6 +86,31 @@ module Mistri
55
86
  ui: result.ui && JSON.parse(JSON.generate(result.ui)))
56
87
  end
57
88
 
89
+ # Normalization is an explicit per-tool migration boundary, never a
90
+ # global coercion policy. Agent calls this once before policy; direct
91
+ # trusted invocations get the same compatibility behavior through #call.
92
+ def normalize_arguments(arguments)
93
+ return arguments unless @argument_normalizer
94
+
95
+ normalized = @argument_normalizer.call(arguments)
96
+ raise ArgumentError, "argument_normalizer must return a Hash" unless normalized.is_a?(Hash)
97
+
98
+ normalized
99
+ end
100
+
101
+ # Core validation is always authoritative for its portable subset. A
102
+ # supplemental validator adds domain rules; an explicitly complete one
103
+ # additionally owns schema interactions core cannot represent.
104
+ def argument_violations(arguments)
105
+ validate_arguments(arguments, owned: false)
106
+ end
107
+
108
+ # Agent has already moved the value through ToolCall's ownership boundary,
109
+ # so its hot path can avoid copying the same immutable JSON twice.
110
+ def prepared_argument_violations(arguments)
111
+ validate_arguments(arguments, owned: true)
112
+ end
113
+
58
114
  # Whether this call should pause for a human. true/false, or a callable
59
115
  # given the parsed arguments so a tool can gate only the risky calls
60
116
  # (needs_approval: ->(args) { args["amount"].to_i > 100 }).
@@ -79,6 +135,30 @@ module Mistri
79
135
 
80
136
  private
81
137
 
138
+ def prepared_context?(context)
139
+ context.respond_to?(:arguments_prepared?) && context.arguments_prepared?
140
+ end
141
+
142
+ def validate_arguments(arguments, owned:)
143
+ unless owned
144
+ arguments, error = ToolArguments.canonicalize(arguments)
145
+ return ["$ validation limit exceeded"] if ToolArguments.resource_error?(error)
146
+ return ["$ must be valid JSON"] if error
147
+ end
148
+
149
+ errors = @schema_validator.violations(arguments, owned: true)
150
+ custom_validator = @complete_argument_validator || @argument_validator
151
+ return errors if errors.any? || !custom_validator
152
+
153
+ custom = custom_validator.call(arguments, @input_schema)
154
+ unless custom.is_a?(Array) && custom.all?(String)
155
+ name = @complete_argument_validator ? "complete_argument_validator" : "argument_validator"
156
+ raise TypeError, "#{name} must return an Array of Strings"
157
+ end
158
+
159
+ custom
160
+ end
161
+
82
162
  def invoke(arguments, context)
83
163
  if @handler.lambda? && @handler.arity.between?(0, 1)
84
164
  @handler.arity.zero? ? @handler.call : @handler.call(arguments)
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Mistri
6
+ # Owns model-supplied JSON before it crosses a durable or policy boundary.
7
+ module ToolArguments # rubocop:disable Metrics/ModuleLength -- one JSON ownership boundary
8
+ MAX_DEPTH = 64
9
+ MAX_NODES = 10_000
10
+ MAX_LEXICAL_TOKENS = (MAX_NODES * 2) + 1
11
+ MAX_BYTES = 8 * 1024 * 1024
12
+ MAX_NUMBER_BYTES = 64 * 1024
13
+ EMPTY_OBJECT = {}.freeze
14
+ ERROR_CODES = %w[
15
+ invalid_arguments invalid_encoding invalid_json invalid_key duplicate_key
16
+ cyclic_value too_deep too_large too_many_nodes number_too_large
17
+ non_finite_number non_json_value incomplete
18
+ ].freeze
19
+ RESOURCE_ERRORS = %w[too_deep too_large too_many_nodes number_too_large].freeze
20
+
21
+ Failure = Class.new(StandardError) do
22
+ attr_reader :code
23
+
24
+ def initialize(code)
25
+ @code = code
26
+ super
27
+ end
28
+ end
29
+ private_constant :Failure
30
+
31
+ module_function
32
+
33
+ def canonicalize(value)
34
+ state = { nodes: 0, bytes: 0, active: {} }
35
+ value = copy(value, state, 0)
36
+ raise Failure, "too_large" unless serialized_size_within_limit?(value)
37
+
38
+ [value, nil]
39
+ rescue Failure => e
40
+ [nil, e.code]
41
+ end
42
+
43
+ # Counts the generated JSON envelope without materializing it. Integer
44
+ # digits are conservatively bounded so a hostile Bignum cannot allocate an
45
+ # oversized decimal string before the limit is enforced.
46
+ def serialized_size_within_limit?(value, limit: MAX_BYTES)
47
+ return false unless limit.is_a?(Integer) && limit >= 0
48
+
49
+ state = { limit:, remaining: limit, active: {} }
50
+ count_json(value, state, 0)
51
+ true
52
+ rescue Failure
53
+ false
54
+ end
55
+
56
+ # Provider assemblers parse completed argument payloads strictly. Partial
57
+ # JSON belongs only to transient streaming snapshots, never executable calls.
58
+ def parse_json(raw)
59
+ return [nil, "invalid_json"] unless raw.is_a?(String)
60
+ return [nil, "too_large"] if raw.bytesize > MAX_BYTES
61
+ if (resource_error = raw_json_resource_error(raw))
62
+ return [nil, resource_error]
63
+ end
64
+
65
+ [JSON.parse(raw, max_nesting: MAX_DEPTH + 1), nil]
66
+ rescue JSON::NestingError
67
+ [nil, "too_deep"]
68
+ rescue JSON::ParserError, TypeError
69
+ [nil, "invalid_json"]
70
+ end
71
+
72
+ def normalize_error(code)
73
+ candidate = code.to_s
74
+ ERROR_CODES.include?(candidate) ? candidate.freeze : "invalid_arguments"
75
+ end
76
+
77
+ def resource_error?(code) = RESOURCE_ERRORS.include?(code)
78
+
79
+ # A lexical pass bounds parser allocation without duplicating JSON's
80
+ # grammar. Counting keys as tokens leaves room for every valid document
81
+ # under MAX_NODES while stopping wide hostile inputs before JSON.parse.
82
+ # This byte-state machine must branch on JSON token starts without building a tree.
83
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
84
+ def raw_json_resource_error(raw)
85
+ tokens = 0
86
+ depth = 0
87
+ index = 0
88
+ while index < raw.bytesize
89
+ byte = raw.getbyte(index)
90
+ case byte
91
+ when 34
92
+ tokens += 1
93
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
94
+
95
+ index = string_end(raw, index + 1)
96
+ when 91, 123
97
+ tokens += 1
98
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
99
+
100
+ depth += 1
101
+ return "too_deep" if depth > MAX_DEPTH + 1
102
+
103
+ index += 1
104
+ when 93, 125
105
+ depth -= 1 if depth.positive?
106
+ index += 1
107
+ when 45, 48..57
108
+ tokens += 1
109
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
110
+
111
+ number_start = index
112
+ index = number_end(raw, index + 1)
113
+ return "number_too_large" if index - number_start > MAX_NUMBER_BYTES
114
+ when 116
115
+ tokens += 1
116
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
117
+
118
+ index += raw.byteslice(index, 4) == "true" ? 4 : 1
119
+ when 102
120
+ tokens += 1
121
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
122
+
123
+ index += raw.byteslice(index, 5) == "false" ? 5 : 1
124
+ when 110
125
+ tokens += 1
126
+ return "too_many_nodes" if tokens > MAX_LEXICAL_TOKENS
127
+
128
+ index += raw.byteslice(index, 4) == "null" ? 4 : 1
129
+ else
130
+ index += 1
131
+ end
132
+ end
133
+ nil
134
+ end
135
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
136
+
137
+ def string_end(raw, index)
138
+ while index < raw.bytesize
139
+ case raw.getbyte(index)
140
+ when 34 then return index + 1
141
+ when 92 then index += 2
142
+ else index += 1
143
+ end
144
+ end
145
+ index
146
+ end
147
+ private_class_method :string_end
148
+
149
+ def number_end(raw, index)
150
+ while index < raw.bytesize
151
+ byte = raw.getbyte(index)
152
+ break unless byte == 43 || byte == 45 || byte == 46 || byte == 69 || byte == 101 ||
153
+ byte.between?(48, 57)
154
+
155
+ index += 1
156
+ end
157
+ index
158
+ end
159
+ private_class_method :number_end
160
+
161
+ # Anthropic and Gemini require an object when an earlier invalid call is
162
+ # replayed. The paired error result preserves the truth; this shape only
163
+ # keeps the provider transcript structurally valid.
164
+ def replay_object(call)
165
+ return EMPTY_OBJECT if call.arguments_error
166
+ return EMPTY_OBJECT unless call.arguments.is_a?(Hash)
167
+
168
+ call.arguments
169
+ end
170
+
171
+ # OpenAI carries function arguments as a JSON string, so every completed
172
+ # JSON value can replay exactly; only an unparseable value needs a placeholder.
173
+ def replay_value(call)
174
+ call.arguments_error ? EMPTY_OBJECT : call.arguments
175
+ end
176
+
177
+ # Assemblers own freshly parsed previews, so they can freeze the tree in
178
+ # place without the copy that completed values require.
179
+ def freeze_partial(value)
180
+ return value if value.frozen?
181
+
182
+ case value
183
+ when Hash
184
+ value.each do |key, nested|
185
+ freeze_partial(key)
186
+ freeze_partial(nested)
187
+ end
188
+ when Array
189
+ value.each { |nested| freeze_partial(nested) }
190
+ end
191
+ value.freeze if value.respond_to?(:freeze)
192
+ value
193
+ end
194
+
195
+ def copy(value, state, depth)
196
+ raise Failure, "too_deep" if depth > MAX_DEPTH
197
+
198
+ state[:nodes] += 1
199
+ raise Failure, "too_large" if state[:nodes] > MAX_NODES
200
+
201
+ case value
202
+ when nil, true, false then value
203
+ when Integer then copy_integer(value)
204
+ when Float then copy_float(value)
205
+ when String then copy_string(value, state)
206
+ when Array then copy_array(value, state, depth)
207
+ when Hash then copy_hash(value, state, depth)
208
+ else raise Failure, "non_json_value"
209
+ end
210
+ end
211
+ private_class_method :copy
212
+
213
+ def copy_float(value)
214
+ raise Failure, "non_finite_number" unless value.finite?
215
+
216
+ value
217
+ end
218
+ private_class_method :copy_float
219
+
220
+ def copy_integer(value)
221
+ upper_bound = integer_decimal_upper_bound(value)
222
+ raise Failure, "number_too_large" if upper_bound > MAX_NUMBER_BYTES + 1
223
+ if upper_bound > MAX_NUMBER_BYTES && value.to_s.bytesize > MAX_NUMBER_BYTES
224
+ raise Failure, "number_too_large"
225
+ end
226
+
227
+ value
228
+ end
229
+ private_class_method :copy_integer
230
+
231
+ def copy_string(value, state)
232
+ string = if value.encoding == Encoding::UTF_8
233
+ raise Failure, "too_large" if value.bytesize > MAX_BYTES - state[:bytes]
234
+ raise Failure, "invalid_encoding" unless value.valid_encoding?
235
+
236
+ value.dup
237
+ else
238
+ value.encode(Encoding::UTF_8)
239
+ end
240
+ state[:bytes] += string.bytesize
241
+ raise Failure, "too_large" if state[:bytes] > MAX_BYTES
242
+
243
+ string.freeze
244
+ rescue EncodingError
245
+ raise Failure, "invalid_encoding"
246
+ end
247
+ private_class_method :copy_string
248
+
249
+ def copy_array(value, state, depth)
250
+ within(value, state) do
251
+ copied = []
252
+ # Array#map preallocates from an untrusted length before the node ceiling fires.
253
+ # rubocop:disable Style/MapIntoArray
254
+ value.each { |item| copied << copy(item, state, depth + 1) }
255
+ # rubocop:enable Style/MapIntoArray
256
+ copied.freeze
257
+ end
258
+ end
259
+ private_class_method :copy_array
260
+
261
+ def copy_hash(value, state, depth)
262
+ within(value, state) do
263
+ value.each_with_object({}) do |(key, item), copy|
264
+ key = copy_key(key, state)
265
+ raise Failure, "duplicate_key" if copy.key?(key)
266
+
267
+ copy[key] = copy(item, state, depth + 1)
268
+ end.freeze
269
+ end
270
+ end
271
+ private_class_method :copy_hash
272
+
273
+ def copy_key(key, state)
274
+ raise Failure, "invalid_key" unless key.is_a?(String) || key.is_a?(Symbol)
275
+
276
+ copy_string(key.to_s, state)
277
+ end
278
+ private_class_method :copy_key
279
+
280
+ def within(value, state)
281
+ identity = value.object_id
282
+ raise Failure, "cyclic_value" if state[:active].key?(identity)
283
+
284
+ state[:active][identity] = true
285
+ inserted = true
286
+ yield
287
+ ensure
288
+ state[:active].delete(identity) if inserted
289
+ end
290
+ private_class_method :within
291
+
292
+ def count_json(value, state, depth)
293
+ raise Failure, "too_deep" if depth > MAX_DEPTH
294
+
295
+ case value
296
+ when nil, true then consume(state, 4)
297
+ when false then consume(state, 5)
298
+ when Integer then count_integer(value, state)
299
+ when Float then count_float(value, state)
300
+ when String then count_string(value, state)
301
+ when Array then count_array(value, state, depth)
302
+ when Hash then count_hash(value, state, depth)
303
+ else raise Failure, "non_json_value"
304
+ end
305
+ end
306
+ private_class_method :count_json
307
+
308
+ def count_float(value, state)
309
+ raise Failure, "non_finite_number" unless value.finite?
310
+
311
+ consume(state, JSON.generate(value).bytesize)
312
+ end
313
+ private_class_method :count_float
314
+
315
+ def count_integer(value, state)
316
+ copy_integer(value)
317
+ upper_bound = integer_decimal_upper_bound(value)
318
+ # A bit length can straddle one decimal boundary. One byte of bounded
319
+ # slack lets the exact count decide without exposing an oversized allocation.
320
+ raise Failure, "too_large" if upper_bound > state[:limit] + 1
321
+
322
+ consume(state, value.to_s.bytesize)
323
+ end
324
+ private_class_method :count_integer
325
+
326
+ def integer_decimal_upper_bound(value)
327
+ digits = ((value.bit_length * 30_103) / 100_000) + 1
328
+ digits + (value.negative? ? 1 : 0)
329
+ end
330
+ private_class_method :integer_decimal_upper_bound
331
+
332
+ def count_string(value, state)
333
+ valid_utf8 = value.encoding == Encoding::UTF_8 && value.valid_encoding?
334
+ raise Failure, "invalid_encoding" unless valid_utf8
335
+
336
+ short_controls = value.count("\b\t\n\f\r")
337
+ controls = value.count("\u0000-\u001f")
338
+ escaped = value.count('"') + value.count("\\")
339
+ size = value.bytesize + 2 + escaped + short_controls + ((controls - short_controls) * 5)
340
+ consume(state, size)
341
+ end
342
+ private_class_method :count_string
343
+
344
+ def count_array(value, state, depth)
345
+ within(value, state) do
346
+ consume(state, 2)
347
+ value.each_with_index do |item, index|
348
+ consume(state, 1) unless index.zero?
349
+ count_json(item, state, depth + 1)
350
+ end
351
+ end
352
+ end
353
+ private_class_method :count_array
354
+
355
+ def count_hash(value, state, depth)
356
+ within(value, state) do
357
+ consume(state, 2)
358
+ value.each_with_index do |(key, item), index|
359
+ raise Failure, "invalid_key" unless key.is_a?(String)
360
+
361
+ consume(state, 1) unless index.zero?
362
+ count_string(key, state)
363
+ consume(state, 1)
364
+ count_json(item, state, depth + 1)
365
+ end
366
+ end
367
+ end
368
+ private_class_method :count_hash
369
+
370
+ def consume(state, bytes)
371
+ raise Failure, "too_large" if bytes > state[:remaining]
372
+
373
+ state[:remaining] -= bytes
374
+ end
375
+ private_class_method :consume
376
+ end # rubocop:enable Metrics/ModuleLength
377
+ end
@@ -1,18 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "tool_arguments"
4
+
3
5
  module Mistri
4
- # One tool invocation requested by the model. `arguments` is the parsed Hash
5
- # exactly as the model emitted it, string keys and all; the top level is owned
6
- # and frozen, nested values stay as parsed. `signature` is the opaque
7
- # reasoning payload some providers attach to the call and require echoed back,
8
- # such as Gemini's thoughtSignature.
9
- ToolCall = Data.define(:id, :name, :arguments, :signature) do
10
- def initialize(id:, name:, arguments: {}, signature: nil)
11
- super(id:, name:, arguments: arguments.dup.freeze, signature:)
6
+ # One immutable tool invocation requested by the model. Completed arguments
7
+ # are deeply owned JSON; arguments_error records why an unsafe value became
8
+ # nil instead of letting it cross the execution boundary. Signature carries
9
+ # opaque replay state; provider_call_id distinguishes an optional wire ID
10
+ # from Mistri's always-present session correlation ID.
11
+ ToolCall = Data.define(:id, :name, :arguments, :signature, :arguments_error,
12
+ :provider_call_id) do
13
+ def initialize(id:, name:, arguments: {}, signature: nil, arguments_error: nil,
14
+ provider_call_id: nil, canonicalize: true)
15
+ id = id.dup.freeze if id.is_a?(String)
16
+ name = name.dup.freeze if name.is_a?(String)
17
+ signature = signature.dup.freeze if signature.is_a?(String)
18
+ provider_call_id = provider_call_id.dup.freeze if provider_call_id.is_a?(String)
19
+ @arguments_owned = canonicalize || !arguments_error.nil?
20
+ if arguments_error.nil?
21
+ arguments, arguments_error = ToolArguments.canonicalize(arguments) if canonicalize
22
+ else
23
+ arguments = nil
24
+ arguments_error = ToolArguments.normalize_error(arguments_error)
25
+ end
26
+ super(id:, name:, arguments:, signature:, arguments_error:, provider_call_id:)
12
27
  end
13
28
 
14
29
  def type = :tool_call
15
30
 
16
- def to_h = { type: :tool_call, id:, name:, arguments:, signature: }.compact
31
+ def arguments_error? = !arguments_error.nil?
32
+
33
+ def arguments_owned? = @arguments_owned
34
+
35
+ # Data#with bypasses custom initializers; this value must re-enter the
36
+ # ownership boundary whenever a normalizer replaces its arguments.
37
+ def with(id: self.id, name: self.name, arguments: self.arguments,
38
+ signature: self.signature, arguments_error: self.arguments_error,
39
+ provider_call_id: self.provider_call_id)
40
+ self.class.new(id:, name:, arguments:, signature:, arguments_error:, provider_call_id:)
41
+ end
42
+
43
+ def to_h
44
+ hash = { type: :tool_call, id:, name: }.compact
45
+ hash[:arguments] = arguments
46
+ hash[:signature] = signature unless signature.nil?
47
+ hash[:arguments_error] = arguments_error unless arguments_error.nil?
48
+ hash[:provider_call_id] = provider_call_id unless provider_call_id.nil?
49
+ hash
50
+ end
17
51
  end
18
52
  end
@@ -15,8 +15,10 @@ module Mistri
15
15
  #
16
16
  # agent = Mistri.agent("claude-opus-4-8", tools: tools,
17
17
  # context: { traveler: current_traveler })
18
- # Mistri::Tool.define("book_hotel", "Books the chosen hotel.") do |args, context|
19
- # Bookings.create(args, traveler: context.app[:traveler])
18
+ # Mistri::Tool.define("book_hotel", "Books the chosen hotel.", schema: -> {
19
+ # string :hotel_id, "Hotel ID", required: true
20
+ # }) do |args, context|
21
+ # Bookings.create(args.fetch("hotel_id"), traveler: context.app[:traveler])
20
22
  # end
21
23
  ToolContext = Data.define(:session, :signal, :emit, :app) do
22
24
  def initialize(session: nil, signal: nil, emit: nil, app: nil)