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/agent.rb CHANGED
@@ -19,7 +19,14 @@ module Mistri
19
19
  # queues a user message from any process while the loop runs; it folds
20
20
  # into the transcript at the next turn boundary, and a background child's
21
21
  # report arrives through the same inbox.
22
- class Agent
22
+ class Agent # rubocop:disable Metrics/ClassLength -- lifecycle order is the class contract
23
+ MAX_ARGUMENT_VIOLATIONS = 8
24
+ MAX_ARGUMENT_ERROR_BYTES = 2048
25
+ MAX_VIOLATION_SOURCE_BYTES = 4096
26
+ TOOL_ARGUMENT_VALIDATOR = Tool.instance_method(:validate_arguments)
27
+ private_constant :MAX_ARGUMENT_VIOLATIONS, :MAX_ARGUMENT_ERROR_BYTES,
28
+ :MAX_VIOLATION_SOURCE_BYTES, :TOOL_ARGUMENT_VALIDATOR
29
+
23
30
  # compaction defaults on so long sessions survive their context window;
24
31
  # pass false to disable, or a tuned Compaction. It only ever triggers
25
32
  # when the model's window is known (catalog or Compaction#window).
@@ -44,7 +51,10 @@ module Mistri
44
51
  @tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
45
52
  raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length
46
53
 
54
+ @tool_specs, @external_tool_validators = compile_tool_contracts
55
+
47
56
  @budget = budget || Budget.new
57
+ @budget.validate_provider!(@provider)
48
58
  @max_concurrency = max_concurrency
49
59
  @transform_context = Array(transform_context)
50
60
  @compaction = compaction || nil
@@ -63,7 +73,7 @@ module Mistri
63
73
  # schema, natively where the provider supports it. task adds validation
64
74
  # on top; run alone does not validate.
65
75
  def run(input, images: [], signal: nil, output_schema: nil, &emit)
66
- if @session.open_approvals.any?
76
+ if refresh_tool_control.any?
67
77
  raise ConfigurationError,
68
78
  "session is awaiting approvals; call resume"
69
79
  end
@@ -83,7 +93,7 @@ module Mistri
83
93
  # carries on as if it never stopped, unless a settled call's tool ends
84
94
  # the turn, in which case its execution was the run's last word.
85
95
  def resume(signal: nil, &emit)
86
- open = @session.open_approvals
96
+ open = refresh_tool_control
87
97
  pending = open.select { |approval| approval[:decision].nil? }
88
98
  if pending.any?
89
99
  return Result.new(message: nil, status: :awaiting_approval,
@@ -92,6 +102,10 @@ module Mistri
92
102
  end
93
103
 
94
104
  executed = settle(open, signal, &emit)
105
+ if signal&.aborted?
106
+ last = @session.messages.reverse_each.find(&:assistant?)
107
+ return finished(last, Usage.zero, signal)
108
+ end
95
109
  if executed.any? { |call| ends_turn?(call) }
96
110
  last = @session.messages.reverse_each.find(&:assistant?)
97
111
  return finished(last, Usage.zero, signal, handed_off: true)
@@ -112,8 +126,10 @@ module Mistri
112
126
  # belongs to whoever answers, and re-prompting for JSON would steal it
113
127
  # back. Ask again once the answer arrives.
114
128
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
115
- result = run(TaskOutput.prompt(input, schema), images: images, signal: signal,
116
- output_schema: schema, &emit)
129
+ plan = Schema.task_plan(schema)
130
+ result = run(
131
+ TaskOutput.prompt(input, plan), images:, signal:, output_schema: plan, &emit
132
+ )
117
133
  spent = result.usage
118
134
  fixes.downto(0) do |remaining|
119
135
  result = result.with(usage: spent)
@@ -121,11 +137,11 @@ module Mistri
121
137
  return result if result.handed_off?
122
138
 
123
139
  value = TaskOutput.parse(result.text)
124
- errors = TaskOutput.errors(value, schema)
140
+ errors = TaskOutput.errors(value, plan)
125
141
  return result.with(output: value) if errors.empty?
126
142
  raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
127
143
 
128
- result = run(TaskOutput.fix_prompt(errors), signal: signal, output_schema: schema, &emit)
144
+ result = run(TaskOutput.fix_prompt(errors), signal:, output_schema: plan, &emit)
129
145
  spent += result.usage
130
146
  end
131
147
  end
@@ -134,7 +150,7 @@ module Mistri
134
150
  # meters and near-limit warnings from this; window is nil for models the
135
151
  # catalog does not know unless Compaction#window supplies one.
136
152
  def context_usage
137
- tokens = Compaction.context_tokens(@session.messages)
153
+ tokens = @session.context_tokens
138
154
  window = context_window
139
155
  { tokens: tokens, window: window,
140
156
  fraction: window && (tokens.to_f / window).round(3) }
@@ -159,10 +175,16 @@ module Mistri
159
175
 
160
176
  fold_inbox
161
177
  compacted = auto_compact(&emit)
162
- usage += compacted[:usage] if compacted&.dig(:usage)
163
- last = run_turn(signal, output_schema, &emit)
178
+ if compacted
179
+ compaction_usage = compacted[:usage] || Usage.new
180
+ validate_usage!(compaction_usage, kind: "compaction", &emit)
181
+ usage += compaction_usage
182
+ reason = @budget.exceeded(turns:, usage:, elapsed: monotonic_now - started)
183
+ return stop_for_budget(reason, usage, &emit) if reason
184
+ end
185
+ last, turn_usage = run_turn(signal, output_schema, &emit)
164
186
  turns += 1
165
- usage += last.usage if last.usage
187
+ usage += turn_usage
166
188
 
167
189
  # Any tool call the turn made must be answered or parked, or the
168
190
  # transcript is unpairable and replay fails.
@@ -189,12 +211,20 @@ module Mistri
189
211
  def auto_compact(&)
190
212
  return nil unless @compaction
191
213
 
192
- tokens = Compaction.context_tokens(@session.messages)
193
- return nil unless @compaction.needed?(tokens, context_window)
214
+ tokens = @session.context_tokens
215
+ return nil unless @compaction.needed?(tokens, context_window,
216
+ max_output: Models.shared_output(@provider.model))
194
217
 
195
- Compactor.call(session: @session, provider: @provider, settings: @compaction, &)
196
- rescue CompactionError
197
- nil
218
+ compact_automatically(&)
219
+ end
220
+
221
+ def compact_automatically(&emit)
222
+ delivery = EventDelivery.wrap(emit)
223
+ Compactor.call(session: @session, provider: @provider, settings: @compaction, &delivery)
224
+ rescue EventDelivery::Failure => e
225
+ raise EventDelivery.unwrap(e, delivery)
226
+ rescue CompactionError => e
227
+ { usage: e.usage || Usage.new }
198
228
  end
199
229
 
200
230
  def context_window
@@ -229,12 +259,18 @@ module Mistri
229
259
  transform.call(messages)
230
260
  end
231
261
  attempt = 0
262
+ usage = Usage.zero
263
+ schema = provider_output_schema(output_schema)
232
264
  loop do
233
265
  held = nil
234
266
  gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
235
267
  message = @provider.stream(messages: history, system: @system,
236
- tools: @tools.map(&:spec), signal: signal,
237
- output_schema: output_schema, &gate)
268
+ tools: @tool_specs, signal: signal,
269
+ output_schema: schema, &gate)
270
+ message, held = enforce_tool_call_envelope(message, held)
271
+ attempt_usage = message.usage || Usage.new
272
+ validate_usage!(attempt_usage, message:, kind: "turn", &emit)
273
+ usage += attempt_usage
238
274
  attempt += 1
239
275
  error = @retries&.error_for(message)
240
276
  if retry_turn?(error, attempt, signal)
@@ -245,10 +281,18 @@ module Mistri
245
281
  end
246
282
  emit&.call(held) if held
247
283
  @session.append_message(message)
248
- return message
284
+ reserve_tool_call_ids(message.tool_calls)
285
+ return [message, usage]
249
286
  end
250
287
  end
251
288
 
289
+ def provider_output_schema(output_schema)
290
+ return output_schema unless output_schema.respond_to?(:native_fallback?)
291
+ return output_schema.schema unless @provider.respond_to?(:native_output_schema)
292
+
293
+ @provider.native_output_schema(output_schema.schema)
294
+ end
295
+
252
296
  def retry_turn?(error, attempt, signal)
253
297
  return false unless @retries && error
254
298
  return false if signal&.aborted?
@@ -257,8 +301,9 @@ module Mistri
257
301
  end
258
302
 
259
303
  def record_retry(message, error, attempt, pause, &emit)
260
- @session.append("retry", "attempt" => attempt, "error" => error,
261
- "delay" => pause.round(2))
304
+ entry = { "attempt" => attempt, "error" => error, "delay" => pause.round(2) }
305
+ entry["usage"] = (message.usage || Usage.new).to_h
306
+ @session.append("retry", entry)
262
307
  note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
263
308
  attempt: attempt, pause: pause)
264
309
  emit&.call(Event.new(type: :retry, content: note, attempt: attempt,
@@ -266,6 +311,22 @@ module Mistri
266
311
  message: message))
267
312
  end
268
313
 
314
+ def validate_usage!(usage, kind:, message: nil, &emit)
315
+ @budget.validate_usage!(usage)
316
+ rescue BudgetError => e
317
+ entry = { "kind" => kind, "usage" => usage.to_h }
318
+ entry["message"] = message.to_h if message
319
+ @session.append("unpriced_attempt", entry)
320
+ error = BudgetError.new(e.message, usage: e.usage || usage, provider_message: message)
321
+ stopped = Message.assistant(content: "Run stopped: cost could not be determined.",
322
+ stop_reason: StopReason::BUDGET,
323
+ error_message: "budget_cost_unknown")
324
+ @session.append_message(stopped)
325
+ emit&.call(Event.new(type: :error, reason: StopReason::BUDGET, message: stopped,
326
+ error_message: error.message))
327
+ raise error
328
+ end
329
+
269
330
  # Backoff that an abort can cut short.
270
331
  def wait(seconds, signal)
271
332
  deadline = monotonic_now + seconds
@@ -282,88 +343,532 @@ module Mistri
282
343
  def run_tools(assistant, signal, &emit)
283
344
  calls = assistant.tool_calls
284
345
  unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
285
- calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
346
+ calls.each do |call|
347
+ answer(call, ToolResult.new(content: ToolExecutor::INTERRUPTED, error: true), &emit)
348
+ end
286
349
  return [[], false]
287
350
  end
288
351
 
289
- parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
352
+ prepared, batch = prepare_tool_batch(calls, signal, &emit)
353
+ return interrupt_tool_turn(prepared, signal, &emit) unless batch
354
+
355
+ complete_tool_batch(calls, prepared, batch, signal, provider: assistant.provider, &emit)
356
+ end
357
+
358
+ def complete_tool_batch(source_calls, prepared, batch, signal, provider:, &emit)
359
+ batch = protect_gemini_result_order(prepared, batch) if provider == :gemini
360
+ invalid, blocked, parked, free, rejected = batch
290
361
  executed = execute(free, signal, &emit)
362
+ if signal&.aborted?
363
+ uncommitted = [*invalid, *blocked, *rejected].map(&:first) + parked
364
+ results = [*executed, *interrupted_results(uncommitted)]
365
+ answer_results(prepared, results, executed:, signal:, &emit)
366
+ return [[], executed_ends_turn?(executed)]
367
+ end
368
+
369
+ answer_results(prepared, [*invalid, *blocked, *rejected, *executed],
370
+ executed:, signal:, &emit)
371
+ if signal&.aborted?
372
+ answer_results(prepared, interrupted_results(parked),
373
+ executed: [], signal:, &emit)
374
+ return [[], executed_ends_turn?(executed)]
375
+ end
376
+
377
+ sources = source_calls.to_h { |call| [call.id, call] }
291
378
  parked.each do |call|
292
- @session.append("approval_request", "call" => call.to_h)
379
+ source = sources.fetch(call.id)
380
+ data = { "call" => call.to_h }
381
+ data["prepared_from"] = "assistant" unless call == source
382
+ @session.append("approval_request", data)
293
383
  emit&.call(Event.new(type: :approval_needed, tool_call: call))
294
384
  end
295
- [parked, executed.any? { |call| ends_turn?(call) }]
385
+ [parked, executed_ends_turn?(executed)]
386
+ end
387
+
388
+ def executed_ends_turn?(executed)
389
+ executed.any? { |call, _result, _seconds| ends_turn?(call) }
390
+ end
391
+
392
+ # Before Gemini 3, same-name parallel calls may have no wire IDs and only
393
+ # order pairs their responses. A later immediate result cannot pass an
394
+ # earlier parked result without becoming ambiguous.
395
+ def protect_gemini_result_order(prepared, batch)
396
+ invalid, blocked, parked, free, rejected = batch
397
+ positions = prepared.each_with_index.to_h { |call, index| [call.id, index] }
398
+ immediate = [*invalid, *blocked, *rejected].map(&:first) + free
399
+ unsafe, safe = parked.partition do |parked_call|
400
+ parked_call.provider_call_id.nil? && immediate.any? do |call|
401
+ call.provider_call_id.nil? && call.name == parked_call.name &&
402
+ positions.fetch(call.id) > positions.fetch(parked_call.id)
403
+ end
404
+ end
405
+ failures = unsafe.map do |call|
406
+ content = "Gemini returned parallel same-name calls without IDs across an approval " \
407
+ "boundary. This tool did not run; retry those calls separately."
408
+ [call, ToolResult.new(content:, error: true), nil]
409
+ end
410
+ [invalid, blocked, safe, free, [*rejected, *failures]]
296
411
  end
297
412
 
298
413
  # Returns the calls that actually executed (denied ones only answer).
299
- def settle(open, signal, &emit)
414
+ def settle(open, signal, &)
300
415
  approved, denied = open.partition { |approval| approval[:decision]["approved"] }
301
- cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
302
- executed = execute(cleared, signal, &emit)
303
- denied.each do |approval|
416
+ approved_calls = approved.map { |approval| approval[:call] }
417
+ _prepared, valid, invalid = prepare_calls(
418
+ approved_calls, normalize: false, signal:
419
+ )
420
+ return interrupt_settlement(open, approved_calls, denied, signal, &) if signal&.aborted?
421
+
422
+ cleared, blocked = screen(valid, signal, &)
423
+ return interrupt_settlement(open, approved_calls, denied, signal, &) if signal&.aborted?
424
+
425
+ executed = execute(cleared, signal, &)
426
+ denials = denial_results(denied)
427
+ results = if signal&.aborted?
428
+ uncommitted = [*invalid, *blocked].map(&:first)
429
+ [*executed, *interrupted_results(uncommitted), *denials]
430
+ else
431
+ [*invalid, *blocked, *executed, *denials]
432
+ end
433
+ answer_results(open.map { |approval| approval[:call] },
434
+ results, executed:, signal:, &)
435
+ executed.map(&:first)
436
+ end
437
+
438
+ def interrupt_settlement(open, approved, denied, signal, &)
439
+ results = [*interrupted_results(approved), *denial_results(denied)]
440
+ answer_results(open.map { |approval| approval[:call] }, results,
441
+ executed: [], signal:, &)
442
+ []
443
+ end
444
+
445
+ def denial_results(denied)
446
+ denied.map do |approval|
304
447
  note = approval[:decision]["note"]
305
448
  text = "The user denied this tool call#{note ? ": #{note}" : "."}"
306
- answer(approval[:call], text, &emit)
449
+ [approval[:call], text, nil]
450
+ end
451
+ end
452
+
453
+ # Provider output is untrusted until the call has resolved to a current
454
+ # tool, passed its one allowed normalization, and satisfied both core and
455
+ # host schema checks. Rejections stay paired in band, while valid siblings
456
+ # continue through policy and execution.
457
+ def prepare_calls(calls, normalize:, signal: nil)
458
+ calls.each_with_object([[], [], []]) do |call, (ordered, valid, rejected)|
459
+ if signal&.aborted?
460
+ ordered << call
461
+ next
462
+ end
463
+
464
+ prepared, result = prepare_call(call, normalize:, signal:)
465
+ ordered << prepared
466
+ if result
467
+ rejected << [prepared, result, nil]
468
+ else
469
+ valid << prepared
470
+ end
471
+ end
472
+ end
473
+
474
+ def prepare_tool_batch(calls, signal, &)
475
+ prepared, valid, invalid = prepare_calls(calls, normalize: true, signal:)
476
+ return [prepared, nil] if signal&.aborted?
477
+
478
+ screened, blocked = screen(valid, signal, &)
479
+ return [prepared, nil] if signal&.aborted?
480
+
481
+ parked, free, rejected = partition_approval(screened, signal)
482
+ return [prepared, nil] if signal&.aborted?
483
+
484
+ [prepared, [invalid, blocked, parked, free, rejected]]
485
+ end
486
+
487
+ def prepare_call(call, normalize:, signal: nil)
488
+ call = call.with unless call.arguments_owned?
489
+ tool = @tools_by_name[call.name]
490
+ return [call, unavailable_tool(call.name)] unless tool
491
+
492
+ if call.arguments_error
493
+ violation = if call.arguments_error == "invalid_json"
494
+ "arguments were not valid JSON"
495
+ else
496
+ "arguments were not valid bounded JSON"
497
+ end
498
+ return [call, argument_failure(call.name, violation)]
499
+ end
500
+ unless call.arguments.is_a?(Hash)
501
+ return [call, argument_failure(call.name, "arguments must be a JSON object")]
502
+ end
503
+
504
+ prepared, failure = normalize ? normalize_call(tool, call) : [call, nil]
505
+ return [prepared, nil] if signal&.aborted?
506
+ return [prepared, failure] if failure
507
+
508
+ [prepared, validate_call(tool, prepared)]
509
+ end
510
+
511
+ # Tool-result correlation is provider-owned opaque state. If any ID is
512
+ # unusable, the whole assistant attempt is unpairable: discard it before
513
+ # persistence and let the ordinary provider retry contract request a
514
+ # clean turn. Inventing an ID would corrupt Anthropic tool_use_id and
515
+ # OpenAI Responses call_id replay.
516
+ def enforce_tool_call_envelope(message, held)
517
+ unless message.is_a?(Message) && message.assistant?
518
+ return reject_provider_message(message, "provider turns must be assistant messages")
519
+ end
520
+
521
+ violation = tool_call_envelope_violation(message.tool_calls) ||
522
+ provider_tool_call_violation(message)
523
+ incomplete = message.tool_calls.any? { |call| call.arguments_error == "incomplete" }
524
+ return [message, held] unless violation || incomplete
525
+
526
+ if [StopReason::ERROR, StopReason::ABORTED, StopReason::LENGTH,
527
+ StopReason::BUDGET].include?(message.stop_reason)
528
+ sanitized = Message.assistant(content: message.content.grep_v(ToolCall),
529
+ model: message.model, provider: message.provider,
530
+ usage: message.usage, stop_reason: message.stop_reason,
531
+ error_message: message.error_message, error: message.error)
532
+ terminal = Event.new(type: held&.type || :error, reason: sanitized.stop_reason,
533
+ message: sanitized,
534
+ error_message: held&.error_message || sanitized.error_message)
535
+ return [sanitized, terminal]
307
536
  end
308
- executed
537
+
538
+ text = "The provider returned malformed tool-call metadata. No tools ran."
539
+ detail = violation || "tool-call arguments were incomplete"
540
+ failure = ProviderError.new("#{text} #{detail}.")
541
+ rejected = Message.assistant(content: text, model: message.model,
542
+ provider: message.provider, usage: message.usage,
543
+ stop_reason: StopReason::ERROR,
544
+ error_message: failure.message,
545
+ error: ErrorData.for(failure))
546
+ terminal = Event.new(type: :error, reason: StopReason::ERROR, message: rejected,
547
+ error_message: failure.message)
548
+ [rejected, terminal]
549
+ end
550
+
551
+ def reject_provider_message(message, reason)
552
+ text = "The provider returned an invalid message. No tools ran."
553
+ failure = ProviderError.new("#{text} #{reason}.")
554
+ usage = message.usage if message.is_a?(Message)
555
+ rejected = Message.assistant(content: text, usage:, stop_reason: StopReason::ERROR,
556
+ error_message: failure.message,
557
+ error: ErrorData.for(failure))
558
+ terminal = Event.new(type: :error, reason: StopReason::ERROR, message: rejected,
559
+ error_message: failure.message)
560
+ [rejected, terminal]
561
+ end
562
+
563
+ # Gemini cryptographically binds a function call to its continuation.
564
+ # Replacing malformed signed arguments with {} makes that continuation
565
+ # fail at the provider, so the only replay-safe boundary is the complete
566
+ # attempt: retry it without persisting any part of the bad turn.
567
+ def provider_tool_call_violation(message)
568
+ return unless message.provider == :gemini
569
+ return unless message.tool_calls.any? do |call|
570
+ call.arguments_error || !call.arguments.is_a?(Hash)
571
+ end
572
+
573
+ "Gemini function-call arguments must be a valid JSON object"
309
574
  end
310
575
 
311
- # Runs the calls and answers each; returns the calls that executed
312
- # (blocked and parked calls never reach here).
576
+ def tool_call_envelope_violation(calls)
577
+ seen = {}
578
+ seen_provider_ids = {}
579
+ calls.each do |call|
580
+ problem = call_id_problem(call.id)
581
+ return problem if problem
582
+
583
+ return "tool call IDs must be unique within a session" if @tool_call_ids.include?(call.id)
584
+
585
+ problem = call_name_problem(call.name)
586
+ return problem if problem
587
+
588
+ problem = call_signature_problem(call.signature)
589
+ return problem if problem
590
+
591
+ if call.respond_to?(:provider_call_id)
592
+ problem = provider_call_id_problem(call.provider_call_id)
593
+ return problem if problem
594
+ if call.provider_call_id && seen_provider_ids.key?(call.provider_call_id)
595
+ return "provider tool call IDs must be unique within an assistant turn"
596
+ end
597
+ end
598
+
599
+ return "tool call IDs must be unique within an assistant turn" if seen.key?(call.id)
600
+
601
+ seen[call.id] = true
602
+ seen_provider_ids[call.provider_call_id] = true if call.provider_call_id
603
+ end
604
+ nil
605
+ end
606
+
607
+ def refresh_tool_control
608
+ state = @session.tool_control_state
609
+ @tool_call_ids = state.fetch(:tool_call_ids)
610
+ state.fetch(:approvals)
611
+ end
612
+
613
+ def reserve_tool_call_ids(calls)
614
+ calls.each { |call| @tool_call_ids << call.id }
615
+ end
616
+
617
+ def call_name_problem(name)
618
+ return "a tool name was missing" if name.nil?
619
+ return "tool names must be strings" unless name.is_a?(String)
620
+ unless name.encoding == Encoding::UTF_8 && name.valid_encoding?
621
+ return "tool names must be valid UTF-8"
622
+ end
623
+ return "tool names must not be blank" if name.match?(/\A[[:space:]]*\z/)
624
+
625
+ nil
626
+ end
627
+
628
+ def call_id_problem(id)
629
+ return "a tool call ID was missing" if id.nil?
630
+ return "tool call IDs must be strings" unless id.is_a?(String)
631
+ unless id.encoding == Encoding::UTF_8 && id.valid_encoding?
632
+ return "tool call IDs must be valid UTF-8"
633
+ end
634
+ return "tool call IDs must not be blank" if id.match?(/\A[[:space:]]*\z/)
635
+
636
+ nil
637
+ end
638
+
639
+ def provider_call_id_problem(id)
640
+ return nil if id.nil?
641
+ return "provider tool call IDs must be strings" unless id.is_a?(String)
642
+ unless id.encoding == Encoding::UTF_8 && id.valid_encoding?
643
+ return "provider tool call IDs must be valid UTF-8"
644
+ end
645
+ return "provider tool call IDs must not be blank" if id.match?(/\A[[:space:]]*\z/)
646
+
647
+ nil
648
+ end
649
+
650
+ def call_signature_problem(signature)
651
+ return nil if signature.nil?
652
+ return "tool call signatures must be strings" unless signature.is_a?(String)
653
+ unless signature.encoding == Encoding::UTF_8 && signature.valid_encoding?
654
+ return "tool call signatures must be valid UTF-8"
655
+ end
656
+ return "tool call signatures must not be blank" if signature.match?(/\A[[:space:]]*\z/)
657
+
658
+ nil
659
+ end
660
+
661
+ def normalize_call(tool, call)
662
+ return [call, nil] unless tool.respond_to?(:normalize_arguments)
663
+
664
+ normalized = tool.normalize_arguments(call.arguments)
665
+ prepared = normalized.equal?(call.arguments) ? call : call.with(arguments: normalized)
666
+ if prepared.arguments_error || !prepared.arguments.is_a?(Hash)
667
+ return [prepared, preparation_failure(call.name, "normalizer", TypeError)]
668
+ end
669
+
670
+ [prepared, nil]
671
+ rescue StandardError => e
672
+ [call, preparation_failure(call.name, "normalizer", e.class)]
673
+ end
674
+
675
+ def validate_call(tool, call)
676
+ validator = @external_tool_validators[call.name]
677
+ errors = if tool.is_a?(Tool)
678
+ TOOL_ARGUMENT_VALIDATOR.bind_call(tool, call.arguments, owned: true)
679
+ else
680
+ validator&.violations(call.arguments, owned: true) || []
681
+ end
682
+ if errors.empty? && !tool.is_a?(Tool) && tool.respond_to?(:prepared_argument_violations)
683
+ errors = tool.prepared_argument_violations(call.arguments)
684
+ end
685
+ errors.empty? ? nil : argument_failure(call.name, *errors)
686
+ rescue StandardError => e
687
+ preparation_failure(call.name, "validator", e.class)
688
+ end
689
+
690
+ def argument_failure(name, *violations)
691
+ header = "Invalid arguments for tool #{tool_label(name)}:\n"
692
+ footer = "\nThe tool did not run. Correct the arguments and try again."
693
+ available = MAX_ARGUMENT_ERROR_BYTES - header.bytesize - footer.bytesize
694
+ shown = violations.first(MAX_ARGUMENT_VIOLATIONS)
695
+ shown[-1] = "additional violations were omitted" if violations.length > shown.length
696
+ lines = shown.map do |violation|
697
+ "- #{safe_violation(violation)}"
698
+ end
699
+ body = utf8_prefix(lines.join("\n"), available)
700
+ ToolResult.new(content: "#{header}#{body}#{footer}", error: true)
701
+ end
702
+
703
+ def unavailable_tool(name)
704
+ content = "Unknown tool #{tool_label(name)}. The tool did not run; choose an available tool."
705
+ ToolResult.new(content: utf8_prefix(content, MAX_ARGUMENT_ERROR_BYTES), error: true)
706
+ end
707
+
708
+ def preparation_failure(name, phase, error_class)
709
+ klass = safe_violation(error_class.name || "Exception")
710
+ content = "Tool #{tool_label(name)} could not prepare arguments: its argument #{phase} " \
711
+ "failed (#{utf8_prefix(klass, 120)}). The tool did not run. This is a host " \
712
+ "configuration failure; do not retry the same call unchanged."
713
+ ToolResult.new(content: utf8_prefix(content, MAX_ARGUMENT_ERROR_BYTES), error: true)
714
+ end
715
+
716
+ def compile_tool_contracts
717
+ validators = {}
718
+ specs = @tools.map do |tool|
719
+ raw = tool.spec
720
+ unless raw.is_a?(Hash)
721
+ raise ConfigurationError, "tool #{tool.name.inspect} spec must be a Hash"
722
+ end
723
+
724
+ spec = raw.transform_keys(&:to_sym)
725
+ schema = spec.fetch(:input_schema) do
726
+ raise ConfigurationError, "tool #{tool.name.inspect} spec needs input_schema"
727
+ end
728
+ if tool.is_a?(Tool)
729
+ schema = tool.input_schema
730
+ else
731
+ complete = tool.respond_to?(:prepared_argument_violations)
732
+ plan = Schema.tool_validator(schema, complete:)
733
+ validators[tool.name] = plan
734
+ schema = plan.schema
735
+ end
736
+ spec.merge(input_schema: schema).freeze
737
+ end
738
+ [specs.freeze, validators.freeze]
739
+ end
740
+
741
+ def tool_label(name)
742
+ safe = safe_violation(name)
743
+ JSON.generate(utf8_prefix(safe, 120))
744
+ end
745
+
746
+ def safe_violation(violation)
747
+ raw = violation.to_s
748
+ prefix = raw.byteslice(0, MAX_VIOLATION_SOURCE_BYTES).dup.force_encoding(raw.encoding)
749
+ prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "?")
750
+ .gsub(/[[:space:]]+/, " ").strip
751
+ end
752
+
753
+ def utf8_prefix(text, bytes)
754
+ return text if text.bytesize <= bytes
755
+ return "" if bytes <= 3
756
+
757
+ prefix = text.byteslice(0, bytes - 3).dup.force_encoding(Encoding::UTF_8)
758
+ prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
759
+ "#{prefix}..."
760
+ end
761
+
762
+ # Runs calls without persisting them; the caller commits and rewrites
763
+ # settled results in the model's original call order.
313
764
  def execute(calls, signal, &emit)
314
765
  return [] if calls.empty?
315
766
 
316
- results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
317
- max_concurrency: @max_concurrency,
318
- session: @session, emit: emit,
319
- app: @context)
320
- context = hook_context(signal, emit)
321
- results.each do |call, result, seconds|
322
- result = rewrite(call, result, context) if @after_tool
323
- answer(call, result, duration: seconds, &emit)
767
+ ToolExecutor.call_with_outcomes(calls, @tools_by_name, signal: signal,
768
+ max_concurrency: @max_concurrency,
769
+ session: @session, emit: emit,
770
+ app: @context,
771
+ prepared_arguments: true)
772
+ end
773
+
774
+ def interrupt_tool_turn(calls, signal, &)
775
+ answer_results(calls, interrupted_results(calls), executed: [], signal:, &)
776
+ [[], false]
777
+ end
778
+
779
+ def interrupted_results(calls)
780
+ calls.map do |call|
781
+ result = ToolResult.new(content: ToolExecutor::INTERRUPTED, error: true)
782
+ [call, result, nil]
324
783
  end
325
- calls
326
784
  end
327
785
 
328
786
  # A blocked call answers in band, so the model reads the reason and
329
787
  # reacts; a hook that raises blocks conservatively rather than letting
330
788
  # an unpoliced call through.
331
789
  def screen(calls, signal, &emit)
332
- return calls unless @before_tool
790
+ return [calls, []] unless @before_tool
333
791
 
334
792
  context = hook_context(signal, emit)
335
- calls.reject do |call|
793
+ calls.each_with_object([[], []]) do |call, (cleared, blocked)|
794
+ if signal&.aborted?
795
+ cleared << call
796
+ next
797
+ end
798
+
336
799
  reason = begin
337
800
  @before_tool.call(call, context)
801
+ rescue EventDelivery::Failure => e
802
+ raise EventDelivery.unwrap(e, context.emit)
338
803
  rescue StandardError => e
339
804
  "the before_tool hook failed: #{e.class}: #{e.message}"
340
805
  end
341
- next false unless reason.is_a?(String)
342
-
343
- answer(call, "Blocked: #{reason}", &emit)
344
- true
806
+ if reason.is_a?(String)
807
+ result = ToolResult.new(content: "Blocked: #{reason}", error: true)
808
+ blocked << [call, result, nil]
809
+ else
810
+ cleared << call
811
+ end
345
812
  end
346
813
  end
347
814
 
348
815
  def rewrite(call, result, context)
349
- @after_tool.call(call, result, context) || result
816
+ rewritten = @after_tool.call(call, result, context) || result
817
+ return rewritten unless result.is_a?(ToolResult) && result.error?
818
+
819
+ if rewritten.is_a?(ToolResult)
820
+ rewritten.with(error: true)
821
+ else
822
+ ToolResult.new(content: rewritten, error: true)
823
+ end
824
+ rescue EventDelivery::Failure => e
825
+ raise EventDelivery.unwrap(e, context.emit)
350
826
  rescue StandardError => e
351
- "Error in after_tool hook: #{e.class}: #{e.message}"
827
+ ToolResult.new(
828
+ content: "Error in after_tool hook: #{e.class}: #{e.message}. The tool already returned; " \
829
+ "verify its effects before retrying.",
830
+ error: true
831
+ )
352
832
  end
353
833
 
354
834
  def hook_context(signal, emit)
355
- ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
835
+ ToolContext.new(session: @session, signal: signal, emit: EventDelivery.wrap(emit),
836
+ app: @context)
356
837
  end
357
838
 
358
839
  # The tool message carries both channels; the :tool_result event exposes
359
840
  # it whole so hosts read event.message.ui for their side of the result.
360
841
  def answer(call, result, duration: nil, &emit)
361
- content, ui = result.is_a?(ToolResult) ? [result.content, result.ui] : [result, nil]
842
+ content, ui, tool_error = if result.is_a?(ToolResult)
843
+ [result.content, result.ui, result.error?]
844
+ else
845
+ [result, nil, false]
846
+ end
362
847
  message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
363
- tool_name: call.name, ui: ui))
848
+ tool_name: call.name, ui: ui,
849
+ tool_error: tool_error))
364
850
  text = content.is_a?(String) ? content : "[content]"
365
851
  emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
366
- message: message, duration: duration))
852
+ message: message, duration: duration, tool_error: tool_error))
853
+ end
854
+
855
+ def answer_results(calls, results, executed:, signal:, &emit)
856
+ executed_results = if @after_tool
857
+ {}.compare_by_identity.tap do |index|
858
+ executed.each { |result| index[result] = true if result[3] }
859
+ end
860
+ end
861
+ by_call = {}.compare_by_identity
862
+ results.each { |result| (by_call[result[0]] ||= []) << result }
863
+ context = hook_context(signal, emit) if @after_tool
864
+ calls.each do |call|
865
+ queued = by_call[call]
866
+ next unless queued && (entry = queued.shift)
867
+
868
+ _call, result, seconds = entry
869
+ result = rewrite(call, result, context) if executed_results&.key?(entry)
870
+ answer(call, result, duration: seconds, &emit)
871
+ end
367
872
  end
368
873
 
369
874
  def gated?(call)
@@ -371,6 +876,21 @@ module Mistri
371
876
  tool ? tool.needs_approval?(call.arguments) : false
372
877
  end
373
878
 
879
+ def partition_approval(calls, signal = nil)
880
+ calls.each_with_object([[], [], []]) do |call, (parked, free, rejected)|
881
+ if signal&.aborted?
882
+ free << call
883
+ next
884
+ end
885
+
886
+ (gated?(call) ? parked : free) << call
887
+ rescue StandardError => e
888
+ content = "Error evaluating approval policy for tool #{call.name.inspect}: " \
889
+ "#{e.class}: #{e.message}. The tool did not run."
890
+ rejected << [call, ToolResult.new(content:, error: true), nil]
891
+ end
892
+ end
893
+
374
894
  def ends_turn?(call)
375
895
  tool = @tools_by_name[call.name]
376
896
  tool ? tool.ends_turn? : false
@@ -404,5 +924,5 @@ module Mistri
404
924
  end
405
925
 
406
926
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
407
- end
927
+ end # rubocop:enable Metrics/ClassLength
408
928
  end