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
@@ -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,21 +70,33 @@ 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
66
92
  entries.reverse_each.find { |entry| entry["type"] == "compaction" }
67
93
  end
68
94
 
95
+ # The inbox: entry types queued for the loop's next turn boundary, each
96
+ # mapped to the marker key its fold leaves on the consuming message
97
+ # entry.
98
+ INBOX = { "steer" => "steer_id", "subagent_report" => "report_id" }.freeze
99
+
69
100
  # Queue a message for a running exchange from any process. The loop folds
70
101
  # pending steers into the transcript at the next turn boundary, so the
71
102
  # model sees them mid-run; one that arrives as the model finishes cleanly
@@ -74,12 +105,66 @@ module Mistri
74
105
  append("steer", "id" => SecureRandom.uuid, "message" => Message.user(text).to_h)
75
106
  end
76
107
 
77
- # Steers not yet folded into the transcript, oldest first. The folding
78
- # message entry carries the steer id, so consumption is derived from the
79
- # log alone and reads the same from every process.
108
+ # A sub-agent's report, queued for this session the way a steer is: it
109
+ # folds into the transcript at the next turn boundary as a labeled block
110
+ # the model can react to ("[Magpie finished] <report>"), while the typed
111
+ # entry keeps name, status, and the raw text for hosts to render as a
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.
118
+ def deliver_report(name:, session_id:, status:, text: nil) # rubocop:disable Naming/PredicateMethod
119
+ already = entries.any? do |entry|
120
+ entry["type"] == "subagent_report" && entry["session_id"] == session_id
121
+ end
122
+ return false if already
123
+
124
+ append("subagent_report", "id" => SecureRandom.uuid, "name" => name,
125
+ "session_id" => session_id, "status" => status, "report" => text,
126
+ "message" => Message.user(report_label(name, status, text)).to_h)
127
+ true
128
+ end
129
+
130
+ # Everything queued for the loop's next turn boundary (steers and
131
+ # sub-agent reports), oldest first, in arrival order. The folding
132
+ # message entry carries the source entry's id under its marker key, so
133
+ # consumption is derived from the log alone and reads the same from
134
+ # every process. A host that wakes an idle session when a steer arrives
135
+ # should watch this instead: a report deserves the same pickup.
136
+ def pending_inbox
137
+ log = entries
138
+ folded = log.flat_map { |entry| entry.values_at(*INBOX.values) }.compact.to_set
139
+ log.select { |entry| INBOX.key?(entry["type"]) && !folded.include?(entry["id"]) }
140
+ end
141
+
142
+ # The steer-only slice of the inbox, oldest first.
80
143
  def pending_steers
81
- folded = entries.filter_map { |entry| entry["steer_id"] }
82
- entries.select { |entry| entry["type"] == "steer" && !folded.include?(entry["id"]) }
144
+ pending_inbox.select { |entry| entry["type"] == "steer" }
145
+ end
146
+
147
+ # Sub-agents this session has spawned, in spawn order: one Child window
148
+ # per link entry, each reading the child's own session. Derived from the
149
+ # log alone, like everything else here.
150
+ def children
151
+ entries.filter_map do |entry|
152
+ next unless entry["type"] == "subagent"
153
+
154
+ Child.new(name: entry["name"], session_id: entry["session_id"], store: @store,
155
+ parent_session_id: @id)
156
+ end
157
+ end
158
+
159
+ # The session as a reader renders it: entries in order with inline image
160
+ # bytes stripped, and, with include_children, every sub-agent's own
161
+ # transcript spliced in after its link entry. Spliced entries carry an
162
+ # "origin" key shaped exactly like the live stream's event origins
163
+ # ("Magpie#ab12cd34", nesting joined with ">"), so a UI that rebuilds
164
+ # from this sees what it saw live, lanes included, running children's
165
+ # progress-so-far included. The raw log stays available as #entries.
166
+ def transcript(include_children: false)
167
+ splice(entries, include_children: include_children, prefix: nil, seen: Set[@id])
83
168
  end
84
169
 
85
170
  # Record a human's decision on a parked tool call. Decisions are session
@@ -92,64 +177,581 @@ module Mistri
92
177
  # Parked tool calls not yet settled by a tool result, each with its
93
178
  # decision when one has been recorded. Derived from the entry log alone,
94
179
  # so it survives crashes and reads the same from every process.
95
- def open_approvals
96
- answered = []
97
- decisions = {}
98
- requests = []
99
- entries.each do |entry|
100
- case entry["type"]
101
- when "message"
102
- call_id = entry.dig("message", "tool_call_id")
103
- answered << call_id if call_id
104
- when "approval_request" then requests << entry["call"]
105
- 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"
106
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
107
217
  end
108
- requests.reject { |call| answered.include?(call["id"]) }
109
- .map { |call| { call: rebuild_call(call), decision: decisions[call["id"]] } }
110
218
  end
111
219
 
112
- 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
463
+
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
113
631
 
114
- def heal(pairs, parked)
115
- answered = pairs.map(&:first).select(&:tool?).to_set(&:tool_call_id)
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
116
636
  pairs.flat_map do |message, index|
117
- dangling = if message.assistant?
118
- message.tool_calls.reject do |call|
119
- answered.include?(call.id) || parked.include?(call.id)
120
- end
121
- else
122
- []
123
- end
124
- [[message, index]] + dangling.map do |call|
125
- [Message.tool(content: INTERRUPTED_RESULT, tool_call_id: call.id,
126
- 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
127
657
  end
128
658
  end
129
659
  end
130
660
 
131
- def parked_call_ids(log)
132
- log.filter_map { |entry| entry.dig("call", "id") if entry["type"] == "approval_request" }
133
- .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]
134
664
  end
135
665
 
136
666
  def summary_message(summary)
137
667
  Message.user("#{Compaction::SUMMARY_PREFACE}\n\n#{summary}")
138
668
  end
139
669
 
670
+ # Each link entry opens its child's log in place, depth first, the way
671
+ # nested lanes opened live. The seen set makes expansion idempotent per
672
+ # child (a repeated or self-referencing link renders but never expands
673
+ # twice), so a hostile log cannot loop this.
674
+ def splice(log, include_children:, prefix:, seen:)
675
+ log.flat_map do |entry|
676
+ rendered = Child.strip_images(entry)
677
+ rendered = rendered.merge("origin" => prefix) if prefix
678
+ next [rendered] unless include_children && expandable?(entry, seen)
679
+
680
+ seen << entry["session_id"]
681
+ origin = "#{entry["name"]}##{entry["session_id"][0, 8]}"
682
+ origin = "#{prefix}>#{origin}" if prefix
683
+ child_log = self.class.new(store: @store, id: entry["session_id"]).entries
684
+ [rendered, *splice(child_log, include_children: true, prefix: origin, seen: seen)]
685
+ end
686
+ end
687
+
688
+ def expandable?(entry, seen)
689
+ entry["type"] == "subagent" && !seen.include?(entry["session_id"])
690
+ end
691
+
692
+ # How a report reads to the model: labeled with the worker's name and
693
+ # fate, so the parent knows exactly who finished and how.
694
+ def report_label(name, status, text)
695
+ case status
696
+ when "done" then "[#{name} finished] #{text}"
697
+ when "failed" then "[#{name} failed] #{text}"
698
+ when "stopped" then "[#{name} was stopped]"
699
+ else "[#{name} ended: #{status}]"
700
+ end
701
+ end
702
+
140
703
  def decide(call_id, approved:, note:)
141
- 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
+
142
712
  raise ConfigurationError, "no open approval for #{call_id.inspect}"
143
713
  end
144
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
+
145
721
  entry = { "call_id" => call_id, "approved" => approved }
146
722
  entry["note"] = note if note
147
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
148
747
  end
149
748
 
150
749
  def rebuild_call(hash)
750
+ arguments = hash.key?("arguments") ? hash["arguments"] : {}
151
751
  ToolCall.new(id: hash["id"], name: hash["name"],
152
- 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"])
153
755
  end
154
- end
756
+ end # rubocop:enable Metrics/ClassLength
155
757
  end