mistri 0.4.1 → 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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +596 -3
  3. data/CONTRIBUTING.md +52 -0
  4. data/README.md +291 -306
  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/abort_signal.rb +10 -0
  28. data/lib/mistri/agent.rb +635 -108
  29. data/lib/mistri/budget.rb +26 -1
  30. data/lib/mistri/child.rb +186 -0
  31. data/lib/mistri/compaction.rb +26 -10
  32. data/lib/mistri/compactor.rb +35 -12
  33. data/lib/mistri/console.rb +209 -0
  34. data/lib/mistri/content.rb +9 -3
  35. data/lib/mistri/dispatchers.rb +49 -0
  36. data/lib/mistri/errors.rb +83 -4
  37. data/lib/mistri/event.rb +30 -8
  38. data/lib/mistri/event_delivery.rb +60 -0
  39. data/lib/mistri/locks/rails_cache.rb +48 -0
  40. data/lib/mistri/locks.rb +141 -0
  41. data/lib/mistri/mcp/client.rb +74 -19
  42. data/lib/mistri/mcp/egress.rb +216 -0
  43. data/lib/mistri/mcp/oauth.rb +476 -127
  44. data/lib/mistri/mcp/wires.rb +115 -23
  45. data/lib/mistri/mcp.rb +43 -9
  46. data/lib/mistri/message.rb +21 -11
  47. data/lib/mistri/models.rb +160 -22
  48. data/lib/mistri/providers/anthropic/assembler.rb +282 -44
  49. data/lib/mistri/providers/anthropic/serializer.rb +14 -9
  50. data/lib/mistri/providers/anthropic.rb +29 -6
  51. data/lib/mistri/providers/fake.rb +36 -6
  52. data/lib/mistri/providers/gemini/assembler.rb +148 -21
  53. data/lib/mistri/providers/gemini/serializer.rb +78 -9
  54. data/lib/mistri/providers/gemini.rb +31 -5
  55. data/lib/mistri/providers/openai/assembler.rb +337 -60
  56. data/lib/mistri/providers/openai/serializer.rb +13 -12
  57. data/lib/mistri/providers/openai.rb +29 -5
  58. data/lib/mistri/providers/schema_capabilities.rb +214 -0
  59. data/lib/mistri/result.rb +8 -3
  60. data/lib/mistri/retry_policy.rb +2 -2
  61. data/lib/mistri/schema.rb +893 -75
  62. data/lib/mistri/session.rb +649 -47
  63. data/lib/mistri/sinks/coalesced.rb +17 -10
  64. data/lib/mistri/skill.rb +1 -1
  65. data/lib/mistri/skills.rb +1 -1
  66. data/lib/mistri/spawner.rb +316 -0
  67. data/lib/mistri/sse.rb +57 -14
  68. data/lib/mistri/stores/active_record.rb +22 -7
  69. data/lib/mistri/stores/jsonl.rb +3 -1
  70. data/lib/mistri/stores/memory.rb +21 -2
  71. data/lib/mistri/sub_agent/execution.rb +81 -0
  72. data/lib/mistri/sub_agent/runtime.rb +297 -0
  73. data/lib/mistri/sub_agent.rb +238 -103
  74. data/lib/mistri/task_output.rb +58 -0
  75. data/lib/mistri/tool.rb +102 -13
  76. data/lib/mistri/tool_arguments.rb +377 -0
  77. data/lib/mistri/tool_call.rb +43 -9
  78. data/lib/mistri/tool_context.rb +7 -5
  79. data/lib/mistri/tool_executor.rb +117 -26
  80. data/lib/mistri/tool_result.rb +15 -10
  81. data/lib/mistri/tools/edit_file.rb +62 -8
  82. data/lib/mistri/tools.rb +41 -4
  83. data/lib/mistri/transport.rb +149 -44
  84. data/lib/mistri/usage.rb +65 -13
  85. data/lib/mistri/version.rb +1 -1
  86. data/lib/mistri/workspace/active_record.rb +183 -3
  87. data/lib/mistri/workspace/directory.rb +28 -8
  88. data/lib/mistri/workspace/memory.rb +34 -9
  89. data/lib/mistri/workspace/single.rb +62 -5
  90. data/lib/mistri/workspace.rb +39 -0
  91. data/lib/mistri.rb +17 -1
  92. data/mistri.gemspec +34 -0
  93. metadata +38 -3
data/lib/mistri/agent.rb CHANGED
@@ -13,9 +13,20 @@ module Mistri
13
13
  # marked needs_approval suspends the run instead of executing: the run
14
14
  # returns at once (no thread ever waits on a human), the decision arrives
15
15
  # later as a session entry from any process, and resume settles it and
16
- # carries on. Session#steer queues a user message from any process while
17
- # the loop runs; it folds into the transcript at the next turn boundary.
18
- class Agent
16
+ # carries on. A tool marked ends_turn ends the run once it executes: the
17
+ # model does not get another word, and whatever comes next (a human's
18
+ # answer to ask_user, say) arrives as the next run's input. Session#steer
19
+ # queues a user message from any process while the loop runs; it folds
20
+ # into the transcript at the next turn boundary, and a background child's
21
+ # report arrives through the same inbox.
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
+
19
30
  # compaction defaults on so long sessions survive their context window;
20
31
  # pass false to disable, or a tuned Compaction. It only ever triggers
21
32
  # when the model's window is known (catalog or Compaction#window).
@@ -40,7 +51,10 @@ module Mistri
40
51
  @tools_by_name = @tools.to_h { |tool| [tool.name, tool] }
41
52
  raise ConfigurationError, "duplicate tool names" if @tools_by_name.length != @tools.length
42
53
 
54
+ @tool_specs, @external_tool_validators = compile_tool_contracts
55
+
43
56
  @budget = budget || Budget.new
57
+ @budget.validate_provider!(@provider)
44
58
  @max_concurrency = max_concurrency
45
59
  @transform_context = Array(transform_context)
46
60
  @compaction = compaction || nil
@@ -59,7 +73,7 @@ module Mistri
59
73
  # schema, natively where the provider supports it. task adds validation
60
74
  # on top; run alone does not validate.
61
75
  def run(input, images: [], signal: nil, output_schema: nil, &emit)
62
- if @session.open_approvals.any?
76
+ if refresh_tool_control.any?
63
77
  raise ConfigurationError,
64
78
  "session is awaiting approvals; call resume"
65
79
  end
@@ -68,7 +82,7 @@ module Mistri
68
82
  "run needs input text or images"
69
83
  end
70
84
 
71
- fold_steers # steers queued while idle arrived first; keep that order
85
+ fold_inbox # anything queued while this session sat idle arrived first; keep that order
72
86
  @session.append_message(Message.user_with_images(input, images))
73
87
  loop_turns(signal, output_schema, &emit)
74
88
  end
@@ -76,9 +90,10 @@ module Mistri
76
90
  # Continue a suspended run. Undecided approvals return immediately, still
77
91
  # suspended. Decided ones settle first: approved calls execute, denied
78
92
  # calls answer in band so the model knows and can react. Then the loop
79
- # carries on as if it never stopped.
93
+ # carries on as if it never stopped, unless a settled call's tool ends
94
+ # the turn, in which case its execution was the run's last word.
80
95
  def resume(signal: nil, &emit)
81
- open = @session.open_approvals
96
+ open = refresh_tool_control
82
97
  pending = open.select { |approval| approval[:decision].nil? }
83
98
  if pending.any?
84
99
  return Result.new(message: nil, status: :awaiting_approval,
@@ -86,7 +101,16 @@ module Mistri
86
101
  usage: Usage.zero)
87
102
  end
88
103
 
89
- settle(open, signal, &emit)
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
109
+ if executed.any? { |call| ends_turn?(call) }
110
+ last = @session.messages.reverse_each.find(&:assistant?)
111
+ return finished(last, Usage.zero, signal, handed_off: true)
112
+ end
113
+
90
114
  loop_turns(signal, nil, &emit)
91
115
  end
92
116
 
@@ -98,21 +122,26 @@ module Mistri
98
122
  #
99
123
  # A run that suspends for approval returns as-is: validation applies to
100
124
  # completed runs only, so resume the session and re-ask if that happens
101
- # mid-task.
125
+ # mid-task. A run an ends_turn tool ended returns as-is too: the floor
126
+ # belongs to whoever answers, and re-prompting for JSON would steal it
127
+ # back. Ask again once the answer arrives.
102
128
  def task(input, schema:, images: [], signal: nil, fixes: 1, &emit)
103
- result = run(task_input(input, schema), images: images, signal: signal,
104
- 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
+ )
105
133
  spent = result.usage
106
134
  fixes.downto(0) do |remaining|
107
135
  result = result.with(usage: spent)
108
136
  return result unless result.completed?
137
+ return result if result.handed_off?
109
138
 
110
- value = parse_output(result.text)
111
- errors = task_errors(value, schema)
139
+ value = TaskOutput.parse(result.text)
140
+ errors = TaskOutput.errors(value, plan)
112
141
  return result.with(output: value) if errors.empty?
113
142
  raise SchemaError, "task output failed validation: #{errors.join("; ")}" if remaining.zero?
114
143
 
115
- result = run(fix_prompt(errors), signal: signal, output_schema: schema, &emit)
144
+ result = run(TaskOutput.fix_prompt(errors), signal:, output_schema: plan, &emit)
116
145
  spent += result.usage
117
146
  end
118
147
  end
@@ -121,7 +150,7 @@ module Mistri
121
150
  # meters and near-limit warnings from this; window is nil for models the
122
151
  # catalog does not know unless Compaction#window supplies one.
123
152
  def context_usage
124
- tokens = Compaction.context_tokens(@session.messages)
153
+ tokens = @session.context_tokens
125
154
  window = context_window
126
155
  { tokens: tokens, window: window,
127
156
  fraction: window && (tokens.to_f / window).round(3) }
@@ -144,29 +173,36 @@ module Mistri
144
173
  reason = @budget.exceeded(turns: turns, usage: usage, elapsed: monotonic_now - started)
145
174
  return stop_for_budget(reason, usage, &emit) if reason
146
175
 
147
- fold_steers
176
+ fold_inbox
148
177
  compacted = auto_compact(&emit)
149
- usage += compacted[:usage] if compacted&.dig(:usage)
150
- 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)
151
186
  turns += 1
152
- usage += last.usage if last.usage
187
+ usage += turn_usage
153
188
 
154
189
  # Any tool call the turn made must be answered or parked, or the
155
190
  # transcript is unpairable and replay fails.
156
- parked = last.tool_calls? ? run_tools(last, signal, &emit) : []
191
+ parked, ended = last.tool_calls? ? run_tools(last, signal, &emit) : [[], false]
157
192
  return suspended(last, parked, usage) if parked.any?
158
- return finished(last, usage) if done?(last, signal)
193
+ return finished(last, usage, signal, handed_off: ended) if ended || done?(last, signal)
159
194
  end
160
195
  end
161
196
 
162
- # A steer that lands while the model finishes cleanly extends the run one
163
- # more turn so it gets answered. Aborts, errors, and length stops always
164
- # end the run; the steer stays pending for the next one.
197
+ # A steer or a child's report that lands while the model finishes
198
+ # cleanly extends the run one more turn, so it is answered rather than
199
+ # left dangling. Aborts, errors, and length stops always end the run;
200
+ # the inbox stays pending for the next one.
165
201
  def done?(last, signal)
166
202
  return false if last.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
167
203
  return true if signal&.aborted? || last.stop_reason != StopReason::STOP
168
204
 
169
- @session.pending_steers.empty?
205
+ @session.pending_inbox.empty?
170
206
  end
171
207
 
172
208
  # Compact when the context has grown into the reserve. A failed
@@ -175,25 +211,35 @@ module Mistri
175
211
  def auto_compact(&)
176
212
  return nil unless @compaction
177
213
 
178
- tokens = Compaction.context_tokens(@session.messages)
179
- 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))
180
217
 
181
- Compactor.call(session: @session, provider: @provider, settings: @compaction, &)
182
- rescue CompactionError
183
- 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 }
184
228
  end
185
229
 
186
230
  def context_window
187
231
  @compaction&.window || Models.find(@provider.model)&.context_window
188
232
  end
189
233
 
190
- # Materialize queued steers into the transcript in arrival order. The
191
- # folded message entry carries the steer id, which is what marks the steer
192
- # consumed: one append is both the fold and the marker, so a crash between
193
- # steers never double-delivers.
194
- def fold_steers
195
- @session.pending_steers.each do |steer|
196
- @session.append("message", "message" => steer["message"], "steer_id" => steer["id"])
234
+ # Materialize the inbox, queued steers and sub-agent reports alike, into
235
+ # the transcript in arrival order. The folded message entry carries the
236
+ # source entry's id under its marker key, which is what marks the entry
237
+ # consumed: one append is both the fold and the marker, so a crash
238
+ # between folds never double-delivers.
239
+ def fold_inbox
240
+ @session.pending_inbox.each do |entry|
241
+ marker = Session::INBOX.fetch(entry["type"])
242
+ @session.append("message", "message" => entry["message"], marker => entry["id"])
197
243
  end
198
244
  end
199
245
 
@@ -213,12 +259,18 @@ module Mistri
213
259
  transform.call(messages)
214
260
  end
215
261
  attempt = 0
262
+ usage = Usage.zero
263
+ schema = provider_output_schema(output_schema)
216
264
  loop do
217
265
  held = nil
218
266
  gate = emit && ->(event) { event.terminal? ? held = event : emit.call(event) }
219
267
  message = @provider.stream(messages: history, system: @system,
220
- tools: @tools.map(&:spec), signal: signal,
221
- 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
222
274
  attempt += 1
223
275
  error = @retries&.error_for(message)
224
276
  if retry_turn?(error, attempt, signal)
@@ -229,10 +281,18 @@ module Mistri
229
281
  end
230
282
  emit&.call(held) if held
231
283
  @session.append_message(message)
232
- return message
284
+ reserve_tool_call_ids(message.tool_calls)
285
+ return [message, usage]
233
286
  end
234
287
  end
235
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
+
236
296
  def retry_turn?(error, attempt, signal)
237
297
  return false unless @retries && error
238
298
  return false if signal&.aborted?
@@ -241,8 +301,9 @@ module Mistri
241
301
  end
242
302
 
243
303
  def record_retry(message, error, attempt, pause, &emit)
244
- @session.append("retry", "attempt" => attempt, "error" => error,
245
- "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)
246
307
  note = format("attempt %<attempt>d failed; retrying in %<pause>.1fs",
247
308
  attempt: attempt, pause: pause)
248
309
  emit&.call(Event.new(type: :retry, content: note, attempt: attempt,
@@ -250,6 +311,22 @@ module Mistri
250
311
  message: message))
251
312
  end
252
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
+
253
330
  # Backoff that an abort can cut short.
254
331
  def wait(seconds, signal)
255
332
  deadline = monotonic_now + seconds
@@ -258,46 +335,451 @@ module Mistri
258
335
 
259
336
  # Answer or park the assistant's tool calls. Ungated calls execute (only
260
337
  # on a genuine tool_use turn with no abort; otherwise they pair with
261
- # interrupted results). Gated calls park as approval requests and are
262
- # returned, so the loop can suspend. Nothing is left dangling either way.
338
+ # interrupted results). Gated calls park as approval requests. Nothing
339
+ # is left dangling either way. Returns the parked calls and whether an
340
+ # executed tool ends the turn; a parked call outranks an executed
341
+ # ends_turn, because a suspension is the stronger stop and the model
342
+ # regains the floor when the run resumes.
263
343
  def run_tools(assistant, signal, &emit)
264
344
  calls = assistant.tool_calls
265
345
  unless assistant.stop_reason == StopReason::TOOL_USE && !signal&.aborted?
266
- calls.each { |call| answer(call, ToolExecutor::INTERRUPTED, &emit) }
267
- return []
346
+ calls.each do |call|
347
+ answer(call, ToolResult.new(content: ToolExecutor::INTERRUPTED, error: true), &emit)
348
+ end
349
+ return [[], false]
350
+ end
351
+
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
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)]
268
375
  end
269
376
 
270
- parked, free = screen(calls, signal, &emit).partition { |call| gated?(call) }
271
- execute(free, signal, &emit)
377
+ sources = source_calls.to_h { |call| [call.id, call] }
272
378
  parked.each do |call|
273
- @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)
274
383
  emit&.call(Event.new(type: :approval_needed, tool_call: call))
275
384
  end
276
- parked
385
+ [parked, executed_ends_turn?(executed)]
277
386
  end
278
387
 
279
- def settle(open, signal, &emit)
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]]
411
+ end
412
+
413
+ # Returns the calls that actually executed (denied ones only answer).
414
+ def settle(open, signal, &)
280
415
  approved, denied = open.partition { |approval| approval[:decision]["approved"] }
281
- cleared = screen(approved.map { |approval| approval[:call] }, signal, &emit)
282
- execute(cleared, signal, &emit)
283
- 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|
284
447
  note = approval[:decision]["note"]
285
448
  text = "The user denied this tool call#{note ? ": #{note}" : "."}"
286
- 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
287
471
  end
288
472
  end
289
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]
536
+ end
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"
574
+ end
575
+
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.
290
764
  def execute(calls, signal, &emit)
291
- return if calls.empty?
765
+ return [] if calls.empty?
292
766
 
293
- results = ToolExecutor.call(calls, @tools_by_name, signal: signal,
294
- max_concurrency: @max_concurrency,
295
- session: @session, emit: emit,
296
- app: @context)
297
- context = hook_context(signal, emit)
298
- results.each do |call, result, seconds|
299
- result = rewrite(call, result, context) if @after_tool
300
- 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]
301
783
  end
302
784
  end
303
785
 
@@ -305,41 +787,88 @@ module Mistri
305
787
  # reacts; a hook that raises blocks conservatively rather than letting
306
788
  # an unpoliced call through.
307
789
  def screen(calls, signal, &emit)
308
- return calls unless @before_tool
790
+ return [calls, []] unless @before_tool
309
791
 
310
792
  context = hook_context(signal, emit)
311
- 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
+
312
799
  reason = begin
313
800
  @before_tool.call(call, context)
801
+ rescue EventDelivery::Failure => e
802
+ raise EventDelivery.unwrap(e, context.emit)
314
803
  rescue StandardError => e
315
804
  "the before_tool hook failed: #{e.class}: #{e.message}"
316
805
  end
317
- next false unless reason.is_a?(String)
318
-
319
- answer(call, "Blocked: #{reason}", &emit)
320
- 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
321
812
  end
322
813
  end
323
814
 
324
815
  def rewrite(call, result, context)
325
- @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)
326
826
  rescue StandardError => e
327
- "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
+ )
328
832
  end
329
833
 
330
834
  def hook_context(signal, emit)
331
- ToolContext.new(session: @session, signal: signal, emit: emit, app: @context)
835
+ ToolContext.new(session: @session, signal: signal, emit: EventDelivery.wrap(emit),
836
+ app: @context)
332
837
  end
333
838
 
334
839
  # The tool message carries both channels; the :tool_result event exposes
335
840
  # it whole so hosts read event.message.ui for their side of the result.
336
841
  def answer(call, result, duration: nil, &emit)
337
- 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
338
847
  message = @session.append_message(Message.tool(content: content, tool_call_id: call.id,
339
- tool_name: call.name, ui: ui))
848
+ tool_name: call.name, ui: ui,
849
+ tool_error: tool_error))
340
850
  text = content.is_a?(String) ? content : "[content]"
341
851
  emit&.call(Event.new(type: :tool_result, tool_call: call, content: text,
342
- 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
343
872
  end
344
873
 
345
874
  def gated?(call)
@@ -347,10 +876,37 @@ module Mistri
347
876
  tool ? tool.needs_approval?(call.arguments) : false
348
877
  end
349
878
 
350
- def finished(message, usage)
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
+
894
+ def ends_turn?(call)
895
+ tool = @tools_by_name[call.name]
896
+ tool ? tool.ends_turn? : false
897
+ end
898
+
899
+ # A run stopped during its tool phase ends with a clean assistant
900
+ # message, so the message's stop reason alone would read :completed;
901
+ # the signal is what knows the user stopped it. handed_off marks a run
902
+ # an ends_turn tool ended, and only a genuinely completed one: a run
903
+ # aborted during that same tool phase handed nothing to anyone.
904
+ def finished(message, usage, signal = nil, handed_off: false)
351
905
  status = { StopReason::ABORTED => :aborted, StopReason::BUDGET => :budget,
352
906
  StopReason::ERROR => :error }.fetch(message.stop_reason, :completed)
353
- Result.new(message: message, status: status, usage: usage)
907
+ status = :aborted if status == :completed && signal&.aborted?
908
+ Result.new(message: message, status: status, usage: usage,
909
+ handed_off: handed_off && status == :completed)
354
910
  end
355
911
 
356
912
  def suspended(message, parked, usage)
@@ -367,35 +923,6 @@ module Mistri
367
923
  Result.new(message: message, status: :budget, usage: usage)
368
924
  end
369
925
 
370
- # Distinguishable from a parsed nil: JSON "null" is a valid value.
371
- PARSE_FAILED = Object.new.freeze
372
- private_constant :PARSE_FAILED
373
-
374
- def task_input(input, schema)
375
- "#{input}\n\nAnswer with ONLY a JSON value matching this schema:\n" \
376
- "#{JSON.generate(Schema.strict(schema))}"
377
- end
378
-
379
- def parse_output(text)
380
- body = text.to_s.strip
381
- body = body[/\A```(?:json)?\s*(.*?)```\z/m, 1] || body
382
- JSON.parse(body)
383
- rescue JSON::ParserError
384
- PARSE_FAILED
385
- end
386
-
387
- def task_errors(value, schema)
388
- return ["the answer is not valid JSON"] if value.equal?(PARSE_FAILED)
389
-
390
- Schema.violations(value, schema)
391
- end
392
-
393
- def fix_prompt(errors)
394
- lines = errors.map { |error| "- #{error}" }.join("\n")
395
- "Your answer did not satisfy the required output schema. Problems:\n" \
396
- "#{lines}\nReply with ONLY the corrected JSON."
397
- end
398
-
399
926
  def monotonic_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
400
- end
927
+ end # rubocop:enable Metrics/ClassLength
401
928
  end