phronomy 0.15.0 → 0.16.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -18
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -47
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -0,0 +1,1123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ class ExecutionCoordinator
8
+ Prepared = Data.define(:execution, :runtime_projection, :filtered_input, :config)
9
+ AgentTerminalCommand = Struct.new(:coordinator, :activation, :result_task, :outcome, :commit_error)
10
+ PrepFailureCommand = Struct.new(:coordinator, :on_event_listener, :result_task, :error)
11
+
12
+ def initialize(agent)
13
+ @agent = agent
14
+ end
15
+
16
+ def start(
17
+ input, thread_id: nil, config: {}, mode: :invoke,
18
+ approval_policy: nil, approval_listener: nil, on_event: nil
19
+ )
20
+ result_task = Phronomy::Task.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
21
+ runtime = Phronomy::Runtime.instance
22
+ preparation = runtime.blocking_io.submit do
23
+ prepare(input, thread_id: thread_id, config: config)
24
+ end
25
+ preparation.on_complete do |prepared, error|
26
+ if error
27
+ post_prep_failure(runtime, on_event, result_task, translated(error))
28
+ else
29
+ register(prepared, result_task, mode: mode,
30
+ approval_policy: approval_policy,
31
+ approval_listener: approval_listener,
32
+ on_event: on_event)
33
+ end
34
+ end
35
+ result_task
36
+ rescue => error
37
+ if defined?(runtime) && runtime
38
+ post_prep_failure(runtime, on_event, result_task, translated(error))
39
+ else
40
+ fail_task(result_task, translated(error))
41
+ end
42
+ result_task
43
+ end
44
+
45
+ def resume(
46
+ execution_id,
47
+ approval_request_id:,
48
+ approved:,
49
+ config: {}
50
+ )
51
+ result_task = Phronomy::Task.deferred(
52
+ name: "agent-approval-resume:#{execution_id}"
53
+ )
54
+ runtime = Phronomy::Runtime.instance
55
+ preparation = runtime.blocking_io.submit do
56
+ execution = @agent.persistence.executions.load(execution_id)
57
+ unless execution.agent_id == @agent.agent_id && execution.status == :suspended
58
+ raise ArgumentError,
59
+ "execution is not a suspended execution of this agent: #{execution_id}"
60
+ end
61
+
62
+ activation = @agent.persistence.activations.fetch(execution_id)
63
+ unless activation
64
+ raise Phronomy::ExecutionRehydrationRequiredError,
65
+ "no live activation for #{execution_id}; durable rehydration is required"
66
+ end
67
+
68
+ mark_resuming!(
69
+ execution_id,
70
+ approval_request_id: approval_request_id,
71
+ approved: approved
72
+ )
73
+ activation
74
+ end
75
+ preparation.on_complete do |activation, error|
76
+ if error
77
+ fail_task(result_task, translated(error))
78
+ next
79
+ end
80
+
81
+ begin
82
+ start_resume(
83
+ activation,
84
+ result_task,
85
+ approved: approved,
86
+ config: config
87
+ )
88
+ rescue => start_error
89
+ commit = runtime.blocking_io.submit do
90
+ commit_failed(activation, activation.invocation, start_error)
91
+ end
92
+ commit.on_complete do |terminal, commit_error|
93
+ if commit_error
94
+ fail_task(result_task, commit_error)
95
+ else
96
+ @agent.persistence.activations.delete(execution_id)
97
+ fail_task(result_task, terminal.fetch(:error))
98
+ end
99
+ end
100
+ end
101
+ end
102
+ result_task
103
+ rescue => error
104
+ fail_task(result_task, translated(error))
105
+ result_task
106
+ end
107
+
108
+ # Persists all Runtime events from the previous call, fixes the next
109
+ # Canonical LLM Input Manifest, and materializes the next Runtime Projection.
110
+ # This method is executed on the blocking adapter pool, never on EventLoop.
111
+ def prepare_next_llm_call(activation)
112
+ snapshot = activation.runtime_snapshot
113
+ manifest = manifest_ref = updated = root = nil
114
+
115
+ @agent.persistence.transaction do |tx|
116
+ root = tx.agents.load(@agent.agent_id)
117
+ current = tx.executions.load(activation.execution_id)
118
+ encoded_records, call_records = encode_runtime_records(
119
+ activation, tx: tx, snapshot: snapshot, context_candidate: true
120
+ )
121
+ staged = current.with(
122
+ execution_revision: current.execution_revision,
123
+ phase: :preparing_llm_call,
124
+ working_records: current.working_records + encoded_records,
125
+ llm_calls: current.llm_calls + call_records
126
+ )
127
+ invocation_config = activation.invocation&.config || {}
128
+ patch = @agent.send(
129
+ :run_before_llm_input_hooks,
130
+ call_sequence: staged.llm_calls.length + 1,
131
+ config: invocation_config
132
+ )
133
+ manifest, manifest_ref = ContextAssembler.new(
134
+ agent: @agent, persistence: tx, policy: context_policy_for(staged)
135
+ ).build_followup(
136
+ base_manifest: activation.base_manifest,
137
+ agent_root: root, execution: staged,
138
+ config: invocation_config, patch: patch
139
+ )
140
+ refs = Array(staged.metadata["manifest_refs"]) + [manifest_ref]
141
+ updated = staged.with(
142
+ phase: :calling_llm,
143
+ metadata: staged.metadata.merge(
144
+ "manifest_ref" => manifest_ref, "manifest_refs" => refs
145
+ )
146
+ )
147
+ tx.executions.save(current.execution_id,
148
+ expected_revision: current.execution_revision, execution: updated)
149
+ end
150
+
151
+ activation.replace_execution(updated)
152
+ activation.acknowledge_runtime_snapshot(snapshot)
153
+
154
+ projection = RubyLLMMaterializer.new(
155
+ agent: @agent, persistence: @agent.persistence
156
+ ).materialize(manifest: manifest, manifest_ref: manifest_ref)
157
+ activation.replace_runtime_projection(projection)
158
+ projection
159
+ end
160
+
161
+ def prepare(input, thread_id:, config:)
162
+ raw_message = @agent.send(:extract_message, input)
163
+ execution, active_root = admit_execution(raw_message, thread_id: thread_id)
164
+ @agent.__replace_root(active_root)
165
+
166
+ begin
167
+ effective_config = thread_id ? config.merge(thread_id: thread_id) : config
168
+ @agent.send(
169
+ :check_cancellation!,
170
+ effective_config,
171
+ "invocation cancelled before input filtering"
172
+ )
173
+ filtered_input = @agent.send(:run_input_filters!, input)
174
+ @agent.send(
175
+ :check_cancellation!,
176
+ effective_config,
177
+ "invocation cancelled before context assembly"
178
+ )
179
+ filtered_message = @agent.send(:extract_message, filtered_input)
180
+ manifest = nil
181
+ manifest_ref = nil
182
+ active_execution = nil
183
+
184
+ @agent.persistence.transaction do |tx|
185
+ root = tx.agents.load(@agent.agent_id)
186
+ current = tx.executions.load(execution.execution_id)
187
+ filtered_ref = tx.contents.put_text(filtered_message)
188
+ input_record = JournalRecord.new(
189
+ agent_id: @agent.agent_id,
190
+ execution_id: current.execution_id,
191
+ kind: :external_message,
192
+ channel: :external,
193
+ role: :user,
194
+ content_ref: filtered_ref,
195
+ correlation_id: thread_id,
196
+ context_generation: root.transcript_generation,
197
+ context_candidate: true
198
+ )
199
+ staged = current.with(
200
+ execution_revision: current.execution_revision,
201
+ working_records: current.working_records + [input_record],
202
+ metadata: current.metadata.merge(
203
+ "current_input_ref" => filtered_ref,
204
+ "current_input_record_id" => input_record.record_id
205
+ )
206
+ )
207
+ manifest, manifest_ref = ContextAssembler.new(
208
+ agent: @agent,
209
+ persistence: tx,
210
+ policy: context_policy_for(staged)
211
+ ).build_initial(
212
+ input: filtered_input,
213
+ agent_root: root,
214
+ execution: staged,
215
+ config: effective_config,
216
+ patch: @agent.send(
217
+ :run_before_llm_input_hooks,
218
+ call_sequence: 1,
219
+ config: effective_config
220
+ )
221
+ )
222
+ active_execution = staged.with(
223
+ status: :active,
224
+ phase: :calling_llm,
225
+ metadata: staged.metadata.merge(
226
+ "base_manifest_ref" => manifest_ref,
227
+ "manifest_ref" => manifest_ref,
228
+ "manifest_refs" => [manifest_ref]
229
+ )
230
+ )
231
+ tx.executions.save(
232
+ current.execution_id,
233
+ expected_revision: current.execution_revision,
234
+ execution: active_execution
235
+ )
236
+ end
237
+
238
+ projection = RubyLLMMaterializer.new(
239
+ agent: @agent,
240
+ persistence: @agent.persistence
241
+ ).materialize(manifest: manifest, manifest_ref: manifest_ref)
242
+ Prepared.new(
243
+ execution: active_execution,
244
+ runtime_projection: projection,
245
+ filtered_input: filtered_input,
246
+ config: effective_config
247
+ )
248
+ rescue => error
249
+ commit_preparation_failure(execution, error)
250
+ raise
251
+ end
252
+ end
253
+
254
+ def admit_execution(raw_message, thread_id:)
255
+ execution = nil
256
+ root = nil
257
+ @agent.persistence.transaction do |tx|
258
+ root = tx.agents.load(@agent.agent_id)
259
+ raise Phronomy::Error, "agent is closed: #{@agent.agent_id}" if root.lifecycle_status == :closed
260
+ input_ref = tx.contents.put_text(raw_message)
261
+ input_record = JournalRecord.new(
262
+ agent_id: @agent.agent_id,
263
+ kind: :input_received,
264
+ channel: :external,
265
+ role: :user,
266
+ content_ref: input_ref,
267
+ correlation_id: thread_id,
268
+ context_generation: root.transcript_generation,
269
+ context_candidate: false
270
+ )
271
+ policy_descriptor = ContextPolicies::Default.new.descriptor
272
+ execution = AgentExecution.start(
273
+ agent_root: root,
274
+ input_record: input_record,
275
+ metadata: {
276
+ "thread_id" => thread_id,
277
+ "current_input_ref" => input_ref,
278
+ "context_policy" => policy_descriptor.to_h
279
+ }.compact
280
+ )
281
+ input_record = JournalRecord.from_h(
282
+ input_record.to_h.merge("execution_id" => execution.execution_id)
283
+ )
284
+ execution = execution.with(
285
+ execution_revision: 0,
286
+ working_records: [input_record]
287
+ )
288
+ tx.executions.create_active(execution)
289
+ next_root = root.with(
290
+ agent_revision: root.agent_revision + 1,
291
+ lifecycle_status: :active
292
+ )
293
+ tx.agents.save(root.agent_id, expected_revision: root.agent_revision, root: next_root)
294
+ root = next_root
295
+ end
296
+ [execution, root]
297
+ end
298
+
299
+ def commit_preparation_failure(execution, error)
300
+ translated_error = translated(error)
301
+ root = nil
302
+ @agent.persistence.transaction do |tx|
303
+ current = tx.executions.load(execution.execution_id)
304
+ root = tx.agents.load(@agent.agent_id)
305
+ error_ref = tx.contents.put_json(
306
+ "class" => translated_error.class.name,
307
+ "message" => translated_error.message
308
+ )
309
+ audit_records = current.working_records.map do |record|
310
+ JournalRecord.from_h(record.to_h.merge("context_candidate" => false))
311
+ end
312
+ terminal_status = terminal_status_for(translated_error)
313
+ audit_records << JournalRecord.new(
314
+ agent_id: @agent.agent_id,
315
+ execution_id: current.execution_id,
316
+ kind: execution_terminal_kind(terminal_status),
317
+ channel: :audit,
318
+ content_ref: error_ref,
319
+ context_generation: root.transcript_generation,
320
+ context_candidate: false
321
+ )
322
+ appended = tx.journals.append(
323
+ root.agent_id,
324
+ expected_position: root.journal_position,
325
+ records: audit_records
326
+ )
327
+ failed = current.with(
328
+ status: terminal_status,
329
+ phase: terminal_status,
330
+ working_records: [],
331
+ error_ref: error_ref,
332
+ terminal_reason: translated_error.class.name
333
+ )
334
+ tx.executions.save(
335
+ current.execution_id,
336
+ expected_revision: current.execution_revision,
337
+ execution: failed
338
+ )
339
+ next_root = root.with(
340
+ agent_revision: root.agent_revision + 1,
341
+ journal_position: root.journal_position + appended.length,
342
+ lifecycle_status: :idle
343
+ )
344
+ tx.agents.save(root.agent_id, expected_revision: root.agent_revision, root: next_root)
345
+ root = next_root
346
+ end
347
+ @agent.__replace_root(root)
348
+ end
349
+
350
+ def register(prepared, result_task, mode:, approval_policy:, approval_listener:, on_event:)
351
+ activation = AgentExecutionActivation.new(
352
+ execution: prepared.execution,
353
+ agent: @agent,
354
+ runtime_projection: prepared.runtime_projection,
355
+ coordinator: self,
356
+ application_listener: on_event
357
+ )
358
+ @agent.persistence.activations.register(activation)
359
+ runtime = Phronomy::Runtime.instance
360
+ event_loop = runtime.event_loop
361
+ effective_config = prepared.config.merge(
362
+ phronomy_activation: activation,
363
+ phronomy_runtime_projection: prepared.runtime_projection,
364
+ phronomy_filtered_input: prepared.filtered_input,
365
+ execution_id: prepared.execution.execution_id
366
+ )
367
+ session = Agent::AgentInvocationSessionBuilder.build(
368
+ agent: @agent,
369
+ input: prepared.filtered_input,
370
+ messages: prepared.runtime_projection.messages,
371
+ config: effective_config,
372
+ approval_policy: approval_policy,
373
+ approval_listener: approval_listener,
374
+ mode: mode,
375
+ on_event: ->(event) { activation.record_event(event) },
376
+ runtime: runtime
377
+ )
378
+ activation.invocation = session.context
379
+ activation.session = session
380
+ source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
381
+ source_task.on_complete do |invocation, error|
382
+ finish(
383
+ activation,
384
+ result_task,
385
+ invocation || session.context,
386
+ error
387
+ )
388
+ end
389
+ event_loop.register(session, completion: source_task)
390
+ rescue => error
391
+ activation ||= AgentExecutionActivation.new(
392
+ execution: prepared.execution, agent: @agent,
393
+ runtime_projection: prepared.runtime_projection, coordinator: self,
394
+ application_listener: on_event
395
+ )
396
+ finish(activation, result_task, activation.invocation, error)
397
+ end
398
+
399
+ def finish(activation, result_task, invocation, error)
400
+ runtime = Phronomy::Runtime.instance
401
+ operation = runtime.blocking_io.submit do
402
+ compute_terminal(activation, invocation, error)
403
+ end
404
+ operation.on_complete do |outcome, commit_error|
405
+ command = AgentTerminalCommand.new(
406
+ self, activation, result_task, outcome, commit_error
407
+ )
408
+ posted = runtime.event_loop.post(
409
+ Phronomy::Event.new(
410
+ type: :agent_terminal_ready,
411
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
412
+ payload: {command: command}
413
+ )
414
+ )
415
+ settle_without_listener(activation, result_task, outcome, commit_error) unless posted
416
+ end
417
+ rescue => caught
418
+ fail_task(result_task, translated(caught))
419
+ end
420
+
421
+ public
422
+
423
+ def deliver_on_event_loop(command)
424
+ case command
425
+ when PrepFailureCommand
426
+ callback_error = @agent.send(
427
+ :_deliver_stream_event,
428
+ command.on_event_listener,
429
+ StreamEvent.new(
430
+ type: terminal_event_type(command.error),
431
+ payload: {error: command.error}
432
+ )
433
+ )
434
+ settle_after_terminal(
435
+ command.result_task, callback_error,
436
+ terminal_event_type(command.error), nil, command.error
437
+ )
438
+ when AgentTerminalCommand
439
+ deliver_execution_terminal(command)
440
+ else
441
+ raise Phronomy::Error, "unknown terminal command: #{command.class}"
442
+ end
443
+ end
444
+
445
+ private
446
+
447
+ def compute_terminal(activation, invocation, error)
448
+ if (failure = activation.callback_failure)
449
+ terminal = commit_failed(activation, invocation, failure.to_stream_callback_error)
450
+ return {type: :failed, error: terminal.fetch(:error)}
451
+ end
452
+ if error
453
+ terminal = commit_failed(activation, invocation, error)
454
+ return {type: :failed, error: terminal.fetch(:error)}
455
+ end
456
+ if invocation&.phase == :suspended
457
+ return {type: :suspended, result: commit_suspended(activation, invocation)}
458
+ end
459
+ raise invocation.block_error if invocation.input_blocked? || invocation.output_blocked?
460
+ raise invocation.error if invocation.error
461
+ {type: :completed, result: commit_completed(activation, invocation)}
462
+ rescue => caught
463
+ terminal = commit_failed(activation, invocation, caught)
464
+ {type: :failed, error: terminal.fetch(:error)}
465
+ end
466
+
467
+ def commit_suspended(activation, invocation)
468
+ execution = activation.execution
469
+ request = invocation.approval_request
470
+ runtime_snapshot = activation.runtime_snapshot
471
+ suspended = nil
472
+ root = nil
473
+ @agent.persistence.transaction do |tx|
474
+ encoded_records, call_records = encode_runtime_records(
475
+ activation, tx: tx, snapshot: runtime_snapshot, context_candidate: true
476
+ )
477
+ root = tx.agents.load(@agent.agent_id)
478
+ current = tx.executions.load(execution.execution_id)
479
+ request_ref = tx.contents.put_json(json_value(request.to_h))
480
+ approval_record = JournalRecord.new(
481
+ agent_id: @agent.agent_id,
482
+ execution_id: current.execution_id,
483
+ kind: :approval_required,
484
+ channel: :approval,
485
+ content_ref: request_ref,
486
+ correlation_id: request.id.to_s,
487
+ context_generation: root.transcript_generation,
488
+ context_candidate: false
489
+ )
490
+ suspended = current.with(
491
+ status: :suspended,
492
+ phase: :approval,
493
+ working_records: current.working_records + encoded_records + [approval_record],
494
+ llm_calls: current.llm_calls + call_records,
495
+ approval_request: json_value(request.to_h)
496
+ )
497
+ tx.executions.save(
498
+ current.execution_id,
499
+ expected_revision: current.execution_revision,
500
+ execution: suspended
501
+ )
502
+ next_root = root.with(
503
+ agent_revision: root.agent_revision + 1,
504
+ lifecycle_status: :suspended
505
+ )
506
+ tx.agents.save(root.agent_id, expected_revision: root.agent_revision, root: next_root)
507
+ root = next_root
508
+ end
509
+ activation.acknowledge_runtime_snapshot(runtime_snapshot)
510
+ activation.replace_execution(suspended)
511
+ @agent.__replace_root(root)
512
+ dispatch_approval_listener(invocation, request)
513
+ result_base(suspended, root).merge(
514
+ suspended: true,
515
+ approval_request: request
516
+ )
517
+ end
518
+
519
+ def commit_completed(activation, invocation)
520
+ execution = activation.execution
521
+ runtime_snapshot = activation.runtime_snapshot
522
+ completed = nil
523
+ root = nil
524
+ appended = nil
525
+
526
+ @agent.persistence.transaction do |tx|
527
+ encoded_records, call_records = encode_runtime_records(
528
+ activation, tx: tx, snapshot: runtime_snapshot, context_candidate: true
529
+ )
530
+ output_ref = tx.contents.put_text(invocation.output.to_s)
531
+ root = tx.agents.load(@agent.agent_id)
532
+ current = tx.executions.load(execution.execution_id)
533
+ final_output_record = JournalRecord.new(
534
+ agent_id: @agent.agent_id,
535
+ execution_id: current.execution_id,
536
+ kind: :final_output,
537
+ channel: :audit,
538
+ role: :assistant,
539
+ content_ref: output_ref,
540
+ context_generation: root.transcript_generation,
541
+ context_candidate: false
542
+ )
543
+ completed_record = JournalRecord.new(
544
+ agent_id: @agent.agent_id,
545
+ execution_id: current.execution_id,
546
+ kind: invocation.rejected ? :execution_rejected : :execution_completed,
547
+ channel: :audit,
548
+ content_ref: output_ref,
549
+ context_generation: root.transcript_generation,
550
+ context_candidate: false
551
+ )
552
+ all_records = current.working_records + encoded_records +
553
+ [final_output_record, completed_record]
554
+ appended = tx.journals.append(
555
+ root.agent_id,
556
+ expected_position: root.journal_position,
557
+ records: all_records
558
+ )
559
+ completed = current.with(
560
+ status: invocation.rejected ? :rejected : :completed,
561
+ phase: :completed,
562
+ working_records: [],
563
+ llm_calls: current.llm_calls + call_records,
564
+ approval_request: nil,
565
+ result_ref: output_ref,
566
+ terminal_reason: invocation.rejected ? "rejected" : "completed"
567
+ )
568
+ tx.executions.save(
569
+ current.execution_id,
570
+ expected_revision: current.execution_revision,
571
+ execution: completed
572
+ )
573
+ context_changed = appended.any?(&:context_candidate)
574
+ next_root = root.with(
575
+ agent_revision: root.agent_revision + 1,
576
+ context_revision: root.context_revision + (context_changed ? 1 : 0),
577
+ journal_position: root.journal_position + appended.length,
578
+ lifecycle_status: :idle
579
+ )
580
+ tx.agents.save(root.agent_id, expected_revision: root.agent_revision, root: next_root)
581
+ root = next_root
582
+ end
583
+ activation.acknowledge_runtime_snapshot(runtime_snapshot)
584
+ activation.replace_execution(completed)
585
+ @agent.__replace_root(root)
586
+ result_base(completed, root).merge(
587
+ output: invocation.output,
588
+ rejected: invocation.rejected || nil,
589
+ usage: invocation.usage,
590
+ messages: transcript_messages(root)
591
+ ).compact
592
+ end
593
+
594
+ def commit_failed(activation, _invocation, error)
595
+ translated_error = translated(error)
596
+ execution = activation.execution
597
+ runtime_snapshot = activation.runtime_snapshot
598
+ failed = nil
599
+ root = nil
600
+ @agent.persistence.transaction do |tx|
601
+ encoded_records, call_records = encode_runtime_records(
602
+ activation, tx: tx, snapshot: runtime_snapshot, context_candidate: false
603
+ )
604
+ error_ref = tx.contents.put_json(
605
+ "class" => translated_error.class.name,
606
+ "message" => translated_error.message
607
+ )
608
+ root = tx.agents.load(@agent.agent_id)
609
+ current = tx.executions.load(execution.execution_id)
610
+ audit_records = (current.working_records + encoded_records).map do |record|
611
+ JournalRecord.from_h(record.to_h.merge("context_candidate" => false))
612
+ end
613
+ terminal_status = terminal_status_for(translated_error)
614
+ audit_records << JournalRecord.new(
615
+ agent_id: @agent.agent_id,
616
+ execution_id: current.execution_id,
617
+ kind: execution_terminal_kind(terminal_status),
618
+ channel: :audit,
619
+ content_ref: error_ref,
620
+ context_generation: root.transcript_generation,
621
+ context_candidate: false
622
+ )
623
+ appended = tx.journals.append(
624
+ root.agent_id,
625
+ expected_position: root.journal_position,
626
+ records: audit_records
627
+ )
628
+ failed = current.with(
629
+ status: terminal_status,
630
+ phase: terminal_status,
631
+ working_records: [],
632
+ llm_calls: current.llm_calls + call_records,
633
+ error_ref: error_ref,
634
+ terminal_reason: translated_error.class.name
635
+ )
636
+ tx.executions.save(
637
+ current.execution_id,
638
+ expected_revision: current.execution_revision,
639
+ execution: failed
640
+ )
641
+ next_root = root.with(
642
+ agent_revision: root.agent_revision + 1,
643
+ journal_position: root.journal_position + appended.length,
644
+ lifecycle_status: :idle
645
+ )
646
+ tx.agents.save(root.agent_id, expected_revision: root.agent_revision, root: next_root)
647
+ root = next_root
648
+ end
649
+ activation.acknowledge_runtime_snapshot(runtime_snapshot)
650
+ activation.replace_execution(failed)
651
+ @agent.__replace_root(root)
652
+ {error: translated_error, execution: failed, root: root}
653
+ end
654
+
655
+ def encode_runtime_records(activation, tx:, snapshot:, context_candidate:)
656
+ execution = activation.execution
657
+ root = tx.agents.load(@agent.agent_id)
658
+ records = []
659
+ calls = []
660
+
661
+ snapshot.fetch(:llm_results).each_with_index do |item, index|
662
+ outcome = item[:response]
663
+ error = item[:error]
664
+ llm_call_id = item.fetch(:llm_call_id).to_s
665
+ intercepted = error.is_a?(ToolCallIntercepted)
666
+ call_error = intercepted ? nil : error
667
+ output_ref = assistant_output_ref(tx, outcome)
668
+ assistant_ref = assistant_message_ref(tx, outcome)
669
+ error_ref = call_error ? tx.contents.put_json(
670
+ "class" => call_error.class.name,
671
+ "message" => call_error.message
672
+ ) : nil
673
+ usage_ref = if outcome && !outcome.usage.empty?
674
+ tx.contents.put_json(json_value(outcome.usage))
675
+ end
676
+ call = LLMCallRecord.new(
677
+ llm_call_id: llm_call_id,
678
+ execution_id: execution.execution_id,
679
+ sequence: execution.llm_calls.length + index + 1,
680
+ status: call_error ? :failed : :completed,
681
+ manifest_ref: item.fetch(:manifest_ref),
682
+ output_ref: output_ref,
683
+ error_ref: error_ref,
684
+ usage_ref: usage_ref,
685
+ started_at: item.fetch(:started_at),
686
+ completed_at: Time.now.utc.iso8601(6),
687
+ metadata: {
688
+ "streaming" => item[:streaming],
689
+ "tool_call_intercepted" => intercepted,
690
+ "assistant_outcome_captured" => !outcome.nil?,
691
+ "tool_call_count" => outcome ? outcome.tool_calls.length : 0
692
+ }
693
+ )
694
+ calls << call
695
+ call_ref = tx.contents.put_json(call.to_h)
696
+ records << JournalRecord.new(
697
+ agent_id: @agent.agent_id,
698
+ execution_id: execution.execution_id,
699
+ llm_call_id: llm_call_id,
700
+ kind: :llm_call_recorded,
701
+ channel: :audit,
702
+ content_ref: call_ref,
703
+ context_generation: root.transcript_generation,
704
+ context_candidate: false
705
+ )
706
+
707
+ if assistant_ref
708
+ records << JournalRecord.new(
709
+ agent_id: @agent.agent_id,
710
+ execution_id: execution.execution_id,
711
+ llm_call_id: llm_call_id,
712
+ kind: :assistant_message,
713
+ channel: :llm,
714
+ role: :assistant,
715
+ content_ref: assistant_ref,
716
+ context_generation: root.transcript_generation,
717
+ context_candidate: context_candidate,
718
+ metadata: assistant_message_metadata(outcome)
719
+ )
720
+ end
721
+ end
722
+
723
+ if (active = snapshot[:active_call])
724
+ abandoned = LLMCallRecord.new(
725
+ llm_call_id: active.fetch(:llm_call_id),
726
+ execution_id: execution.execution_id,
727
+ sequence: execution.llm_calls.length + calls.length + 1,
728
+ status: :cancelled,
729
+ manifest_ref: active.fetch(:manifest_ref),
730
+ started_at: active.fetch(:started_at),
731
+ completed_at: Time.now.utc.iso8601(6),
732
+ metadata: {"reason" => "execution_terminalized_before_provider_settlement"}
733
+ )
734
+ calls << abandoned
735
+ records << JournalRecord.new(
736
+ agent_id: @agent.agent_id,
737
+ execution_id: execution.execution_id,
738
+ llm_call_id: abandoned.llm_call_id,
739
+ kind: :llm_call_recorded,
740
+ channel: :audit,
741
+ content_ref: tx.contents.put_json(abandoned.to_h),
742
+ context_generation: root.transcript_generation,
743
+ context_candidate: false
744
+ )
745
+ end
746
+
747
+ snapshot.fetch(:runtime_events).each do |event|
748
+ case event.type
749
+ when :tool_call
750
+ # Tool Calls remain part of the captured assistant message. The
751
+ # StreamEvent is an application/runtime notification only.
752
+ next
753
+ when :tool_result
754
+ payload = event.payload
755
+ llm_call_id = payload[:llm_call_id] || payload["llm_call_id"]
756
+ tool_call_id = payload.fetch(:tool_call_id).to_s
757
+ tool_name = payload.fetch(:tool_name).to_s
758
+
759
+ # The raw Tool return value is an execution fact, not an LLM message.
760
+ # Keep it in the complete Journal, but never expose it directly as a
761
+ # Context candidate.
762
+ result_ref = put_runtime_content(tx, payload.fetch(:tool_result))
763
+ records << JournalRecord.new(
764
+ agent_id: @agent.agent_id,
765
+ execution_id: execution.execution_id,
766
+ llm_call_id: llm_call_id,
767
+ kind: :tool_result,
768
+ channel: :tool,
769
+ content_ref: result_ref,
770
+ causation_id: tool_call_id,
771
+ context_generation: root.transcript_generation,
772
+ context_candidate: false,
773
+ metadata: {
774
+ "tool_call_id" => tool_call_id,
775
+ "tool_name" => tool_name
776
+ }
777
+ )
778
+
779
+ # Separately preserve the exact Tool message that was appended to
780
+ # RubyLLM Chat and therefore became eligible for a later Manifest.
781
+ message_payload = if payload.key?(:tool_message)
782
+ payload.fetch(:tool_message)
783
+ else
784
+ payload.fetch("tool_message")
785
+ end
786
+ records << JournalRecord.new(
787
+ agent_id: @agent.agent_id,
788
+ execution_id: execution.execution_id,
789
+ llm_call_id: llm_call_id,
790
+ kind: :tool_message,
791
+ channel: :tool,
792
+ role: :tool,
793
+ content_ref: tx.contents.put_json(json_value(message_payload)),
794
+ causation_id: tool_call_id,
795
+ context_generation: root.transcript_generation,
796
+ context_candidate: context_candidate,
797
+ metadata: {
798
+ "tool_call_id" => tool_call_id,
799
+ "tool_name" => tool_name
800
+ }
801
+ )
802
+ end
803
+ end
804
+ [records, calls]
805
+ end
806
+
807
+ def assistant_message_ref(tx, outcome)
808
+ return unless outcome
809
+
810
+ payload = {
811
+ "role" => "assistant",
812
+ "content" => json_value(outcome.content),
813
+ "tool_calls" => json_value(Array(outcome.tool_calls)),
814
+ "model_id" => outcome.metadata["model_id"]
815
+ }.compact
816
+ tx.contents.put_json(payload)
817
+ end
818
+
819
+ def assistant_message_metadata(outcome)
820
+ calls = Array(outcome&.tool_calls)
821
+ {
822
+ "tool_call_ids" => calls.map { |call| call.fetch("id").to_s },
823
+ "tool_names" => calls.map { |call| call.fetch("name").to_s }
824
+ }
825
+ end
826
+
827
+ def assistant_output_ref(tx, outcome)
828
+ return unless outcome&.content_present?
829
+
830
+ content = outcome.content
831
+ text = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
832
+ tx.contents.put_text(text)
833
+ end
834
+
835
+ def put_runtime_content(tx, value)
836
+ value.is_a?(String) ? tx.contents.put_text(value) : tx.contents.put_json(json_value(value))
837
+ end
838
+
839
+ def mark_resuming!(execution_id, approval_request_id:, approved:)
840
+ root = nil
841
+ updated = nil
842
+ @agent.persistence.transaction do |tx|
843
+ current = tx.executions.load(execution_id)
844
+ unless current.agent_id == @agent.agent_id && current.status == :suspended
845
+ raise ArgumentError,
846
+ "execution is not suspended: #{execution_id}"
847
+ end
848
+
849
+ request = current.approval_request || {}
850
+ request_id = request["id"] || request[:id]
851
+ unless request_id.to_s == approval_request_id.to_s
852
+ raise ArgumentError,
853
+ "approval request does not match execution #{execution_id}"
854
+ end
855
+
856
+ current_root = tx.agents.load(@agent.agent_id)
857
+ decision_ref = tx.contents.put_json(
858
+ "approval_request_id" => request_id.to_s,
859
+ "approved" => !!approved
860
+ )
861
+ decision_record = JournalRecord.new(
862
+ agent_id: @agent.agent_id,
863
+ execution_id: current.execution_id,
864
+ kind: :approval_decided,
865
+ channel: :approval,
866
+ content_ref: decision_ref,
867
+ correlation_id: request_id.to_s,
868
+ context_generation: current_root.transcript_generation,
869
+ context_candidate: false
870
+ )
871
+ updated = current.with(
872
+ status: :active,
873
+ phase: :resuming,
874
+ working_records: current.working_records + [decision_record],
875
+ approval_request: request.merge("approved" => approved)
876
+ )
877
+ tx.executions.save(
878
+ current.execution_id,
879
+ expected_revision: current.execution_revision,
880
+ execution: updated
881
+ )
882
+ next_root = current_root.with(
883
+ agent_revision: current_root.agent_revision + 1,
884
+ lifecycle_status: :active
885
+ )
886
+ tx.agents.save(
887
+ current_root.agent_id,
888
+ expected_revision: current_root.agent_revision,
889
+ root: next_root
890
+ )
891
+ root = next_root
892
+ end
893
+ @agent.persistence.activations.fetch(execution_id)&.replace_execution(updated)
894
+ @agent.__replace_root(root)
895
+ updated
896
+ end
897
+
898
+ def start_resume(activation, result_task, approved:, config:)
899
+ invocation = activation.invocation
900
+ invocation.merge_config!(config)
901
+ invocation.begin_approval_resume!(approved: approved)
902
+ runtime = Phronomy::Runtime.instance
903
+ event_loop = runtime.event_loop
904
+ parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
905
+ agent_invocation: invocation,
906
+ resume_event: :resume,
907
+ resume_phase: :suspended,
908
+ runtime: runtime
909
+ )
910
+ activation.session = parent_session
911
+ source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
912
+ source_task.on_complete do |completed, error|
913
+ finish(activation, result_task, completed || parent_session.context, error)
914
+ end
915
+ event_loop.register(parent_session, completion: source_task)
916
+ invocation.tool_invocations.each do |child|
917
+ child_session = if child.awaiting_approval?
918
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
919
+ tool_invocation: child,
920
+ resume_event: approved ? :approve : :reject,
921
+ resume_phase: :awaiting_approval,
922
+ runtime: runtime
923
+ )
924
+ elsif !approved && child.authorized?
925
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
926
+ tool_invocation: child,
927
+ resume_event: :cancel,
928
+ resume_phase: :authorized,
929
+ runtime: runtime
930
+ )
931
+ end
932
+ register_child(event_loop, runtime, child, child_session) if child_session
933
+ end
934
+ end
935
+
936
+ def register_child(event_loop, runtime, child, session)
937
+ completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
938
+ completion.on_complete do |_result, error|
939
+ next unless error
940
+ child.mark_framework_failed!(error)
941
+ runtime.event_loop.post_to_session(
942
+ Phronomy::Event.new(
943
+ type: :tool_failed,
944
+ target_id: child.parent_agent_invocation_id,
945
+ payload: {tool_invocation_id: child.id}
946
+ )
947
+ )
948
+ end
949
+ event_loop.register(session, completion: completion)
950
+ end
951
+
952
+ def result_base(execution, root)
953
+ {
954
+ agent_id: root.agent_id,
955
+ execution_id: execution.execution_id,
956
+ agent_revision: root.agent_revision,
957
+ context_revision: root.context_revision,
958
+ journal_position: root.journal_position
959
+ }
960
+ end
961
+
962
+ def context_policy_for(execution)
963
+ descriptor_hash = execution.metadata.fetch("context_policy") do
964
+ ContextPolicies::Default.new.descriptor.to_h
965
+ end
966
+ descriptor = ContextPolicyDescriptor.from_h(descriptor_hash)
967
+ ContextPolicyRegistry.default.resolve(descriptor)
968
+ end
969
+
970
+ def transcript_messages(root)
971
+ materializer = RubyLLMMaterializer.new(
972
+ agent: @agent,
973
+ persistence: @agent.persistence
974
+ )
975
+ materializer.materialize_journal_records(
976
+ JournalProjection.new(
977
+ persistence: @agent.persistence,
978
+ agent_root: root
979
+ ).transcript_records
980
+ )
981
+ end
982
+
983
+ def deliver_terminal(activation, type, payload)
984
+ listener = activation.application_listener
985
+ return nil unless listener
986
+ @agent.send(:_deliver_stream_event, listener, StreamEvent.new(type: type, payload: payload))
987
+ end
988
+
989
+ def post_prep_failure(runtime, listener, result_task, error)
990
+ command = PrepFailureCommand.new(self, listener, result_task, error)
991
+ posted = runtime.event_loop.post(
992
+ Phronomy::Event.new(
993
+ type: :agent_terminal_ready,
994
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
995
+ payload: {command: command}
996
+ )
997
+ )
998
+ fail_task(result_task, error) unless posted
999
+ end
1000
+
1001
+ def deliver_execution_terminal(command)
1002
+ activation = command.activation
1003
+ if command.commit_error
1004
+ callback_error = deliver_terminal(activation, :error, error: command.commit_error)
1005
+ settle_after_terminal(command.result_task, callback_error, :error, nil, command.commit_error)
1006
+ return
1007
+ end
1008
+ outcome = command.outcome
1009
+ case outcome.fetch(:type)
1010
+ when :suspended
1011
+ result = outcome.fetch(:result)
1012
+ callback_error = deliver_terminal(
1013
+ activation, :approval_required,
1014
+ request: result.fetch(:approval_request)
1015
+ )
1016
+ settle_after_terminal(command.result_task, callback_error, :approval_required, result)
1017
+ when :completed
1018
+ @agent.persistence.activations.delete(activation.execution_id)
1019
+ result = outcome.fetch(:result)
1020
+ callback_error = deliver_terminal(activation, :done, result)
1021
+ settle_after_terminal(command.result_task, callback_error, :done, result)
1022
+ when :failed
1023
+ @agent.persistence.activations.delete(activation.execution_id)
1024
+ error = outcome.fetch(:error)
1025
+ type = terminal_event_type(error)
1026
+ callback_error = deliver_terminal(activation, type, error: error)
1027
+ settle_after_terminal(command.result_task, callback_error, type, nil, error)
1028
+ end
1029
+ end
1030
+
1031
+ def settle_without_listener(activation, result_task, outcome, commit_error)
1032
+ return fail_task(result_task, commit_error) if commit_error
1033
+ case outcome&.fetch(:type)
1034
+ when :suspended then complete_task(result_task, outcome.fetch(:result))
1035
+ when :completed
1036
+ @agent.persistence.activations.delete(activation.execution_id)
1037
+ complete_task(result_task, outcome.fetch(:result))
1038
+ when :failed
1039
+ @agent.persistence.activations.delete(activation.execution_id)
1040
+ fail_task(result_task, outcome.fetch(:error))
1041
+ else
1042
+ fail_task(result_task, Phronomy::RuntimeShutdownError.new("EventLoop is not accepting terminal delivery"))
1043
+ end
1044
+ end
1045
+
1046
+ def settle_after_terminal(result_task, callback_error, event_type, result, execution_error = nil)
1047
+ if callback_error
1048
+ policy = Phronomy.configuration.stream_callback_error_policy
1049
+ @agent.send(
1050
+ :_report_stream_callback_error, callback_error,
1051
+ event: StreamEvent.new(type: event_type, payload: result || {error: execution_error}),
1052
+ invocation_id: nil, callback_error_policy: policy
1053
+ )
1054
+ if policy == :fail_task && execution_error.nil?
1055
+ return fail_task(result_task, @agent.send(
1056
+ :_build_stream_callback_error,
1057
+ event_type: event_type, callback_error: callback_error, result: result
1058
+ ))
1059
+ end
1060
+ end
1061
+ execution_error ? fail_task(result_task, execution_error) : complete_task(result_task, result)
1062
+ end
1063
+
1064
+ def terminal_event_type(error)
1065
+ return :timeout if error.is_a?(Phronomy::TimeoutError)
1066
+ return :cancelled if error.is_a?(Phronomy::CancellationError)
1067
+ :error
1068
+ end
1069
+
1070
+ def dispatch_approval_listener(invocation, request)
1071
+ listener = invocation.approval_listener
1072
+ return unless listener
1073
+ Phronomy::Runtime.instance.blocking_io.submit { listener.call(request) }
1074
+ end
1075
+
1076
+ def json_value(value)
1077
+ case value
1078
+ when Hash
1079
+ value.to_h { |key, child| [key.to_s, json_value(child)] }
1080
+ when Array
1081
+ value.map { |child| json_value(child) }
1082
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
1083
+ value
1084
+ when Symbol
1085
+ value.to_s
1086
+ else
1087
+ if value.respond_to?(:to_h)
1088
+ json_value(value.to_h)
1089
+ else
1090
+ raise ArgumentError, "unsupported canonical runtime value: #{value.class}"
1091
+ end
1092
+ end
1093
+ end
1094
+
1095
+ def terminal_status_for(error)
1096
+ return :cancelled if defined?(Phronomy::CancellationError) && error.is_a?(Phronomy::CancellationError)
1097
+ return :blocked if defined?(Phronomy::FilterBlockError) && error.is_a?(Phronomy::FilterBlockError)
1098
+
1099
+ :failed
1100
+ end
1101
+
1102
+ def execution_terminal_kind(status)
1103
+ {
1104
+ cancelled: :execution_cancelled,
1105
+ blocked: :execution_blocked,
1106
+ failed: :execution_failed
1107
+ }.fetch(status)
1108
+ end
1109
+
1110
+ def translated(error)
1111
+ @agent.send(:_translated_error, error)
1112
+ end
1113
+
1114
+ def complete_task(task, result)
1115
+ @agent.send(:_complete_result_task, task, result)
1116
+ end
1117
+
1118
+ def fail_task(task, error)
1119
+ @agent.send(:_fail_result_task, task, error)
1120
+ end
1121
+ end
1122
+ end
1123
+ end