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
@@ -12,7 +12,7 @@ module Mistri
12
12
  #
13
13
  # A store implements two methods: append(id, entry_hash) and load(id) ->
14
14
  # array of entry hashes in append order. Everything else lives here.
15
- class Session
15
+ class Session # rubocop:disable Metrics/ClassLength -- append-only ordering is the class contract
16
16
  attr_reader :id, :store
17
17
 
18
18
  def initialize(store:, id: nil)
@@ -37,11 +37,30 @@ module Mistri
37
37
 
38
38
  def entries = @store.load(@id)
39
39
 
40
+ # Every call ID is a session-wide correlation key. Read and validate the
41
+ # append-only history once, returning an owned set the loop can extend as
42
+ # it accepts later turns. Approval control entries are audited in the same
43
+ # ordered pass: they may only advance a prior assistant call.
44
+ def tool_control_state
45
+ audited = audit_history(entries)
46
+ approvals = audited.fetch(:approvals).map do |approval|
47
+ decision = approval[:decision] && own_json(approval[:decision])
48
+ { call: rebuild_call(approval[:call]), decision: }
49
+ end
50
+ { tool_call_ids: audited.fetch(:tool_call_ids), approvals: }
51
+ end
52
+
53
+ def tool_call_ids = tool_control_state.fetch(:tool_call_ids)
54
+
40
55
  # The conversation as the model replays it.
41
56
  def messages = replay.map(&:first)
42
57
 
43
58
  # What a run killed mid-tool answers in place of the result it never got.
44
- INTERRUPTED_RESULT = "[interrupted: the run stopped before this tool returned]"
59
+ INTERRUPTED_RESULT = "[interrupted: the run stopped before this result was persisted; " \
60
+ "the tool may have executed, so verify its effects before retrying]"
61
+ LEGACY_CALL_ID = /\Acall_[1-9]\d*\z/
62
+ LEGACY_CALL_PROVIDERS = %i[fake gemini].freeze
63
+ private_constant :LEGACY_CALL_ID, :LEGACY_CALL_PROVIDERS
45
64
 
46
65
  # Replay messages paired with the entry index each came from, starting at
47
66
  # the latest compaction boundary. The synthetic summary message carries a
@@ -51,15 +70,22 @@ module Mistri
51
70
  # brick every later turn with a provider rejection, so unsettled calls
52
71
  # get a synthesized interrupted result. Calls parked for human approval
53
72
  # stay open; resume owns those.
54
- def replay
73
+ def replay = replay_from(entries)
74
+
75
+ # Context accounting ignores provider usage reported before the latest
76
+ # compaction: those prompt counts describe the larger, pre-summary replay.
77
+ # Until a post-compaction assistant turn reports fresh usage, the compacted
78
+ # replay is estimated directly.
79
+ def context_tokens
55
80
  log = entries
56
- compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
57
- from = compaction ? compaction["kept_from"] : 0
58
- pairs = log.each_with_index.filter_map do |entry, index|
59
- [Message.from_h(entry["message"]), index] if index >= from && entry["type"] == "message"
60
- end
61
- pairs = heal(pairs, parked_call_ids(log))
62
- compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
81
+ replay = replay_from(log)
82
+ compacted_at = log.rindex { |entry| entry["type"] == "compaction" }
83
+ usage_from = if compacted_at
84
+ replay.index { |(_, index)| index && index > compacted_at } || replay.length
85
+ else
86
+ 0
87
+ end
88
+ Compaction.context_tokens(replay.map(&:first), usage_from:)
63
89
  end
64
90
 
65
91
  def last_compaction
@@ -83,11 +109,12 @@ module Mistri
83
109
  # folds into the transcript at the next turn boundary as a labeled block
84
110
  # the model can react to ("[Magpie finished] <report>"), while the typed
85
111
  # entry keeps name, status, and the raw text for hosts to render as a
86
- # report card rather than a fake user message. One report per child,
87
- # ever: a duplicate delivery (a redelivered queue job, a lease race) is
88
- # dropped, and the return says which happened. Reports normally arrive
89
- # via SubAgent.run_dispatched; call this directly only from a custom
90
- # dispatch path.
112
+ # report card rather than a fake user message. Sequential redelivery of
113
+ # one child's report is dropped, and the return says which happened.
114
+ # Concurrent callers need their own serialization; with a lock adapter,
115
+ # the background runner and inactive cancellation use the child's lease
116
+ # for that. Reports normally arrive via SubAgent.run_dispatched; call this
117
+ # directly only from a custom dispatch path.
91
118
  def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
92
119
  already = entries.any? do |entry|
93
120
  entry["type"] == "subagent_report" && entry["session_id"] == session_id
@@ -124,7 +151,8 @@ module Mistri
124
151
  entries.filter_map do |entry|
125
152
  next unless entry["type"] == "subagent"
126
153
 
127
- Child.new(name: entry["name"], session_id: entry["session_id"], store: @store)
154
+ Child.new(name: entry["name"], session_id: entry["session_id"], store: @store,
155
+ parent_session_id: @id)
128
156
  end
129
157
  end
130
158
 
@@ -149,45 +177,490 @@ module Mistri
149
177
  # Parked tool calls not yet settled by a tool result, each with its
150
178
  # decision when one has been recorded. Derived from the entry log alone,
151
179
  # so it survives crashes and reads the same from every process.
152
- def open_approvals
153
- answered = []
154
- decisions = {}
155
- requests = []
156
- entries.each do |entry|
157
- case entry["type"]
158
- when "message"
159
- call_id = entry.dig("message", "tool_call_id")
160
- answered << call_id if call_id
161
- when "approval_request" then requests << entry["call"]
162
- when "approval_decision" then decisions[entry["call_id"]] = entry
180
+ def open_approvals = tool_control_state.fetch(:approvals)
181
+
182
+ private
183
+
184
+ def tool_calls_from(entry)
185
+ Array(entry.dig("message", "content")).select do |block|
186
+ block.is_a?(Hash) && block["type"].to_s == "tool_call"
187
+ end
188
+ end
189
+
190
+ def record_assistant_calls(entry, message, index, audit)
191
+ calls = audit.fetch(:calls)
192
+ unresolved = audit.fetch(:unresolved)
193
+ reserved = audit.fetch(:reserved)
194
+ batch = { calls: [], provider_call_ids: {} }
195
+ parsed_calls = message.tool_calls
196
+ tool_calls_from(entry).each_with_index do |call, position|
197
+ id = validated_persisted_call(call)
198
+ source_call = parsed_calls.fetch(position)
199
+ reused = reused_legacy_call(id, source_call, message.provider, calls, reserved)
200
+ owned = id.dup.freeze
201
+ provider_id = source_call.provider_call_id
202
+ if provider_id && batch[:provider_call_ids].key?(provider_id)
203
+ raise ConfigurationError, "session contains a duplicate provider tool call ID"
163
204
  end
205
+
206
+ batch[:provider_call_ids][provider_id] = true if provider_id
207
+ state = { source: call, source_call:,
208
+ source_provider: message.provider,
209
+ source_stop_reason: message.stop_reason,
210
+ source_index: index, source_position: position,
211
+ batch:, status: :pending, reused: !reused.nil? }
212
+ calls[owned] = state
213
+ batch[:calls] << state
214
+ audit.fetch(:states) << state
215
+ unresolved << owned
216
+ reserved << owned
164
217
  end
165
- requests.reject { |call| answered.include?(call["id"]) }
166
- .map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
167
218
  end
168
219
 
169
- private
220
+ # Gemini and Fake once synthesized call_N from a per-turn counter. Only
221
+ # that known shape without provider correlation IDs may repeat; widening
222
+ # the exception would turn the session correlation key back into a guess.
223
+ def reused_legacy_call(id, source_call, provider, calls, reserved)
224
+ return nil unless reserved.include?(id)
225
+
226
+ prior = calls[id]
227
+ safe_status = prior && %i[answered interrupted].include?(prior[:status])
228
+ legacy_shape = LEGACY_CALL_ID.match?(id) && LEGACY_CALL_PROVIDERS.include?(provider) &&
229
+ LEGACY_CALL_PROVIDERS.include?(prior&.fetch(:source_provider, nil)) &&
230
+ source_call.provider_call_id.nil? &&
231
+ prior&.fetch(:source_call)&.provider_call_id.nil?
232
+ unless safe_status && legacy_shape
233
+ raise ConfigurationError, "session contains a duplicate tool call ID"
234
+ end
235
+
236
+ prior
237
+ end
238
+
239
+ def audit_history(log)
240
+ calls = {}
241
+ unresolved = Set.new
242
+ reserved = Set.new
243
+ states = []
244
+ approval_ids = Set.new
245
+ audit = { calls:, unresolved:, reserved:, states:, approval_ids: }
246
+ latest_compaction = nil
247
+
248
+ log.each_with_index do |entry, index|
249
+ unless entry.is_a?(Hash)
250
+ raise ConfigurationError, "session contains an entry that is not an object"
251
+ end
252
+
253
+ message = validate_message_entry!(entry) if entry["type"] == "message"
254
+
255
+ if message&.tool?
256
+ record_tool_result(entry, index, calls, unresolved)
257
+ elsif message
258
+ close_unsettled_calls(calls, unresolved)
259
+ record_assistant_calls(entry, message, index, audit) if message.assistant?
260
+ elsif entry["type"] == "approval_request"
261
+ record_approval_call(entry, calls, audit)
262
+ elsif entry["type"] == "approval_decision"
263
+ record_approval_decision(entry, calls)
264
+ elsif entry["type"] == "compaction"
265
+ latest_compaction = entry
266
+ end
267
+ end
268
+
269
+ interrupt_ambiguous_reused_approvals!(states)
270
+ approvals = open_approval_states(states)
271
+ validate_compaction!(audit, approvals, latest_compaction)
272
+ { tool_call_ids: reserved, approvals:, call_states: calls }
273
+ end
274
+
275
+ # Control entries can authorize side effects, so every message in the
276
+ # same durable history must deserialize before any approval is exposed.
277
+ def validate_message_entry!(entry)
278
+ Message.from_h(entry["message"])
279
+ rescue StandardError => e
280
+ raise ConfigurationError,
281
+ "session contains an invalid persisted message (#{e.class.name})"
282
+ end
283
+
284
+ def record_tool_result(entry, index, calls, unresolved)
285
+ id = entry.dig("message", "tool_call_id")
286
+ problem = required_string_problem(id, "tool result call IDs")
287
+ raise ConfigurationError, "session contains #{problem}" if problem
288
+
289
+ state = calls[id]
290
+ unless state
291
+ raise ConfigurationError, "session contains a tool result without a prior assistant " \
292
+ "tool call"
293
+ end
294
+
295
+ name = entry.dig("message", "tool_name")
296
+ problem = required_string_problem(name, "tool result names")
297
+ raise ConfigurationError, "session contains #{problem}" if problem
298
+ unless name == state[:source]["name"]
299
+ raise ConfigurationError, "session contains a tool result whose name does not match " \
300
+ "its assistant tool call"
301
+ end
302
+
303
+ case state[:status]
304
+ when :pending
305
+ if state[:batch][:approval_phase_started]
306
+ raise ConfigurationError, "session contains a direct tool result after approval " \
307
+ "settlement began"
308
+ end
309
+ advance_call_order!(state, :direct_result_position, "tool results")
310
+ state[:status] = :answered
311
+ state[:result_index] = index
312
+ unresolved.delete(id)
313
+ when :decided
314
+ advance_call_order!(state, :approval_result_position, "approval tool results")
315
+ state[:status] = :answered
316
+ state[:result_index] = index
317
+ unresolved.delete(id)
318
+ when :approval_requested
319
+ raise ConfigurationError, "session contains a tool result for an approval without a " \
320
+ "prior decision"
321
+ when :answered
322
+ raise ConfigurationError, "session contains a duplicate tool result"
323
+ when :interrupted
324
+ raise ConfigurationError, "session contains a late tool result for a crash-healed call"
325
+ end
326
+ end
327
+
328
+ def advance_call_order!(state, key, label)
329
+ previous = state[:batch][key]
330
+ if previous && state[:source_position] < previous
331
+ raise ConfigurationError, "session contains #{label} out of assistant call order"
332
+ end
333
+
334
+ state[:batch][key] = state[:source_position]
335
+ end
336
+
337
+ def close_unsettled_calls(calls, unresolved)
338
+ unresolved.each do |id|
339
+ state = calls.fetch(id)
340
+ if state[:ambiguous_approval] &&
341
+ %i[approval_requested decided].include?(state[:status])
342
+ state[:status] = :interrupted
343
+ next
344
+ end
345
+ unless state[:status] == :pending
346
+ raise ConfigurationError, "session continues past an unsettled approval"
347
+ end
348
+
349
+ state[:status] = :interrupted
350
+ end
351
+ unresolved.clear
352
+ end
353
+
354
+ def record_approval_call(entry, calls, audit)
355
+ call = entry["call"]
356
+ unless call.is_a?(Hash)
357
+ raise ConfigurationError, "session contains an invalid approval request"
358
+ end
359
+
360
+ id = validated_persisted_call(call)
361
+ state = calls[id]
362
+ unless state
363
+ raise ConfigurationError, "session contains an approval request without a prior " \
364
+ "assistant tool call"
365
+ end
366
+ if state[:status] == :answered
367
+ raise ConfigurationError, "session contains an approval request for an already " \
368
+ "answered tool call"
369
+ end
370
+ if state[:status] == :interrupted
371
+ raise ConfigurationError, "session contains a stale approval request for a " \
372
+ "crash-healed tool call"
373
+ end
374
+ unless state[:status] == :pending
375
+ raise ConfigurationError, "session contains a duplicate approval request ID"
376
+ end
377
+ unless state[:source_stop_reason] == StopReason::TOOL_USE
378
+ raise ConfigurationError, "session contains an approval request for an assistant turn " \
379
+ "that did not stop for tool use"
380
+ end
381
+ validate_gemini_approval_order!(state)
382
+ validate_approval_provenance!(entry, call, state)
383
+
384
+ advance_call_order!(state, :approval_request_position, "approval requests")
385
+ state[:batch][:approval_phase_started] = true
386
+ state[:status] = :approval_requested
387
+ state[:approval] = { call:, decision: nil, source_index: state[:source_index] }
388
+ state[:ambiguous_approval] = state[:reused] && audit.fetch(:approval_ids).include?(id)
389
+ audit.fetch(:approval_ids) << id
390
+ end
391
+
392
+ def validate_approval_provenance!(entry, call, state)
393
+ if entry["prepared_from"] == "assistant"
394
+ validate_normalizable_source!(state)
395
+ validate_prepared_call!(call, state[:source])
396
+ elsif entry.key?("prepared_from")
397
+ raise ConfigurationError, "session contains invalid approval provenance"
398
+ elsif entry.key?("source_call")
399
+ validate_approval_source!(entry["source_call"], call, state)
400
+ elsif !approval_mirrors?(state[:source], call)
401
+ raise ConfigurationError, "session contains an approval request that does not " \
402
+ "match its assistant tool call"
403
+ end
404
+ end
405
+
406
+ # Pre-ID Gemini calls pair same-name results by position. Once a later
407
+ # sibling has answered, resuming an earlier side effect would make replay
408
+ # ambiguous even when the durable history predates the live-run guard.
409
+ def validate_gemini_approval_order!(state)
410
+ source = state[:source_call]
411
+ return unless state[:source_provider] == :gemini && source.provider_call_id.nil?
412
+
413
+ unsafe = state[:batch][:calls].any? do |sibling|
414
+ sibling[:source_position] > state[:source_position] && sibling[:status] == :answered &&
415
+ sibling[:source_call].provider_call_id.nil? && sibling[:source_call].name == source.name
416
+ end
417
+ return unless unsafe
418
+
419
+ raise ConfigurationError, "session contains an ambiguous Gemini approval after a later " \
420
+ "same-name tool result"
421
+ end
422
+
423
+ def record_approval_decision(entry, calls)
424
+ id = entry["call_id"]
425
+ problem = required_string_problem(id, "approval decision call IDs")
426
+ raise ConfigurationError, "session contains #{problem}" if problem
427
+
428
+ unless entry["approved"].equal?(true) || entry["approved"].equal?(false)
429
+ raise ConfigurationError, "session contains an approval decision whose approved " \
430
+ "value is not true or false"
431
+ end
432
+
433
+ state = calls[id]
434
+ unless state && state[:approval]
435
+ raise ConfigurationError, "session contains an approval decision without a prior " \
436
+ "matching approval request"
437
+ end
438
+ # Store order is the write-once register. A stale loser can append after
439
+ # execution, so it must not revoke the winner or corrupt the real result.
440
+ return if state[:approval][:decision]
441
+
442
+ state[:approval][:decision] = entry
443
+ state[:status] = :decided
444
+ end
445
+
446
+ # An old decision names only call_N. Once that ID has already participated
447
+ # in approval, a later unsettled approval cannot prove which generation a
448
+ # delayed decision intended, so replay it as interrupted rather than risk
449
+ # executing a side effect under stale authorization.
450
+ def interrupt_ambiguous_reused_approvals!(states)
451
+ states.each do |state|
452
+ if state[:ambiguous_approval] && %i[approval_requested decided].include?(state[:status])
453
+ state[:status] = :interrupted
454
+ end
455
+ end
456
+ end
457
+
458
+ def open_approval_states(states)
459
+ states.filter_map do |state|
460
+ state[:approval] if %i[approval_requested decided].include?(state[:status])
461
+ end
462
+ end
170
463
 
171
- def heal(pairs, parked)
172
- answered = pairs.map(&:first).select(&:tool?).to_set(&:tool_call_id)
464
+ def validate_compaction!(audit, approvals, compaction)
465
+ return unless compaction
466
+
467
+ kept_from = compaction["kept_from"]
468
+ unless kept_from.is_a?(Integer) && kept_from >= 0
469
+ raise ConfigurationError, "session contains an invalid compaction boundary"
470
+ end
471
+ if approvals.any? { |approval| approval[:source_index] < kept_from }
472
+ raise ConfigurationError, "session contains an open approval whose assistant tool call " \
473
+ "was removed by compaction"
474
+ end
475
+ split = audit.fetch(:states).any? do |state|
476
+ state[:result_index] && state[:source_index] < kept_from &&
477
+ state[:result_index] >= kept_from
478
+ end
479
+ return unless split
480
+
481
+ raise ConfigurationError, "session contains a compaction boundary that splits a tool call " \
482
+ "from its result"
483
+ end
484
+
485
+ def validate_approval_source!(source, prepared, state)
486
+ unless source.is_a?(Hash)
487
+ raise ConfigurationError, "session contains an invalid approval source call"
488
+ end
489
+
490
+ validated_persisted_call(source)
491
+ unless state[:source] && approval_mirrors?(state[:source], source)
492
+ raise ConfigurationError, "session contains an approval source that does not " \
493
+ "match its assistant tool call"
494
+ end
495
+ return if approval_mirrors?(prepared, source)
496
+
497
+ validate_normalizable_source!(state)
498
+ return if prepared_call_from_source?(prepared, source)
499
+
500
+ raise ConfigurationError, "session contains prepared approval metadata that does not " \
501
+ "match its source call"
502
+ end
503
+
504
+ def validate_normalizable_source!(state)
505
+ source = state[:source_call]
506
+ return if source && !source.arguments_error? && source.arguments.is_a?(Hash)
507
+
508
+ raise ConfigurationError, "session contains an approval source that could not have " \
509
+ "been normalized"
510
+ end
511
+
512
+ def validate_prepared_call!(prepared, assistant)
513
+ return if prepared_call_from_source?(prepared, assistant)
514
+
515
+ raise ConfigurationError, "session contains prepared approval metadata that does not " \
516
+ "match its assistant tool call"
517
+ end
518
+
519
+ def validated_persisted_call(call)
520
+ unless call["type"].to_s == "tool_call"
521
+ raise ConfigurationError, "session contains an invalid tool call type"
522
+ end
523
+
524
+ problem = required_string_problem(call["id"], "tool call IDs")
525
+ raise ConfigurationError, "session contains #{problem}" if problem
526
+
527
+ problem = required_string_problem(call["name"], "tool call names")
528
+ raise ConfigurationError, "session contains #{problem}" if problem
529
+
530
+ validate_optional_string!(call["signature"], "tool call signatures")
531
+ validate_optional_string!(call["provider_call_id"], "provider tool call IDs")
532
+ call["id"]
533
+ end
534
+
535
+ def validate_optional_string!(value, label)
536
+ return if value.nil?
537
+
538
+ problem = required_string_problem(value, label)
539
+ raise ConfigurationError, "session contains #{problem}" if problem
540
+ end
541
+
542
+ def required_string_problem(value, label)
543
+ return "#{label} with no value" if value.nil?
544
+ return "#{label} that are not strings" unless value.is_a?(String)
545
+ unless value.encoding == Encoding::UTF_8 && value.valid_encoding?
546
+ return "#{label} that are not valid UTF-8"
547
+ end
548
+ return "#{label} that are blank" if value.match?(/\A[[:space:]]*\z/)
549
+
550
+ nil
551
+ end
552
+
553
+ def approval_mirrors?(assistant, approval)
554
+ assistant == approval
555
+ end
556
+
557
+ def prepared_call_from_source?(prepared, source)
558
+ %w[id name signature provider_call_id].all? do |field|
559
+ persisted_call_field(prepared, field) == persisted_call_field(source, field)
560
+ end
561
+ end
562
+
563
+ def persisted_call_field(call, field)
564
+ call[field]
565
+ end
566
+
567
+ def replay_from(log)
568
+ compaction = log.reverse_each.find { |entry| entry["type"] == "compaction" }
569
+ from = compaction ? compaction["kept_from"] : 0
570
+ pairs = log.each_with_index.filter_map do |entry, index|
571
+ next unless index >= from && entry["type"] == "message"
572
+
573
+ [Message.from_h(entry["message"]), index]
574
+ end
575
+ pairs = heal(pairs, replay_call_states(log, from:))
576
+ compaction ? [[summary_message(compaction["summary"]), nil], *pairs] : pairs
577
+ end
578
+
579
+ # Replay only needs occurrence pairing, not the authorization audit. Keep
580
+ # it tolerant enough to render a rejected history while still ensuring a
581
+ # reused call_N cannot borrow an earlier result or approval.
582
+ def replay_call_states(log, from:)
583
+ replay = { active: {}, seen: Set.new, approval_ids: Set.new, states: [], from: }
584
+ log.each_with_index do |entry, index|
585
+ if entry["type"] == "message"
586
+ track_replay_message(entry, index, replay)
587
+ else
588
+ track_replay_control(entry, replay)
589
+ end
590
+ end
591
+ replay.fetch(:states).each do |state|
592
+ next unless state[:ambiguous_approval]
593
+ next unless %i[approval_requested decided].include?(state[:status])
594
+
595
+ state[:status] = :interrupted
596
+ end
597
+ end
598
+
599
+ def track_replay_message(entry, index, replay)
600
+ role = entry.dig("message", "role").to_s
601
+ if role == "assistant"
602
+ tool_calls_from(entry).each_with_index do |call, position|
603
+ id = call["id"]
604
+ state = { source_index: index, source_position: position, status: :pending,
605
+ reused: replay.fetch(:seen).include?(id), approval: false }
606
+ replay.fetch(:states) << state if index >= replay.fetch(:from)
607
+ replay.fetch(:active)[id] = state
608
+ replay.fetch(:seen) << id
609
+ end
610
+ elsif role == "tool" &&
611
+ (state = replay.fetch(:active).delete(entry.dig("message", "tool_call_id")))
612
+ state[:status] = :answered
613
+ state[:result_index] = index
614
+ end
615
+ end
616
+
617
+ def track_replay_control(entry, replay)
618
+ if entry["type"] == "approval_request"
619
+ id = entry.dig("call", "id")
620
+ return unless (state = replay.fetch(:active)[id])
621
+
622
+ state[:status] = :approval_requested
623
+ state[:approval] = true
624
+ state[:ambiguous_approval] = state[:reused] && replay.fetch(:approval_ids).include?(id)
625
+ replay.fetch(:approval_ids) << id
626
+ elsif entry["type"] == "approval_decision"
627
+ state = replay.fetch(:active)[entry["call_id"]]
628
+ state[:status] = :decided if state&.dig(:status) == :approval_requested
629
+ end
630
+ end
631
+
632
+ def heal(pairs, states)
633
+ by_source = states.group_by { |state| state[:source_index] }
634
+ by_result = pairs.to_h { |message, index| [index, [message, index]] }
635
+ consumed = states.filter_map { |state| state[:result_index] }.to_set
173
636
  pairs.flat_map do |message, index|
174
- dangling = if message.assistant?
175
- message.tool_calls.reject do |call|
176
- answered.include?(call.id) || parked.include?(call.id)
177
- end
178
- else
179
- []
180
- end
181
- [[message, index]] + dangling.map do |call|
182
- [Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
183
- tool_name: call.name), nil]
637
+ next [] if consumed.include?(index)
638
+
639
+ batch = by_source[index]
640
+ next [[message, index]] unless message.assistant? && batch
641
+
642
+ [[message, index], *healed_results(message, batch, by_result)]
643
+ end
644
+ end
645
+
646
+ def healed_results(message, states, by_result)
647
+ calls = message.tool_calls
648
+ [false, true].flat_map do |approval_phase|
649
+ states.select { |state| state[:approval] == approval_phase }
650
+ .sort_by { |state| state[:source_position] }
651
+ .filter_map do |state|
652
+ if state[:status] == :answered
653
+ by_result.fetch(state[:result_index])
654
+ elsif !approval_phase || state[:status] == :interrupted
655
+ interrupted_pair(calls.fetch(state[:source_position]))
656
+ end
184
657
  end
185
658
  end
186
659
  end
187
660
 
188
- def parked_call_ids(log)
189
- log.filter_map { |entry| entry.dig("call", "id") if entry["type"] == "approval_request" }
190
- .to_set
661
+ def interrupted_pair(call)
662
+ [Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
663
+ tool_name: call.name, tool_error: true), nil]
191
664
  end
192
665
 
193
666
  def summary_message(summary)
@@ -228,18 +701,57 @@ module Mistri
228
701
  end
229
702
 
230
703
  def decide(call_id, approved:, note:)
231
- unless open_approvals.any? { |open| open[:call].id == call_id }
704
+ open = open_approvals.find { |approval| approval[:call].id == call_id }
705
+ unless open
706
+ winner = recorded_approval_decision(call_id)
707
+ return nil if winner && winner["approved"] == approved
708
+ if winner
709
+ raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
710
+ end
711
+
232
712
  raise ConfigurationError, "no open approval for #{call_id.inspect}"
233
713
  end
234
714
 
715
+ if open[:decision]
716
+ return nil if open[:decision]["approved"] == approved
717
+
718
+ raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
719
+ end
720
+
235
721
  entry = { "call_id" => call_id, "approved" => approved }
236
722
  entry["note"] = note if note
237
723
  append("approval_decision", entry)
724
+
725
+ # The two-method Store contract cannot combine the precheck and append.
726
+ # Re-read its total order so a racing conflicting caller learns it lost.
727
+ winner = recorded_approval_decision(call_id)
728
+ return nil if winner && winner["approved"] == approved
729
+
730
+ raise ConfigurationError, "approval for #{call_id.inspect} has already been decided"
731
+ end
732
+
733
+ def recorded_approval_decision(call_id)
734
+ state = audit_history(entries).fetch(:call_states)[call_id]
735
+ return nil if state&.fetch(:ambiguous_approval, false)
736
+
737
+ state&.dig(:approval, :decision)
738
+ end
739
+
740
+ def own_json(value)
741
+ case value
742
+ when Hash then value.to_h { |key, nested| [own_json(key), own_json(nested)] }
743
+ when Array then value.map { |nested| own_json(nested) }
744
+ when String then value.dup
745
+ else value
746
+ end
238
747
  end
239
748
 
240
749
  def rebuild_call(hash)
750
+ arguments = hash.key?("arguments") ? hash["arguments"] : {}
241
751
  ToolCall.new(id: hash["id"], name: hash["name"],
242
- arguments: hash["arguments"] || {}, signature: hash["signature"])
752
+ arguments:, signature: hash["signature"],
753
+ arguments_error: hash["arguments_error"],
754
+ provider_call_id: hash["provider_call_id"])
243
755
  end
244
- end
756
+ end # rubocop:enable Metrics/ClassLength
245
757
  end