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.
- checksums.yaml +4 -4
- data/.mutant.yml +2 -2
- data/CHANGELOG.md +15 -0
- data/CONTRIBUTING.md +2 -2
- data/README.md +1 -1
- data/docs/architecture/multi-agent-handoff.md +35 -40
- data/docs/architecture/persistence.md +19 -8
- data/docs/architecture.md +8 -1
- data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
- data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +5 -1
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +35 -1
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +42 -3
- data/lib/phronomy/agent/agent_execution.rb +2 -2
- data/lib/phronomy/agent/agent_invocation.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +18 -1
- data/lib/phronomy/agent/base.rb +28 -0
- data/lib/phronomy/agent/context_assembler.rb +1 -1
- data/lib/phronomy/agent/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +484 -27
- data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
- data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
- data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
- data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
- data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
- data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
- data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
- data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
- data/lib/phronomy/agent/recovery_support.rb +15 -23
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
- data/lib/phronomy/agent/tool_invocation.rb +4 -2
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +16 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +60 -0
- data/lib/phronomy/persistence/in_memory.rb +264 -2
- data/lib/phronomy/persistence/repository_facades.rb +221 -2
- data/lib/phronomy/persistence.rb +95 -1
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/version.rb +1 -1
- data/scripts/api_snapshot.rb +3 -3
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +28 -32
- data/sig/phronomy/persistence.rbs +64 -3
- metadata +30 -12
- data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
- data/lib/phronomy/multi_agent/coordinator.rb +0 -154
- data/lib/phronomy/multi_agent/runner.rb +0 -98
|
@@ -19,7 +19,7 @@ module Phronomy
|
|
|
19
19
|
|
|
20
20
|
# EventLoop -> Offload operation-specific immutable snapshots.
|
|
21
21
|
InitialPreparationCommand = Data.define(
|
|
22
|
-
:root, :journal_records, :input, :config
|
|
22
|
+
:root, :journal_records, :input, :config, :preparation_replayable
|
|
23
23
|
)
|
|
24
24
|
ProviderDispatchPreparationCommand = Data.define(
|
|
25
25
|
:execution_id, :fsm_session_id, :expected_execution_revision,
|
|
@@ -43,12 +43,12 @@ module Phronomy
|
|
|
43
43
|
)
|
|
44
44
|
HandoffTerminalView = Data.define(
|
|
45
45
|
:target_agent_id, :responsibility, :selection_intent,
|
|
46
|
-
:llm_call_id, :tool_call_id
|
|
46
|
+
:llm_call_id, :tool_call_id, :policy
|
|
47
47
|
)
|
|
48
48
|
TerminalView = Data.define(
|
|
49
49
|
:phase, :output, :usage, :approval_request, :rejected,
|
|
50
50
|
:input_blocked, :output_blocked, :block_error,
|
|
51
|
-
:invocation_error, :handoff, :callback_failure, :source_error
|
|
51
|
+
:invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested
|
|
52
52
|
)
|
|
53
53
|
TerminalCommitCommand = Data.define(
|
|
54
54
|
:execution_id, :fsm_session_id, :expected_execution_revision,
|
|
@@ -83,6 +83,10 @@ module Phronomy
|
|
|
83
83
|
InitialPreparationReady = Data.define(
|
|
84
84
|
:coordinator, :request, :result, :error
|
|
85
85
|
)
|
|
86
|
+
InitialPreparationRecoveryReady = Data.define(
|
|
87
|
+
:coordinator, :execution_id, :expected_execution_revision,
|
|
88
|
+
:result_task, :load_completion, :result, :error
|
|
89
|
+
)
|
|
86
90
|
ProviderDispatchPreparationReady = Data.define(
|
|
87
91
|
:coordinator, :operation, :result, :error
|
|
88
92
|
)
|
|
@@ -135,7 +139,7 @@ module Phronomy
|
|
|
135
139
|
command = StartCommand.new(
|
|
136
140
|
coordinator: self,
|
|
137
141
|
input: input,
|
|
138
|
-
config: config.dup.freeze,
|
|
142
|
+
config: @agent.__invocation_config(config).dup.freeze,
|
|
139
143
|
mode: mode.to_sym,
|
|
140
144
|
approval_policy: approval_policy,
|
|
141
145
|
approval_listener: approval_listener,
|
|
@@ -361,6 +365,8 @@ module Phronomy
|
|
|
361
365
|
begin_start_on_event_loop(command)
|
|
362
366
|
when InitialPreparationReady
|
|
363
367
|
apply_initial_preparation_on_event_loop(command)
|
|
368
|
+
when InitialPreparationRecoveryReady
|
|
369
|
+
apply_initial_preparation_recovery_on_event_loop(command)
|
|
364
370
|
when ProviderDispatchPreparationReady
|
|
365
371
|
apply_provider_dispatch_preparation_on_event_loop(command)
|
|
366
372
|
when ToolDispatchPreparationReady
|
|
@@ -419,7 +425,12 @@ module Phronomy
|
|
|
419
425
|
root: root,
|
|
420
426
|
journal_records: @agent.send(:_journal_records_snapshot),
|
|
421
427
|
input: request.input,
|
|
422
|
-
config: request.config
|
|
428
|
+
config: request.config,
|
|
429
|
+
preparation_replayable: initial_preparation_replayable?(
|
|
430
|
+
request.input,
|
|
431
|
+
request.config,
|
|
432
|
+
request.approval_policy
|
|
433
|
+
)
|
|
423
434
|
)
|
|
424
435
|
task = runtime.offload.submit(on_full: :raise) do
|
|
425
436
|
perform_initial_preparation(operation)
|
|
@@ -472,7 +483,9 @@ module Phronomy
|
|
|
472
483
|
execution, active_root = admit_execution(
|
|
473
484
|
raw_message,
|
|
474
485
|
root: operation.root,
|
|
475
|
-
mode: (operation.config[:phronomy_recovery_mode] || :invoke).to_sym
|
|
486
|
+
mode: (operation.config[:phronomy_recovery_mode] || :invoke).to_sym,
|
|
487
|
+
config: operation.config,
|
|
488
|
+
preparation_replayable: operation.preparation_replayable
|
|
476
489
|
)
|
|
477
490
|
rescue Phronomy::AgentBusyError => error
|
|
478
491
|
# A durable busy conflict proves that another nonterminal logical
|
|
@@ -517,15 +530,31 @@ module Phronomy
|
|
|
517
530
|
)
|
|
518
531
|
end
|
|
519
532
|
|
|
533
|
+
perform_admitted_initial_preparation(
|
|
534
|
+
input: operation.input,
|
|
535
|
+
config: operation.config,
|
|
536
|
+
execution: execution,
|
|
537
|
+
active_root: active_root,
|
|
538
|
+
journal_records: operation.journal_records
|
|
539
|
+
)
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def perform_admitted_initial_preparation(
|
|
543
|
+
input:,
|
|
544
|
+
config:,
|
|
545
|
+
execution:,
|
|
546
|
+
active_root:,
|
|
547
|
+
journal_records:
|
|
548
|
+
)
|
|
520
549
|
current_execution = execution
|
|
521
550
|
begin
|
|
522
|
-
effective_config =
|
|
551
|
+
effective_config = config
|
|
523
552
|
@agent.send(
|
|
524
553
|
:check_cancellation!,
|
|
525
554
|
effective_config,
|
|
526
555
|
"invocation cancelled before input filtering"
|
|
527
556
|
)
|
|
528
|
-
filtered_input = @agent.send(:run_input_filters!,
|
|
557
|
+
filtered_input = @agent.send(:run_input_filters!, input)
|
|
529
558
|
@agent.send(
|
|
530
559
|
:check_cancellation!,
|
|
531
560
|
effective_config,
|
|
@@ -559,7 +588,7 @@ module Phronomy
|
|
|
559
588
|
assembler = ContextAssembler.new(
|
|
560
589
|
agent: @agent,
|
|
561
590
|
persistence: @agent.persistence,
|
|
562
|
-
journal_records:
|
|
591
|
+
journal_records: journal_records
|
|
563
592
|
)
|
|
564
593
|
prepared = assembler.prepare_initial(
|
|
565
594
|
input: filtered_input,
|
|
@@ -625,7 +654,7 @@ module Phronomy
|
|
|
625
654
|
root: failure.fetch(:root),
|
|
626
655
|
runtime_projection: nil,
|
|
627
656
|
filtered_input: nil,
|
|
628
|
-
config:
|
|
657
|
+
config: config,
|
|
629
658
|
appended_records: failure.fetch(:appended_records),
|
|
630
659
|
error: failure.fetch(:error),
|
|
631
660
|
admission_outcome: :terminal
|
|
@@ -633,7 +662,13 @@ module Phronomy
|
|
|
633
662
|
end
|
|
634
663
|
end
|
|
635
664
|
|
|
636
|
-
def admit_execution(
|
|
665
|
+
def admit_execution(
|
|
666
|
+
raw_message,
|
|
667
|
+
root:,
|
|
668
|
+
mode: :invoke,
|
|
669
|
+
config: {},
|
|
670
|
+
preparation_replayable: false
|
|
671
|
+
)
|
|
637
672
|
if root.lifecycle_status == :closed
|
|
638
673
|
raise Phronomy::Error,
|
|
639
674
|
"agent is closed: #{@agent.agent_id}"
|
|
@@ -646,6 +681,9 @@ module Phronomy
|
|
|
646
681
|
|
|
647
682
|
@agent.persistence.transaction do |tx|
|
|
648
683
|
input_ref = tx.contents.put_text(raw_message)
|
|
684
|
+
durable_context_ref = if config.key?(:durable_context)
|
|
685
|
+
tx.contents.put_json(config.fetch(:durable_context))
|
|
686
|
+
end
|
|
649
687
|
input_record = JournalRecord.new(
|
|
650
688
|
agent_id: @agent.agent_id,
|
|
651
689
|
kind: :input_received,
|
|
@@ -658,8 +696,12 @@ module Phronomy
|
|
|
658
696
|
execution = AgentExecution.start(
|
|
659
697
|
agent_root: root,
|
|
660
698
|
input_record: input_record,
|
|
699
|
+
execution_id: config[:phronomy_reserved_execution_id] || SecureRandom.uuid,
|
|
661
700
|
metadata: {
|
|
701
|
+
"coordination" => config[:phronomy_coordination],
|
|
662
702
|
"current_input_ref" => input_ref,
|
|
703
|
+
"durable_context_ref" => durable_context_ref,
|
|
704
|
+
"preparation_replayable" => !!preparation_replayable,
|
|
663
705
|
RecoverySupport::CONTRACT_VERSION_KEY =>
|
|
664
706
|
RecoverySupport::CONTRACT_VERSION,
|
|
665
707
|
RecoverySupport::INVOCATION_MODE_KEY => mode.to_s,
|
|
@@ -678,6 +720,7 @@ module Phronomy
|
|
|
678
720
|
execution_revision: 0,
|
|
679
721
|
working_records: [input_record]
|
|
680
722
|
)
|
|
723
|
+
validate_coordination_admission!(tx, execution)
|
|
681
724
|
tx.executions.create_active(execution)
|
|
682
725
|
next_root = root.with(
|
|
683
726
|
agent_revision: root.agent_revision + 1,
|
|
@@ -692,6 +735,68 @@ module Phronomy
|
|
|
692
735
|
[execution, next_root]
|
|
693
736
|
end
|
|
694
737
|
|
|
738
|
+
def validate_coordination_admission!(tx, execution)
|
|
739
|
+
owner = execution.metadata["coordination"]
|
|
740
|
+
return unless owner
|
|
741
|
+
case owner.fetch("kind")
|
|
742
|
+
when "team"
|
|
743
|
+
team = tx.team_executions.load(owner.fetch("team_execution_id"))
|
|
744
|
+
unless team.team_id == owner.fetch("team_id") && team.active? && !team.metadata["cancel_requested"]
|
|
745
|
+
raise Phronomy::CancellationError, "Team run is no longer dispatchable"
|
|
746
|
+
end
|
|
747
|
+
slot = if owner.fetch("slot") == "coordinator"
|
|
748
|
+
team.coordinator
|
|
749
|
+
else
|
|
750
|
+
assignment = team.assignments.find { |entry| entry.fetch("task_id") == owner.fetch("slot") }
|
|
751
|
+
worker = assignment && team.workers.fetch(assignment.fetch("worker"))
|
|
752
|
+
worker&.merge("execution_id" => assignment.fetch("execution_id"))
|
|
753
|
+
end
|
|
754
|
+
unless slot && slot.fetch("execution_id") == execution.execution_id && slot.fetch("agent_id") == execution.agent_id
|
|
755
|
+
raise Phronomy::Persistence::ConflictError, "Team reserved child identity mismatch"
|
|
756
|
+
end
|
|
757
|
+
when "subagent"
|
|
758
|
+
parent = tx.executions.load(owner.fetch("parent_execution_id"))
|
|
759
|
+
unless parent.agent_id == owner.fetch("parent_agent_id") && parent.active? && !parent.metadata["coordination_cancel_requested"]
|
|
760
|
+
raise Phronomy::CancellationError, "Parent run is no longer dispatchable"
|
|
761
|
+
end
|
|
762
|
+
snapshot = tx.contents.fetch_json(parent.metadata.fetch("multi_agent_coordination_ref"))
|
|
763
|
+
slot = snapshot.fetch("children").find { |entry| entry.fetch("slot") == owner.fetch("slot") }
|
|
764
|
+
unless slot && slot.fetch("agent_id") == execution.agent_id && slot.fetch("execution_id") == execution.execution_id
|
|
765
|
+
raise Phronomy::Persistence::ConflictError, "Parent reserved child identity mismatch"
|
|
766
|
+
end
|
|
767
|
+
when "handoff"
|
|
768
|
+
routing = tx.handoff_states.load(owner.fetch("main_agent_id"))
|
|
769
|
+
unless routing && routing.active_agent_id == execution.agent_id
|
|
770
|
+
raise Phronomy::Persistence::ConflictError, "Handoff responsibility changed before admission"
|
|
771
|
+
end
|
|
772
|
+
if Array(routing.metadata["cancelled_execution_ids"]).include?(execution.execution_id)
|
|
773
|
+
raise Phronomy::CancellationError, "Handoff reservation was cancelled"
|
|
774
|
+
end
|
|
775
|
+
if routing.phase != "stable" && routing.pending_target_execution_id != execution.execution_id
|
|
776
|
+
raise Phronomy::Persistence::ConflictError, "Handoff reserved Target identity mismatch"
|
|
777
|
+
end
|
|
778
|
+
else
|
|
779
|
+
raise Phronomy::ConfigurationError, "Unknown coordination owner kind"
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def initial_preparation_replayable?(input, config, approval_policy)
|
|
784
|
+
return false unless input.is_a?(String)
|
|
785
|
+
return false unless approval_policy.nil?
|
|
786
|
+
if config.key?(:phronomy_handoff_bindings) ||
|
|
787
|
+
config.key?(:phronomy_handoff_context)
|
|
788
|
+
return false unless config[:phronomy_coordination]&.fetch("kind", nil) == "handoff"
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
invocation_context = config[:invocation_context]
|
|
792
|
+
return true unless invocation_context
|
|
793
|
+
|
|
794
|
+
%i[approval_policy redaction_policy token_budget].none? do |name|
|
|
795
|
+
invocation_context.respond_to?(name) &&
|
|
796
|
+
!invocation_context.public_send(name).nil?
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
695
800
|
def commit_preparation_failure(execution, root, error)
|
|
696
801
|
translated_error = translated(error)
|
|
697
802
|
failed = next_root = appended = nil
|
|
@@ -898,6 +1003,243 @@ module Phronomy
|
|
|
898
1003
|
)
|
|
899
1004
|
end
|
|
900
1005
|
|
|
1006
|
+
# Restarts only the replay-safe initial preparation region of an already
|
|
1007
|
+
# durably admitted logical Execution. Runtime-only caller state is not
|
|
1008
|
+
# reconstructed here; the durable preparation inputs are authoritative.
|
|
1009
|
+
# @api private
|
|
1010
|
+
def start_initial_preparation_recovery_on_event_loop(
|
|
1011
|
+
execution,
|
|
1012
|
+
result_task,
|
|
1013
|
+
load_completion:
|
|
1014
|
+
)
|
|
1015
|
+
runtime = Phronomy::Runtime.instance
|
|
1016
|
+
event_loop = runtime.event_loop
|
|
1017
|
+
assert_event_loop!(event_loop)
|
|
1018
|
+
state = event_loop.agent_execution_state(execution.execution_id)
|
|
1019
|
+
unless state && state.agent.equal?(@agent) &&
|
|
1020
|
+
state.execution.execution_revision == execution.execution_revision &&
|
|
1021
|
+
state.execution.status == :preparing &&
|
|
1022
|
+
state.execution.phase.to_sym == :preparing
|
|
1023
|
+
error = Phronomy::ExecutionRehydrationRequiredError.new(
|
|
1024
|
+
"stale :preparing Recovery installation for #{execution.execution_id}"
|
|
1025
|
+
)
|
|
1026
|
+
fail_task(result_task, error)
|
|
1027
|
+
load_completion.fail(error)
|
|
1028
|
+
return
|
|
1029
|
+
end
|
|
1030
|
+
|
|
1031
|
+
root = @agent.agent_root
|
|
1032
|
+
journal_records = @agent.send(:_journal_records_snapshot)
|
|
1033
|
+
task = runtime.offload.submit(on_full: :raise) do
|
|
1034
|
+
perform_initial_preparation_recovery(
|
|
1035
|
+
execution,
|
|
1036
|
+
root: root,
|
|
1037
|
+
journal_records: journal_records
|
|
1038
|
+
)
|
|
1039
|
+
end
|
|
1040
|
+
task.on_complete do |result, error|
|
|
1041
|
+
ready = InitialPreparationRecoveryReady.new(
|
|
1042
|
+
coordinator: self,
|
|
1043
|
+
execution_id: execution.execution_id.to_s.freeze,
|
|
1044
|
+
expected_execution_revision: execution.execution_revision,
|
|
1045
|
+
result_task: result_task,
|
|
1046
|
+
load_completion: load_completion,
|
|
1047
|
+
result: result,
|
|
1048
|
+
error: error
|
|
1049
|
+
)
|
|
1050
|
+
unless post_control(runtime, ready)
|
|
1051
|
+
rejected = runtime_rejected_error(:initial_preparation_recovery)
|
|
1052
|
+
fail_task(result_task, rejected)
|
|
1053
|
+
load_completion.fail(rejected)
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
rescue => error
|
|
1057
|
+
# simplecov:disable
|
|
1058
|
+
begin
|
|
1059
|
+
if defined?(event_loop) && event_loop.current?
|
|
1060
|
+
release_initial_preparation_recovery_runtime_state(
|
|
1061
|
+
event_loop,
|
|
1062
|
+
execution.execution_id
|
|
1063
|
+
)
|
|
1064
|
+
end
|
|
1065
|
+
rescue
|
|
1066
|
+
nil
|
|
1067
|
+
end
|
|
1068
|
+
translated_error = translated(error)
|
|
1069
|
+
fail_task(result_task, translated_error)
|
|
1070
|
+
load_completion.fail(translated_error)
|
|
1071
|
+
# simplecov:enable
|
|
1072
|
+
end
|
|
1073
|
+
|
|
1074
|
+
def perform_initial_preparation_recovery(
|
|
1075
|
+
execution,
|
|
1076
|
+
root:,
|
|
1077
|
+
journal_records:
|
|
1078
|
+
)
|
|
1079
|
+
unless execution.metadata["preparation_replayable"] == true
|
|
1080
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
1081
|
+
"execution #{execution.execution_id} has no replay-safe initial preparation contract"
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
input_ref = execution.metadata.fetch("current_input_ref")
|
|
1085
|
+
input = @agent.persistence.contents.fetch_text(input_ref)
|
|
1086
|
+
mode = (
|
|
1087
|
+
execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] || "invoke"
|
|
1088
|
+
).to_sym
|
|
1089
|
+
config = {phronomy_recovery_mode: mode}.merge(@agent.__coordination_config)
|
|
1090
|
+
|
|
1091
|
+
if execution.metadata.key?("durable_context_ref")
|
|
1092
|
+
durable_context_ref = execution.metadata.fetch("durable_context_ref")
|
|
1093
|
+
durable_context = @agent.persistence.contents.fetch_json(
|
|
1094
|
+
durable_context_ref
|
|
1095
|
+
)
|
|
1096
|
+
unless durable_context.is_a?(Hash)
|
|
1097
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
1098
|
+
"execution #{execution.execution_id} has a non-Hash durable_context"
|
|
1099
|
+
end
|
|
1100
|
+
Phronomy::Agent::Immutable.validate_canonical_json!(
|
|
1101
|
+
durable_context,
|
|
1102
|
+
label: "Recovered durable_context"
|
|
1103
|
+
)
|
|
1104
|
+
config[:durable_context] =
|
|
1105
|
+
Phronomy::Agent::Immutable.copy(durable_context)
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
perform_admitted_initial_preparation(
|
|
1109
|
+
input: input,
|
|
1110
|
+
config: config.freeze,
|
|
1111
|
+
execution: execution,
|
|
1112
|
+
active_root: root,
|
|
1113
|
+
journal_records: journal_records
|
|
1114
|
+
)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
def apply_initial_preparation_recovery_on_event_loop(ready)
|
|
1118
|
+
event_loop = Phronomy::Runtime.instance.event_loop
|
|
1119
|
+
state = event_loop.agent_execution_state(ready.execution_id)
|
|
1120
|
+
unless state && state.agent.equal?(@agent) &&
|
|
1121
|
+
state.execution.execution_revision == ready.expected_execution_revision &&
|
|
1122
|
+
state.execution.status == :preparing &&
|
|
1123
|
+
state.execution.phase.to_sym == :preparing
|
|
1124
|
+
error = Phronomy::ExecutionRehydrationRequiredError.new(
|
|
1125
|
+
"stale initial preparation Recovery result for #{ready.execution_id}"
|
|
1126
|
+
)
|
|
1127
|
+
fail_task(ready.result_task, error)
|
|
1128
|
+
ready.load_completion.fail(error)
|
|
1129
|
+
return
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
if ready.error
|
|
1133
|
+
release_initial_preparation_recovery_runtime_state(
|
|
1134
|
+
event_loop,
|
|
1135
|
+
ready.execution_id
|
|
1136
|
+
)
|
|
1137
|
+
error = translated(ready.error)
|
|
1138
|
+
fail_task(ready.result_task, error)
|
|
1139
|
+
ready.load_completion.fail(error)
|
|
1140
|
+
return
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1143
|
+
result = ready.result
|
|
1144
|
+
case result.admission_outcome
|
|
1145
|
+
when :terminal
|
|
1146
|
+
apply_agent_live_state(
|
|
1147
|
+
root: result.root,
|
|
1148
|
+
appended_records: result.appended_records
|
|
1149
|
+
)
|
|
1150
|
+
event_loop.replace_agent_execution(
|
|
1151
|
+
ready.execution_id,
|
|
1152
|
+
execution: result.execution
|
|
1153
|
+
)
|
|
1154
|
+
event_loop.release_agent_execution(ready.execution_id)
|
|
1155
|
+
event_loop.release_agent_execution_admission(
|
|
1156
|
+
@agent.agent_id,
|
|
1157
|
+
execution_id: ready.execution_id
|
|
1158
|
+
)
|
|
1159
|
+
event_type = terminal_event_type(result.error)
|
|
1160
|
+
callback_error = deliver_terminal(
|
|
1161
|
+
@agent.send(:_phronomy_event_listener),
|
|
1162
|
+
event_type,
|
|
1163
|
+
error: result.error
|
|
1164
|
+
)
|
|
1165
|
+
settle_after_terminal(
|
|
1166
|
+
ready.result_task,
|
|
1167
|
+
callback_error,
|
|
1168
|
+
event_type,
|
|
1169
|
+
nil,
|
|
1170
|
+
result.error
|
|
1171
|
+
)
|
|
1172
|
+
ready.load_completion.complete(@agent)
|
|
1173
|
+
when :active
|
|
1174
|
+
apply_agent_live_state(
|
|
1175
|
+
root: result.root,
|
|
1176
|
+
appended_records: result.appended_records
|
|
1177
|
+
)
|
|
1178
|
+
event_loop.release_agent_execution(ready.execution_id)
|
|
1179
|
+
mode = (
|
|
1180
|
+
result.execution.metadata[RecoverySupport::INVOCATION_MODE_KEY] ||
|
|
1181
|
+
"invoke"
|
|
1182
|
+
).to_sym
|
|
1183
|
+
request = StartCommand.new(
|
|
1184
|
+
coordinator: self,
|
|
1185
|
+
input: result.filtered_input,
|
|
1186
|
+
config: result.config,
|
|
1187
|
+
mode: mode,
|
|
1188
|
+
approval_policy: nil,
|
|
1189
|
+
approval_listener: nil,
|
|
1190
|
+
on_event: @agent.send(:_phronomy_event_listener),
|
|
1191
|
+
result_task: ready.result_task,
|
|
1192
|
+
admission_token: Object.new.freeze
|
|
1193
|
+
)
|
|
1194
|
+
begin
|
|
1195
|
+
register_initial_session_on_event_loop(request, result)
|
|
1196
|
+
rescue => error
|
|
1197
|
+
begin_terminal_without_session_on_event_loop(
|
|
1198
|
+
request: request,
|
|
1199
|
+
prepared: result,
|
|
1200
|
+
error: error
|
|
1201
|
+
)
|
|
1202
|
+
end
|
|
1203
|
+
ready.load_completion.complete(@agent)
|
|
1204
|
+
else
|
|
1205
|
+
error = Phronomy::ExecutionRehydrationRequiredError.new(
|
|
1206
|
+
"unexpected initial preparation Recovery outcome: #{result.admission_outcome.inspect}"
|
|
1207
|
+
)
|
|
1208
|
+
release_initial_preparation_recovery_runtime_state(
|
|
1209
|
+
event_loop,
|
|
1210
|
+
ready.execution_id
|
|
1211
|
+
)
|
|
1212
|
+
fail_task(ready.result_task, error)
|
|
1213
|
+
ready.load_completion.fail(error)
|
|
1214
|
+
end
|
|
1215
|
+
rescue => error
|
|
1216
|
+
# simplecov:disable
|
|
1217
|
+
begin
|
|
1218
|
+
release_initial_preparation_recovery_runtime_state(
|
|
1219
|
+
event_loop,
|
|
1220
|
+
ready.execution_id
|
|
1221
|
+
)
|
|
1222
|
+
rescue
|
|
1223
|
+
nil
|
|
1224
|
+
end
|
|
1225
|
+
translated_error = translated(error)
|
|
1226
|
+
fail_task(ready.result_task, translated_error)
|
|
1227
|
+
ready.load_completion.fail(translated_error)
|
|
1228
|
+
# simplecov:enable
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
def release_initial_preparation_recovery_runtime_state(
|
|
1232
|
+
event_loop,
|
|
1233
|
+
execution_id
|
|
1234
|
+
)
|
|
1235
|
+
state = event_loop.agent_execution_state(execution_id)
|
|
1236
|
+
event_loop.release_agent_execution(execution_id) if state
|
|
1237
|
+
event_loop.release_agent_execution_admission(
|
|
1238
|
+
@agent.agent_id,
|
|
1239
|
+
execution_id: execution_id
|
|
1240
|
+
)
|
|
1241
|
+
end
|
|
1242
|
+
|
|
901
1243
|
# ----------------------------------------------------------------------
|
|
902
1244
|
# External-operation causal durable barriers
|
|
903
1245
|
# ----------------------------------------------------------------------
|
|
@@ -973,6 +1315,7 @@ module Phronomy
|
|
|
973
1315
|
"manifest_refs" => refs
|
|
974
1316
|
)
|
|
975
1317
|
)
|
|
1318
|
+
updated = @agent.__prepare_coordination_record(updated, tx: tx)
|
|
976
1319
|
tx.executions.save(
|
|
977
1320
|
operation.execution.execution_id,
|
|
978
1321
|
expected_revision: operation.execution.execution_revision,
|
|
@@ -1022,6 +1365,7 @@ module Phronomy
|
|
|
1022
1365
|
metadata.delete(RecoverySupport::PENDING_LLM_ID_KEY)
|
|
1023
1366
|
metadata.delete(RecoverySupport::PENDING_LLM_STARTED_AT_KEY)
|
|
1024
1367
|
metadata.delete(RecoverySupport::RECOVERY_METADATA_KEY)
|
|
1368
|
+
metadata.delete("framework_calls_pending")
|
|
1025
1369
|
metadata[RecoverySupport::TOOL_BATCH_METADATA_KEY] =
|
|
1026
1370
|
RecoverySupport.canonical_copy(operation.tool_batch_snapshot)
|
|
1027
1371
|
metadata[RecoverySupport::CONTRACT_VERSION_KEY] =
|
|
@@ -1032,6 +1376,7 @@ module Phronomy
|
|
|
1032
1376
|
llm_calls: operation.execution.llm_calls + call_records,
|
|
1033
1377
|
metadata: metadata
|
|
1034
1378
|
)
|
|
1379
|
+
updated = @agent.__prepare_coordination_record(updated, tx: tx)
|
|
1035
1380
|
tx.executions.save(
|
|
1036
1381
|
operation.execution.execution_id,
|
|
1037
1382
|
expected_revision: operation.execution.execution_revision,
|
|
@@ -1615,6 +1960,27 @@ module Phronomy
|
|
|
1615
1960
|
end
|
|
1616
1961
|
end
|
|
1617
1962
|
|
|
1963
|
+
def start_framework_tools_on_event_loop(execution_id, result_task)
|
|
1964
|
+
runtime = Phronomy::Runtime.instance
|
|
1965
|
+
event_loop = runtime.event_loop
|
|
1966
|
+
state = event_loop.agent_execution_state(execution_id)
|
|
1967
|
+
invocation = state.invocation
|
|
1968
|
+
session = AgentInvocationSessionBuilder.build_for_resume(agent_invocation: invocation,
|
|
1969
|
+
resume_event: :resume, resume_phase: :suspended, runtime: runtime)
|
|
1970
|
+
event_loop.replace_agent_execution(execution_id, invocation: invocation, fsm_session_id: session.id)
|
|
1971
|
+
event_loop.mark_agent_execution_admission(@agent.agent_id, execution_id: execution_id, state: :executing)
|
|
1972
|
+
source = Phronomy::Task.deferred(name: "framework-tool-recovery-source")
|
|
1973
|
+
source.on_complete do |result, error|
|
|
1974
|
+
finish_on_event_loop(execution_id, result_task, result || session.context, error, fsm_session_id: session.id)
|
|
1975
|
+
end
|
|
1976
|
+
event_loop.register(session, completion: source)
|
|
1977
|
+
invocation.tool_invocations.select(&:authorized?).each do |child|
|
|
1978
|
+
child_session = ToolInvocationSessionBuilder.build_for_resume(tool_invocation: child,
|
|
1979
|
+
parent_event_sink: session.event_sink, resume_event: :dispatch, resume_phase: :authorized, runtime: runtime)
|
|
1980
|
+
register_child(event_loop, child, child_session, session.event_sink)
|
|
1981
|
+
end
|
|
1982
|
+
end
|
|
1983
|
+
|
|
1618
1984
|
def register_child(event_loop, child, session, parent_event_sink)
|
|
1619
1985
|
completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
|
|
1620
1986
|
completion.on_complete do |_result, error|
|
|
@@ -1849,7 +2215,8 @@ module Phronomy
|
|
|
1849
2215
|
invocation_error: invocation&.error,
|
|
1850
2216
|
handoff: handoff_terminal_view(invocation&.handoff_request),
|
|
1851
2217
|
callback_failure: invocation&.callback_failure,
|
|
1852
|
-
source_error: source_error
|
|
2218
|
+
source_error: source_error,
|
|
2219
|
+
cancel_requested: invocation&.config&.fetch(:cancellation_token, nil)&.cancelled? || false
|
|
1853
2220
|
)
|
|
1854
2221
|
end
|
|
1855
2222
|
|
|
@@ -1863,7 +2230,8 @@ module Phronomy
|
|
|
1863
2230
|
[category.to_s.freeze, !!included]
|
|
1864
2231
|
end.freeze,
|
|
1865
2232
|
llm_call_id: request.llm_call_id&.to_s&.freeze,
|
|
1866
|
-
tool_call_id: request.tool_call_id&.to_s&.freeze
|
|
2233
|
+
tool_call_id: request.tool_call_id&.to_s&.freeze,
|
|
2234
|
+
policy: Immutable.copy(request.handoff.policy.to_h)
|
|
1867
2235
|
)
|
|
1868
2236
|
end
|
|
1869
2237
|
|
|
@@ -1873,22 +2241,77 @@ module Phronomy
|
|
|
1873
2241
|
|
|
1874
2242
|
def compute_terminal(operation)
|
|
1875
2243
|
view = operation.terminal_view
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
view.
|
|
1880
|
-
)
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
if
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
raise view.block_error if view.input_blocked || view.output_blocked
|
|
1887
|
-
raise view.invocation_error if view.invocation_error
|
|
1888
|
-
|
|
2244
|
+
error = view.callback_failure&.to_stream_callback_error || view.source_error ||
|
|
2245
|
+
view.block_error || view.invocation_error
|
|
2246
|
+
if operation.execution.metadata["multi_agent_coordination_ref"] &&
|
|
2247
|
+
(error.is_a?(Phronomy::ExecutionRehydrationRequiredError) || view.cancel_requested)
|
|
2248
|
+
waiting = commit_coordination_wait(operation, error)
|
|
2249
|
+
return waiting if waiting
|
|
2250
|
+
end
|
|
2251
|
+
raise error if error.is_a?(Phronomy::ExecutionRehydrationRequiredError)
|
|
2252
|
+
return commit_failed_outcome(operation, error) if error
|
|
2253
|
+
return commit_suspended(operation) if view.phase == :suspended
|
|
1889
2254
|
commit_completed(operation)
|
|
1890
2255
|
rescue => caught
|
|
1891
|
-
|
|
2256
|
+
reconcile_terminal_error(operation, caught)
|
|
2257
|
+
end
|
|
2258
|
+
|
|
2259
|
+
# Reuse the Agent barrier to retain unfinished owned children. This is an
|
|
2260
|
+
# active AgentExecution snapshot, not a second scheduler or terminal state.
|
|
2261
|
+
def commit_coordination_wait(operation, error)
|
|
2262
|
+
current = operation.execution
|
|
2263
|
+
waiting = nil
|
|
2264
|
+
@agent.persistence.transaction do |tx|
|
|
2265
|
+
refreshed = @agent.__prepare_coordination_record(current, tx: tx)
|
|
2266
|
+
snapshot = tx.contents.fetch_json(refreshed.metadata.fetch("multi_agent_coordination_ref"))
|
|
2267
|
+
unresolved = snapshot.fetch("children").any? do |child|
|
|
2268
|
+
!%w[completed failed cancelled rejected blocked handed_off].include?(child.fetch("state")) &&
|
|
2269
|
+
!(operation.terminal_view.cancel_requested && child.fetch("state") == "reserved")
|
|
2270
|
+
end
|
|
2271
|
+
next unless unresolved
|
|
2272
|
+
waiting = current.with(metadata: refreshed.metadata.merge(
|
|
2273
|
+
"coordination_cancel_requested" => operation.terminal_view.cancel_requested || current.metadata["coordination_cancel_requested"] == true
|
|
2274
|
+
))
|
|
2275
|
+
tx.executions.save(current.execution_id, expected_revision: current.execution_revision, execution: waiting)
|
|
2276
|
+
end
|
|
2277
|
+
return unless waiting
|
|
2278
|
+
coordination_wait_outcome(operation, waiting, error)
|
|
2279
|
+
rescue => failure
|
|
2280
|
+
confirmed = @agent.persistence.executions.load(current.execution_id)
|
|
2281
|
+
raise failure unless waiting && confirmed.to_h == waiting.to_h
|
|
2282
|
+
coordination_wait_outcome(operation, confirmed, error)
|
|
2283
|
+
end
|
|
2284
|
+
|
|
2285
|
+
def coordination_wait_outcome(operation, execution, error)
|
|
2286
|
+
TerminalOutcome.new(type: :coordination_wait, execution: execution, root: operation.root,
|
|
2287
|
+
appended_records: [].freeze, result: nil,
|
|
2288
|
+
error: error.is_a?(Phronomy::ExecutionRehydrationRequiredError) ? error :
|
|
2289
|
+
Phronomy::ExecutionRehydrationRequiredError.new("Execution #{execution.execution_id} retains unfinished children for cancellation/recovery"),
|
|
2290
|
+
approval_request: nil)
|
|
2291
|
+
end
|
|
2292
|
+
|
|
2293
|
+
# F1: terminal atomicity alone does not establish commit outcome certainty.
|
|
2294
|
+
# Reuse an exact committed outcome; an absent/active/read-failed result is
|
|
2295
|
+
# never permission to write a second terminal transition.
|
|
2296
|
+
def reconcile_terminal_error(operation, error)
|
|
2297
|
+
confirmed = @agent.persistence.executions.load(operation.execution_id)
|
|
2298
|
+
raise error unless confirmed.terminal? &&
|
|
2299
|
+
confirmed.agent_id == @agent.agent_id &&
|
|
2300
|
+
confirmed.execution_revision == operation.expected_execution_revision + 1
|
|
2301
|
+
root = @agent.persistence.agents.load(@agent.agent_id)
|
|
2302
|
+
records = @agent.persistence.journals.read(@agent.agent_id,
|
|
2303
|
+
after: operation.root.journal_position, limit: root.journal_position - operation.root.journal_position)
|
|
2304
|
+
result = @agent.persistence.execution_result(confirmed.execution_id)
|
|
2305
|
+
failure = result[:error] && RecoverySupport.error_from_failure(result[:error])
|
|
2306
|
+
type = if failure
|
|
2307
|
+
:failed
|
|
2308
|
+
else
|
|
2309
|
+
((confirmed.status == :handed_off) ? :handed_off : :completed)
|
|
2310
|
+
end
|
|
2311
|
+
TerminalOutcome.new(type: type, execution: confirmed, root: root,
|
|
2312
|
+
appended_records: records.freeze,
|
|
2313
|
+
result: failure ? nil : result_base(confirmed, root).merge(output: result[:result]).freeze,
|
|
2314
|
+
error: failure, approval_request: nil)
|
|
1892
2315
|
end
|
|
1893
2316
|
|
|
1894
2317
|
def commit_suspended(operation)
|
|
@@ -1953,6 +2376,18 @@ module Phronomy
|
|
|
1953
2376
|
)
|
|
1954
2377
|
end
|
|
1955
2378
|
|
|
2379
|
+
def synchronize_handoff_target(tx, execution)
|
|
2380
|
+
coordination = execution.metadata["coordination"]
|
|
2381
|
+
return unless coordination && coordination["kind"] == "handoff"
|
|
2382
|
+
routing = tx.handoff_states.load(coordination.fetch("main_agent_id"))
|
|
2383
|
+
return unless routing && routing.pending_target_execution_id == execution.execution_id && routing.phase != "stable"
|
|
2384
|
+
unless routing.active_agent_id == execution.agent_id
|
|
2385
|
+
raise Phronomy::Persistence::ConflictError, "Handoff Target owner mismatch"
|
|
2386
|
+
end
|
|
2387
|
+
tx.handoff_states.save(routing.main_agent_id, expected_revision: routing.handoff_revision,
|
|
2388
|
+
state: routing.with(phase: "stable"))
|
|
2389
|
+
end
|
|
2390
|
+
|
|
1956
2391
|
def commit_completed(operation)
|
|
1957
2392
|
current = operation.execution
|
|
1958
2393
|
root = operation.root
|
|
@@ -2004,6 +2439,8 @@ module Phronomy
|
|
|
2004
2439
|
result_ref: output_ref,
|
|
2005
2440
|
terminal_reason: view.rejected ? "rejected" : "completed"
|
|
2006
2441
|
)
|
|
2442
|
+
completed = @agent.__prepare_coordination_record(completed, tx: tx)
|
|
2443
|
+
synchronize_handoff_target(tx, completed)
|
|
2007
2444
|
tx.executions.save(
|
|
2008
2445
|
current.execution_id,
|
|
2009
2446
|
expected_revision: current.execution_revision,
|
|
@@ -2097,6 +2534,8 @@ module Phronomy
|
|
|
2097
2534
|
error_ref: error_ref,
|
|
2098
2535
|
terminal_reason: translated_error.class.name
|
|
2099
2536
|
)
|
|
2537
|
+
failed = @agent.__prepare_coordination_record(failed, tx: tx)
|
|
2538
|
+
synchronize_handoff_target(tx, failed)
|
|
2100
2539
|
tx.executions.save(
|
|
2101
2540
|
current.execution_id,
|
|
2102
2541
|
expected_revision: current.execution_revision,
|
|
@@ -2142,6 +2581,14 @@ module Phronomy
|
|
|
2142
2581
|
end
|
|
2143
2582
|
|
|
2144
2583
|
if ready.error
|
|
2584
|
+
if operation.execution.metadata["coordination"] || operation.execution.metadata["multi_agent_coordination_ref"]
|
|
2585
|
+
event_loop.release_agent_execution(operation.execution_id) if state
|
|
2586
|
+
event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
|
|
2587
|
+
event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
|
|
2588
|
+
task.fail(ready.error)
|
|
2589
|
+
end
|
|
2590
|
+
return
|
|
2591
|
+
end
|
|
2145
2592
|
event_loop.mark_agent_execution_admission(
|
|
2146
2593
|
@agent.agent_id,
|
|
2147
2594
|
execution_id: operation.execution_id,
|
|
@@ -2169,6 +2616,10 @@ module Phronomy
|
|
|
2169
2616
|
end
|
|
2170
2617
|
|
|
2171
2618
|
case outcome.type
|
|
2619
|
+
when :coordination_wait
|
|
2620
|
+
event_loop.release_agent_execution(operation.execution_id) if state
|
|
2621
|
+
event_loop.release_agent_execution_admission(@agent.agent_id, execution_id: operation.execution_id)
|
|
2622
|
+
event_loop.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
|
|
2172
2623
|
when :suspended
|
|
2173
2624
|
event_loop.mark_agent_execution_admission(
|
|
2174
2625
|
@agent.agent_id,
|
|
@@ -2186,6 +2637,9 @@ module Phronomy
|
|
|
2186
2637
|
:approval_required,
|
|
2187
2638
|
outcome.result
|
|
2188
2639
|
)
|
|
2640
|
+
Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
|
|
2641
|
+
task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
|
|
2642
|
+
end
|
|
2189
2643
|
when :completed
|
|
2190
2644
|
event_loop.release_agent_execution(operation.execution_id) if state
|
|
2191
2645
|
event_loop.release_agent_execution_admission(
|
|
@@ -2350,6 +2804,9 @@ module Phronomy
|
|
|
2350
2804
|
llm_call_id = payload[:llm_call_id] || payload["llm_call_id"]
|
|
2351
2805
|
tool_call_id = payload.fetch(:tool_call_id).to_s
|
|
2352
2806
|
tool_name = payload.fetch(:tool_name).to_s
|
|
2807
|
+
next if execution.working_records.any? do |record|
|
|
2808
|
+
record.kind == :tool_message && record.causation_id.to_s == tool_call_id && record.llm_call_id.to_s == llm_call_id.to_s
|
|
2809
|
+
end
|
|
2353
2810
|
result_ref = put_runtime_content(tx, payload.fetch(:tool_result))
|
|
2354
2811
|
records << JournalRecord.new(
|
|
2355
2812
|
agent_id: @agent.agent_id,
|