phronomy 0.23.0 → 0.24.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +2 -2
  3. data/CHANGELOG.md +15 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +1 -1
  6. data/docs/architecture/multi-agent-handoff.md +35 -40
  7. data/docs/architecture/persistence.md +19 -8
  8. data/docs/architecture.md +8 -1
  9. data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
  10. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  11. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  12. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  13. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  14. data/docs/decisions/README.md +5 -1
  15. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  16. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  17. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  18. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  19. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  20. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  21. data/docs/features.md +35 -1
  22. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  23. data/docs/persistence-backends.md +42 -3
  24. data/lib/phronomy/agent/agent_execution.rb +2 -2
  25. data/lib/phronomy/agent/agent_invocation.rb +1 -1
  26. data/lib/phronomy/agent/async_event_api.rb +18 -1
  27. data/lib/phronomy/agent/base.rb +28 -0
  28. data/lib/phronomy/agent/context_assembler.rb +1 -1
  29. data/lib/phronomy/agent/exact_execution.rb +153 -0
  30. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  31. data/lib/phronomy/agent/execution_coordinator.rb +484 -27
  32. data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
  33. data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
  34. data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
  35. data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
  36. data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
  37. data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
  38. data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
  39. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  40. data/lib/phronomy/agent/handoff_state.rb +43 -0
  41. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
  42. data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
  43. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
  44. data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
  45. data/lib/phronomy/agent/recovery_support.rb +15 -23
  46. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
  47. data/lib/phronomy/agent/tool_invocation.rb +4 -2
  48. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  49. data/lib/phronomy/engine/runtime.rb +16 -1
  50. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  51. data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  53. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  54. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  55. data/lib/phronomy/persistence/durable_codec.rb +60 -0
  56. data/lib/phronomy/persistence/in_memory.rb +264 -2
  57. data/lib/phronomy/persistence/repository_facades.rb +221 -2
  58. data/lib/phronomy/persistence.rb +95 -1
  59. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
  60. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  61. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  62. data/lib/phronomy/tools/agent.rb +1 -1
  63. data/lib/phronomy/version.rb +1 -1
  64. data/scripts/api_snapshot.rb +3 -3
  65. data/sig/phronomy/handoff.rbs +41 -0
  66. data/sig/phronomy/multi_agent.rbs +28 -32
  67. data/sig/phronomy/persistence.rbs +64 -3
  68. metadata +30 -12
  69. data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
  70. data/lib/phronomy/multi_agent/coordinator.rb +0 -154
  71. data/lib/phronomy/multi_agent/runner.rb +0 -98
@@ -9,7 +9,7 @@ module Phronomy
9
9
  private
10
10
 
11
11
  def begin_resolve_on_event_loop(request)
12
- event_loop = Phronomy::Runtime.instance.event_loop
12
+ event_loop = @runtime.event_loop
13
13
  state = event_loop.agent_execution_state(
14
14
  request.execution_id
15
15
  )
@@ -36,7 +36,7 @@ module Phronomy
36
36
  end
37
37
 
38
38
  descriptor =
39
- RecoverySupport.recovery_descriptor(current)
39
+ coordination_recovery_descriptor(current)
40
40
  unless descriptor &&
41
41
  Phronomy::Recovery.subject_equal?(
42
42
  descriptor.fetch(:subject),
@@ -74,10 +74,10 @@ module Phronomy
74
74
  execution_id: current.execution_id,
75
75
  state: :recovery_required
76
76
  )
77
- task = Phronomy::Runtime.instance.offload.submit(
77
+ task = @runtime.offload.submit(
78
78
  on_full: :raise
79
79
  ) do
80
- perform_resolution(operation)
80
+ prepare_resolution(operation)
81
81
  end
82
82
  task.on_complete do |result, error|
83
83
  ready = ResolveReady.new(
@@ -146,13 +146,7 @@ module Phronomy
146
146
  RecoverySupport::RECOVERY_METADATA_KEY => recovery
147
147
  )
148
148
  )
149
- intended = ResolutionResult.new(
150
- execution: updated,
151
- root: operation.root,
152
- continuation: :failed_terminal,
153
- failure: failure,
154
- appended_records: [].freeze
155
- )
149
+ intended = ResolutionResult.new(execution: updated)
156
150
  save_resolution_result(current, intended)
157
151
  when :failed
158
152
  recovery = {
@@ -166,13 +160,7 @@ module Phronomy
166
160
  recovery
167
161
  )
168
162
  )
169
- intended = ResolutionResult.new(
170
- execution: updated,
171
- root: operation.root,
172
- continuation: :failed_terminal,
173
- failure: operation.failure,
174
- appended_records: [].freeze
175
- )
163
+ intended = ResolutionResult.new(execution: updated)
176
164
  save_resolution_result(current, intended)
177
165
  when :succeeded
178
166
  # simplecov:disable
@@ -182,7 +170,7 @@ module Phronomy
182
170
  )
183
171
  updated = nil
184
172
  with_resolution_f1_capture do |tx|
185
- main = agent.send(:execution_coordinator)
173
+ main = agent.send(:execution_coordinator_for, agent.__coordination_config)
186
174
  snapshot = {
187
175
  llm_results: [{
188
176
  llm_call_id: llm_call_id,
@@ -217,7 +205,8 @@ module Phronomy
217
205
  llm_call_id,
218
206
  outcome
219
207
  )
220
- next_phase = subjects.empty? ?
208
+ framework_subjects, external_subjects = subjects.partition { |entry| agent.__framework_call?(entry.fetch("tool_name")) }
209
+ next_phase = external_subjects.empty? ?
221
210
  :recovery_provider_completed : :recovery_tools
222
211
  metadata = current.metadata.dup
223
212
  metadata.delete(
@@ -226,7 +215,21 @@ module Phronomy
226
215
  metadata.delete(
227
216
  RecoverySupport::PENDING_LLM_STARTED_AT_KEY
228
217
  )
229
- if subjects.empty?
218
+ metadata.delete("framework_calls_pending")
219
+ metadata["framework_calls_pending"] = true unless framework_subjects.empty?
220
+ if subjects.any? && Array(metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY]).empty?
221
+ metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY] = subjects.map do |entry|
222
+ {
223
+ "tool_invocation_id" => entry.fetch("tool_invocation_id"),
224
+ "llm_call_id" => entry.fetch("llm_call_id"),
225
+ "tool_call_id" => entry.fetch("tool_call_id"),
226
+ "tool_name" => entry.fetch("tool_name"),
227
+ "arguments" => RecoverySupport.canonical_copy(entry.fetch("arguments", {})),
228
+ "status" => "authorized"
229
+ }.freeze
230
+ end.freeze
231
+ end
232
+ if external_subjects.empty?
230
233
  metadata.delete(
231
234
  RecoverySupport::RECOVERY_METADATA_KEY
232
235
  )
@@ -234,7 +237,7 @@ module Phronomy
234
237
  metadata[
235
238
  RecoverySupport::RECOVERY_METADATA_KEY
236
239
  ] = RecoverySupport.build_recovery_hash(
237
- subjects
240
+ external_subjects
238
241
  )
239
242
  end
240
243
  updated = current.with(
@@ -249,18 +252,7 @@ module Phronomy
249
252
  expected_revision: current.execution_revision,
250
253
  execution: updated
251
254
  )
252
- ResolutionResult.new(
253
- execution: updated,
254
- root: operation.root,
255
- continuation: (
256
- (updated.phase.to_sym ==
257
- :recovery_provider_completed) ?
258
- :provider_completed :
259
- :resolution_required
260
- ),
261
- failure: nil,
262
- appended_records: [].freeze
263
- )
255
+ ResolutionResult.new(execution: updated)
264
256
  end
265
257
  # simplecov:enable
266
258
  end
@@ -321,13 +313,7 @@ module Phronomy
321
313
  resolved_recovery
322
314
  )
323
315
  )
324
- intended = ResolutionResult.new(
325
- execution: updated,
326
- root: operation.root,
327
- continuation: :failed_terminal,
328
- failure: failure,
329
- appended_records: [].freeze
330
- )
316
+ intended = ResolutionResult.new(execution: updated)
331
317
  save_resolution_result(current, intended)
332
318
  when :failed
333
319
  recovery_with_failure =
@@ -347,18 +333,12 @@ module Phronomy
347
333
  recovery_with_failure
348
334
  )
349
335
  )
350
- intended = ResolutionResult.new(
351
- execution: updated,
352
- root: operation.root,
353
- continuation: :failed_terminal,
354
- failure: operation.failure,
355
- appended_records: [].freeze
356
- )
336
+ intended = ResolutionResult.new(execution: updated)
357
337
  save_resolution_result(current, intended)
358
338
  when :succeeded
359
339
  updated = nil
360
340
  with_resolution_f1_capture do |tx|
361
- main = agent.send(:execution_coordinator)
341
+ main = agent.send(:execution_coordinator_for, agent.__coordination_config)
362
342
  result_ref = main.send(
363
343
  :put_runtime_content,
364
344
  tx,
@@ -408,6 +388,13 @@ module Phronomy
408
388
  RecoverySupport.unresolved_subjects(
409
389
  next_recovery
410
390
  )
391
+ tool_batch = Array(current.metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY]).map do |entry|
392
+ if entry.fetch("tool_invocation_id") == subject_entry.fetch("tool_invocation_id")
393
+ entry.merge("status" => "completed", "result" => operation.result)
394
+ else
395
+ entry
396
+ end
397
+ end
411
398
  next_phase = unresolved.empty? ?
412
399
  :recovery_tools_completed : :recovery_tools
413
400
  updated = current.with(
@@ -415,8 +402,8 @@ module Phronomy
415
402
  working_records:
416
403
  current.working_records + records,
417
404
  metadata: current.metadata.merge(
418
- RecoverySupport::RECOVERY_METADATA_KEY =>
419
- next_recovery
405
+ RecoverySupport::RECOVERY_METADATA_KEY => next_recovery,
406
+ RecoverySupport::TOOL_BATCH_METADATA_KEY => tool_batch
420
407
  )
421
408
  )
422
409
  tx.executions.save(
@@ -424,18 +411,7 @@ module Phronomy
424
411
  expected_revision: current.execution_revision,
425
412
  execution: updated
426
413
  )
427
- ResolutionResult.new(
428
- execution: updated,
429
- root: operation.root,
430
- continuation: (
431
- (updated.phase.to_sym ==
432
- :recovery_tools_completed) ?
433
- :tools_completed :
434
- :resolution_required
435
- ),
436
- failure: nil,
437
- appended_records: [].freeze
438
- )
414
+ ResolutionResult.new(execution: updated)
439
415
  end
440
416
  end
441
417
  end
@@ -480,154 +456,54 @@ module Phronomy
480
456
  end
481
457
  end
482
458
 
483
- def apply_resolve_on_event_loop(ready)
484
- request = ready.request
485
- event_loop = Phronomy::Runtime.instance.event_loop
486
- state = event_loop.agent_execution_state(
487
- request.execution_id
488
- )
489
- unless state && state.agent.equal?(agent)
490
- request.completion.fail(
491
- Phronomy::ExecutionRehydrationRequiredError.new(
492
- "recovered execution disappeared before resolution apply"
493
- )
494
- )
495
- return
496
- end
497
-
498
- if ready.error
499
- reconcile_resolution_f1_on_event_loop(
500
- ready,
501
- state
502
- )
503
- return
504
- end
459
+ # Commit/readback and materialization share one worker operation. A read
460
+ # failure after a confirmed commit must retain that committed execution;
461
+ # it must not be mistaken for another uncertain resolution write.
462
+ def prepare_resolution(operation)
463
+ result = resolve_with_readback(operation)
464
+ material = prepare_recovery_material(result.execution)
465
+ ResolutionPreparation.new(execution: result.execution, material: material, error: nil)
466
+ rescue => error
467
+ ResolutionPreparation.new(execution: result&.execution, material: nil, error: error)
468
+ end
505
469
 
506
- result = ready.result
507
- event_loop.replace_agent_execution(
508
- request.execution_id,
509
- execution: result.execution
510
- )
470
+ def resolve_with_readback(operation)
471
+ perform_resolution(operation)
472
+ rescue => error
473
+ intended = error.intended_result.execution if error.is_a?(ResolutionOutcomeUnknownError)
474
+ current = agent.persistence.executions.load(operation.execution.execution_id)
475
+ return ResolutionResult.new(execution: current) if intended && current.to_h == intended.to_h
511
476
 
512
- case result.continuation
513
- when :resolution_required
514
- event_loop.mark_agent_execution_admission(
515
- agent.agent_id,
516
- execution_id: request.execution_id,
517
- state: :recovery_required
518
- )
519
- descriptor =
520
- RecoverySupport.recovery_descriptor(
521
- result.execution
522
- )
523
- deliver_resolution_required(
524
- result.execution,
525
- descriptor
526
- )
527
- request.completion.complete(
528
- {
529
- execution_id: request.execution_id,
530
- execution_revision:
531
- result.execution.execution_revision,
532
- recovery: :resolution_required
533
- }.freeze
534
- )
535
- when :provider_completed
536
- observe_recovery_execution(request.completion, result.execution)
537
- continue_provider_completed_after_resolution(
538
- event_loop,
539
- state,
540
- result.execution,
541
- request.completion
542
- )
543
- when :tools_completed
544
- observe_recovery_execution(request.completion, result.execution)
545
- continue_tools_completed_after_resolution(
546
- event_loop,
547
- state,
548
- result.execution,
549
- request.completion
550
- )
551
- when :failed_terminal
552
- observe_recovery_execution(request.completion, result.execution)
553
- continue_failed_after_resolution(
554
- event_loop,
555
- state,
556
- result.execution,
557
- request.completion,
558
- result.failure
559
- )
560
- else
561
- request.completion.fail(
562
- Phronomy::Error.new(
563
- "unknown Recovery continuation: #{result.continuation.inspect}"
564
- )
565
- )
477
+ if current.to_h == operation.execution.to_h
478
+ raise(error.is_a?(ResolutionOutcomeUnknownError) ? error.original_error : error)
566
479
  end
567
- rescue => caught
568
- request.completion.fail(caught)
480
+ raise Phronomy::Persistence::ConflictError,
481
+ "Recovery resolution durable outcome conflicts with both expected pre-state and intended post-state"
569
482
  end
570
483
 
571
- def reconcile_resolution_f1_on_event_loop(ready, state)
484
+ def apply_resolve_on_event_loop(ready)
572
485
  request = ready.request
573
- intended_result = ready.result
574
- if intended_result.nil? &&
575
- ready.error.is_a?(ResolutionOutcomeUnknownError)
576
- intended_result = ready.error.intended_result
486
+ event_loop = @runtime.event_loop
487
+ state = event_loop.agent_execution_state(request.execution_id)
488
+ unless state && state.agent.equal?(agent)
489
+ raise Phronomy::ExecutionRehydrationRequiredError,
490
+ "recovered execution disappeared before resolution apply"
577
491
  end
578
- intended = intended_result&.execution
579
- current = agent.persistence.executions.load(
580
- request.execution_id
581
- )
582
-
583
- if intended &&
584
- current.execution_revision ==
585
- intended.execution_revision &&
586
- current.to_h == intended.to_h
587
- Phronomy::Runtime.instance.event_loop.replace_agent_execution(
588
- request.execution_id,
589
- execution: current
590
- )
591
- synthetic = ResolveReady.new(
592
- coordinator: self,
593
- request: request,
594
- operation: ready.operation,
595
- result: intended_result.class.new(
596
- execution: current,
597
- root: intended_result.root,
598
- continuation: intended_result.continuation,
599
- failure: intended_result.failure,
600
- appended_records:
601
- intended_result.appended_records
602
- ),
603
- error: nil
604
- )
605
- apply_resolve_on_event_loop(synthetic)
606
- return
492
+ unless state.execution.equal?(ready.operation.execution) && state.fsm_session_id.nil?
493
+ raise Phronomy::Persistence::ConflictError,
494
+ "Recovery execution changed before resolution apply"
607
495
  end
496
+ raise ready.error if ready.error
608
497
 
609
- if current.execution_revision ==
610
- ready.operation.execution.execution_revision &&
611
- current.to_h ==
612
- ready.operation.execution.to_h
613
- failure = if ready.error.is_a?(
614
- ResolutionOutcomeUnknownError
615
- )
616
- ready.error.original_error
617
- else
618
- ready.error
619
- end
620
- request.completion.fail(failure)
621
- return
498
+ result = ready.result
499
+ if result.execution
500
+ event_loop.replace_agent_execution(request.execution_id, execution: result.execution)
622
501
  end
502
+ raise result.error if result.error
623
503
 
624
- request.completion.fail(
625
- Phronomy::Persistence::ConflictError.new(
626
- "Recovery resolution durable outcome conflicts with both expected pre-state and intended post-state"
627
- )
628
- )
629
- rescue => reconciliation_error
630
- request.completion.fail(reconciliation_error)
504
+ continue_recovery_on_event_loop(result.execution, request.completion, material: result.material)
505
+ rescue => caught
506
+ request.completion.fail(caught)
631
507
  end
632
508
  end
633
509
  end
@@ -37,18 +37,19 @@ module Phronomy
37
37
  ResolutionOperation = Data.define(
38
38
  :execution, :root, :subject, :outcome, :result, :failure
39
39
  )
40
- ResolutionResult = Data.define(
41
- :execution, :root, :continuation, :failure, :appended_records
42
- )
40
+ ResolutionResult = Data.define(:execution)
41
+ ResolutionPreparation = Data.define(:execution, :material, :error)
42
+ RecoveryMaterial = Data.define(:projection, :messages, :assistant_message, :output, :usage)
43
43
  RecoveryPlan = Data.define(
44
44
  :execution, :root, :manifest, :base_manifest,
45
- :projection, :classification
45
+ :projection, :classification, :material
46
46
  )
47
47
 
48
48
  attr_reader :agent
49
49
 
50
50
  def initialize(agent)
51
51
  @agent = agent
52
+ @runtime = Phronomy::Runtime.instance
52
53
  end
53
54
 
54
55
  def recover_on_load!
@@ -67,6 +68,11 @@ module Phronomy
67
68
  "active AgentExecution belongs to another Agent: #{execution.agent_id}"
68
69
  end
69
70
 
71
+ coordination = execution.metadata["coordination"]
72
+ if coordination && agent.__coordination_config.empty?
73
+ return agent
74
+ end
75
+
70
76
  plan = prepare_plan(execution)
71
77
  classification = plan.classification
72
78
  if classification.disposition ==
@@ -181,7 +187,7 @@ module Phronomy
181
187
  private
182
188
 
183
189
  def post_control(command)
184
- Phronomy::Runtime.instance.event_loop.post(
190
+ @runtime.event_loop.post(
185
191
  Phronomy::Event.new(
186
192
  type: :agent_control,
187
193
  target_id:
@@ -305,8 +305,8 @@ module Phronomy
305
305
  end
306
306
  end
307
307
 
308
- def build_invocation_for_suspended(agent, execution, projection, main_coordinator, listener)
309
- request = Phronomy::Agent::ToolApprovalRequest.from_h(
308
+ def build_invocation_for_suspended(agent, execution, projection, main_coordinator, listener, assistant_message:)
309
+ request = execution.approval_request && Phronomy::Agent::ToolApprovalRequest.from_h(
310
310
  execution.approval_request
311
311
  )
312
312
  assistant_record = latest_assistant_record(execution)
@@ -315,19 +315,15 @@ module Phronomy
315
315
  "suspended execution #{execution.execution_id} has no durable assistant Tool Call message"
316
316
  end
317
317
 
318
- materializer = Phronomy::Agent::RubyLLMMaterializer.new(
319
- agent: agent,
320
- persistence: agent.persistence
321
- )
322
- assistant_message = materializer.materialize_journal_record(
323
- assistant_record
324
- )
325
-
326
318
  config = {
327
319
  execution_id: execution.execution_id,
328
320
  phronomy_execution_coordinator: main_coordinator,
329
321
  phronomy_runtime_projection: projection
330
- }
322
+ }.merge(agent.__coordination_config)
323
+ config = agent.__invocation_config(config)
324
+ if execution.metadata["coordination_cancel_requested"]
325
+ config = config.merge(cancellation_token: Phronomy::Concurrency::CancellationToken.new.cancel!)
326
+ end
331
327
  invocation = Phronomy::Agent::AgentInvocation.new(
332
328
  agent: agent,
333
329
  input: projection.ask_message,
@@ -359,7 +355,7 @@ module Phronomy
359
355
  assistant_record.llm_call_id&.to_s
360
356
  )
361
357
 
362
- by_id = request.items.to_h do |item|
358
+ by_id = (request ? request.items : []).to_h do |item|
363
359
  [item.tool_invocation_id.to_s, item]
364
360
  end
365
361
  snapshots = Array(execution.metadata[TOOL_BATCH_METADATA_KEY])
@@ -445,12 +441,16 @@ module Phronomy
445
441
  child
446
442
  end
447
443
 
448
- def build_chat_for_recovery(agent, execution, projection, main_coordinator, listener)
444
+ def build_chat_for_recovery(agent, execution, projection, main_coordinator, listener, messages:)
449
445
  config = {
450
446
  execution_id: execution.execution_id,
451
447
  phronomy_execution_coordinator: main_coordinator,
452
448
  phronomy_runtime_projection: projection
453
- }
449
+ }.merge(agent.__coordination_config)
450
+ config = agent.__invocation_config(config)
451
+ if execution.metadata["coordination_cancel_requested"]
452
+ config = config.merge(cancellation_token: Phronomy::Concurrency::CancellationToken.new.cancel!)
453
+ end
454
454
  invocation = Phronomy::Agent::AgentInvocation.new(
455
455
  agent: agent,
456
456
  input: projection.ask_message,
@@ -473,15 +473,7 @@ module Phronomy
473
473
  )
474
474
  end
475
475
 
476
- materializer = Phronomy::Agent::RubyLLMMaterializer.new(
477
- agent: agent,
478
- persistence: agent.persistence
479
- )
480
- Array(execution.working_records).each do |record|
481
- next unless %i[assistant_message tool_message].include?(record.kind.to_sym)
482
-
483
- chat.messages << materializer.materialize_journal_record(record)
484
- end
476
+ messages.each { |message| chat.messages << message }
485
477
 
486
478
  invocation.chat = chat
487
479
  invocation.user_message_sent = true
@@ -105,14 +105,8 @@ module Phronomy
105
105
 
106
106
  expected_definitions = fetch_json(manifest.tool_definitions_ref)
107
107
  additional = @additional_tools
108
- if additional.empty? && defined?(Phronomy::MultiAgent::HandoffCapabilityFactory)
109
- ordinary_names = ToolDefinitionSet.build(@agent).definitions
110
- .map { |definition| definition.fetch("name") }
111
- additional = Array(expected_definitions).filter_map do |definition|
112
- name = definition.fetch("name")
113
- next if ordinary_names.include?(name)
114
- Phronomy::MultiAgent::HandoffCapabilityFactory.lookup(name)&.tool_class
115
- end
108
+ if additional.empty?
109
+ additional = Array(@agent.__coordination_config[:phronomy_handoff_bindings]).map(&:tool_class)
116
110
  end
117
111
 
118
112
  ToolDefinitionSet.build(@agent, additional_tools: additional)
@@ -113,7 +113,7 @@ module Phronomy
113
113
  @tool_call_id = tool_call.respond_to?(:id) ? tool_call.id : nil
114
114
  raw_arguments = tool_call.respond_to?(:arguments) ? (tool_call.arguments || {}) : {}
115
115
  @raw_arguments = immutable_copy(raw_arguments)
116
- @config = config
116
+ @config = config.dup.freeze
117
117
  @approval_policy = approval_policy
118
118
  @approval_context = immutable_copy(approval_context || {})
119
119
  @origin = tool&.respond_to?(:tool_origin) ? tool.tool_origin.to_sym : :local
@@ -560,10 +560,12 @@ module Phronomy
560
560
  on_full: :raise
561
561
  )
562
562
  else
563
+ tool_config = @tool.class.respond_to?(:__framework_owned_operation?) ?
564
+ @config.merge(phronomy_tool_invocation_id: @id, execution_id: @execution_id).freeze : @config
563
565
  @tool.call_async(
564
566
  @arguments,
565
567
  cancellation_token: @config[:cancellation_token],
566
- config: @config
568
+ config: tool_config
567
569
  )
568
570
  end
569
571
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Runtime-local Team identity and construction exclusion; no execution state.
6
+ # @api private
7
+ class TeamOwnershipRegistry
8
+ def initialize
9
+ @mutex = Mutex.new
10
+ @condition = ConditionVariable.new
11
+ @owners = {}
12
+ @constructing = {}
13
+ @draining = false
14
+ end
15
+
16
+ def fetch(id, klass:, create:, persistence:)
17
+ @mutex.synchronize do
18
+ loop do
19
+ raise Phronomy::RuntimeShutdownError, "Runtime is draining" if @draining
20
+ if (owner = @owners[id])
21
+ raise Phronomy::Persistence::ConflictError, "Team #{id} already exists" if create
22
+ unless owner.is_a?(klass) && owner.persistence.equal?(persistence)
23
+ raise Phronomy::ConfigurationError, "Team #{id} owner or Persistence mismatch"
24
+ end
25
+ return owner
26
+ end
27
+ break unless @constructing[id]
28
+ @condition.wait(@mutex)
29
+ end
30
+ @constructing[id] = true
31
+ end
32
+ begin
33
+ owner = yield
34
+ @mutex.synchronize { @owners[id] = owner }
35
+ owner
36
+ ensure
37
+ @mutex.synchronize do
38
+ @constructing.delete(id)
39
+ @condition.broadcast
40
+ end
41
+ end
42
+ end
43
+
44
+ def get(id, klass:)
45
+ @mutex.synchronize do
46
+ owner = @owners[id]
47
+ if owner && !owner.is_a?(klass)
48
+ raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
49
+ end
50
+ owner
51
+ end
52
+ end
53
+
54
+ def begin_draining
55
+ @mutex.synchronize do
56
+ @draining = true
57
+ @condition.broadcast
58
+ end
59
+ end
60
+
61
+ def wait_until_stable(deadline)
62
+ @mutex.synchronize do
63
+ until @constructing.empty?
64
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
65
+ return false if remaining <= 0
66
+ @condition.wait(@mutex, remaining)
67
+ end
68
+ true
69
+ end
70
+ end
71
+
72
+ def shutdown!
73
+ @mutex.synchronize { @owners.clear }
74
+ end
75
+ end
76
+ end
77
+ end