phronomy 0.22.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 +3 -4
- data/CHANGELOG.md +215 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +147 -0
- data/docs/architecture/persistence.md +186 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +89 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- 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 +176 -0
- 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 +65 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +162 -72
- data/lib/phronomy/agent/base.rb +416 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- 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 +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +339 -103
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
- 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 +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- 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/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -1,210 +1,558 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require "digest"
|
|
4
6
|
|
|
5
7
|
module Phronomy
|
|
6
8
|
module MultiAgent
|
|
7
|
-
#
|
|
9
|
+
# Durable purpose-specific task allocation. Agent owns every Provider/Tool
|
|
10
|
+
# execution; TeamExecution owns queue, assignments and aggregate facts.
|
|
11
|
+
# Scheduling and aggregation must be pure, replay-safe result calculations.
|
|
12
|
+
# @api public
|
|
8
13
|
class TeamCoordinator
|
|
9
|
-
WorkerState =
|
|
10
|
-
:
|
|
11
|
-
:agent,
|
|
12
|
-
:transcript_size,
|
|
13
|
-
:status
|
|
14
|
-
) do
|
|
15
|
-
def available? = %i[idle available].include?(status)
|
|
14
|
+
WorkerState = Data.define(:index, :agent_id, :transcript_size, :status) do
|
|
15
|
+
def available? = status == :available
|
|
16
16
|
end
|
|
17
17
|
private_constant :WorkerState
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
20
|
# @api public
|
|
21
|
-
def
|
|
22
|
-
|
|
21
|
+
def team_definition(id: nil, version: nil)
|
|
22
|
+
if id || version
|
|
23
|
+
definition_id = id || name
|
|
24
|
+
unless definition_id && !definition_id.to_s.empty? && version && Integer(version).positive?
|
|
25
|
+
raise ArgumentError, "team_definition requires id and positive version"
|
|
26
|
+
end
|
|
27
|
+
@team_definition = {id: (id || name).to_s.freeze, version: Integer(version)}.freeze
|
|
28
|
+
end
|
|
29
|
+
@team_definition || raise(Phronomy::ConfigurationError, "Team must declare team_definition id: ..., version: ...")
|
|
23
30
|
end
|
|
24
31
|
|
|
25
32
|
# @api public
|
|
26
|
-
def
|
|
27
|
-
value ? @coordinator_instructions = value : @coordinator_instructions
|
|
28
|
-
end
|
|
29
|
-
|
|
33
|
+
def coordinator_model(value = nil) = value ? @coordinator_model = value : @coordinator_model
|
|
30
34
|
# @api public
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
def coordinator_instructions(value = nil) = value ? @coordinator_instructions = value : @coordinator_instructions
|
|
36
|
+
# @api public
|
|
37
|
+
def coordinator_provider(value = nil) = value ? @coordinator_provider = value : @coordinator_provider
|
|
34
38
|
|
|
35
39
|
# @api public
|
|
36
40
|
def pool(size:, agent:, on_error: :raise)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
raise ArgumentError, "pool size must be positive" unless Integer(size).positive?
|
|
42
|
+
raise ArgumentError, "on_error must be :raise or :skip" unless %i[raise skip].include?(on_error)
|
|
43
|
+
raise ArgumentError, "pool agent must be an Agent class" unless agent.is_a?(Class) && agent <= Phronomy::Agent::Base
|
|
44
|
+
@pool_size, @worker_agent, @on_error = Integer(size), agent, on_error
|
|
40
45
|
end
|
|
41
46
|
|
|
42
47
|
# @api public
|
|
43
|
-
def schedule(&block)
|
|
44
|
-
|
|
48
|
+
def schedule(&block) = @scheduler = block
|
|
49
|
+
# @api public
|
|
50
|
+
def aggregate(&block) = @aggregator = block
|
|
51
|
+
|
|
52
|
+
# @api public
|
|
53
|
+
def new(team_id: SecureRandom.uuid, persistence: nil, metadata: {}, on_event: nil)
|
|
54
|
+
construct(team_id, persistence, metadata, on_event, create: true)
|
|
45
55
|
end
|
|
46
56
|
|
|
47
57
|
# @api public
|
|
48
|
-
def
|
|
49
|
-
|
|
58
|
+
def create(**options) = new(**options)
|
|
59
|
+
|
|
60
|
+
# Hydrates Team facts only; use resume with the exact run ID to continue.
|
|
61
|
+
# @api public
|
|
62
|
+
def load(team_id, persistence:, on_event: nil)
|
|
63
|
+
owner = get(team_id)
|
|
64
|
+
if owner && on_event
|
|
65
|
+
raise Phronomy::ConfigurationError, "Team #{team_id} is already live; listener cannot be rebound"
|
|
66
|
+
end
|
|
67
|
+
construct(team_id, persistence, {}, on_event, create: false)
|
|
50
68
|
end
|
|
51
69
|
|
|
70
|
+
# @api public
|
|
71
|
+
def get(team_id) = Phronomy::Runtime.instance.__get_team(team_id, klass: self)
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
52
74
|
def _coordinator_model = @coordinator_model
|
|
75
|
+
# @api private
|
|
53
76
|
def _coordinator_instructions = @coordinator_instructions
|
|
77
|
+
# @api private
|
|
54
78
|
def _coordinator_provider = @coordinator_provider
|
|
79
|
+
# @api private
|
|
55
80
|
def _pool_size = @pool_size || 1
|
|
81
|
+
# @api private
|
|
56
82
|
def _worker_agent = @worker_agent
|
|
83
|
+
# @api private
|
|
57
84
|
def _on_error = @on_error || :raise
|
|
85
|
+
# @api private
|
|
58
86
|
def _scheduler = @scheduler
|
|
87
|
+
# @api private
|
|
59
88
|
def _aggregator = @aggregator
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def construct(id, persistence, metadata, listener, create:)
|
|
93
|
+
raise Phronomy::EventLoopReentrancyError, "Team construction cannot block EventLoop" if Phronomy::Runtime.in_event_loop_context?
|
|
94
|
+
key = id.to_s
|
|
95
|
+
raise ArgumentError, "team_id must not be empty" if key.empty?
|
|
96
|
+
store = persistence || Phronomy.configuration.persistence || Phronomy::Persistence::InMemory.new
|
|
97
|
+
runtime = Phronomy::Runtime.instance
|
|
98
|
+
runtime.__team_owner(key, klass: self, create: create, persistence: store) do
|
|
99
|
+
instance = allocate
|
|
100
|
+
instance.send(:initialize, key.freeze, store, metadata, listener, runtime, create: create)
|
|
101
|
+
instance
|
|
102
|
+
end
|
|
103
|
+
end
|
|
60
104
|
end
|
|
61
105
|
|
|
106
|
+
# @api public
|
|
107
|
+
attr_reader :team_id, :persistence
|
|
108
|
+
|
|
62
109
|
# @api public
|
|
63
110
|
def invoke(team_input, config: {})
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
with_admission do
|
|
112
|
+
execution = admit(team_input.to_s, config)
|
|
113
|
+
run(execution.team_execution_id, config)
|
|
66
114
|
end
|
|
67
|
-
|
|
68
|
-
task_queue = []
|
|
69
|
-
run_coordinator(team_input, task_queue)
|
|
70
|
-
assignments = run_workers(task_queue)
|
|
71
|
-
finalize_result(assignments)
|
|
72
115
|
end
|
|
73
116
|
|
|
117
|
+
# Callback events are observations of committed assignments, never an outbox.
|
|
74
118
|
# @api public
|
|
75
119
|
def stream(team_input, config: {}, &block)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
raise ArgumentError, "pool :agent must be configured before invoking"
|
|
120
|
+
with_admission do
|
|
121
|
+
execution = admit(team_input.to_s, config)
|
|
122
|
+
run(execution.team_execution_id, config, &block)
|
|
80
123
|
end
|
|
124
|
+
end
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
finalize_result(assignments)
|
|
126
|
+
# @api public
|
|
127
|
+
def resume(team_execution_id, config: {})
|
|
128
|
+
with_admission { run(team_execution_id.to_s, config) }
|
|
86
129
|
end
|
|
87
130
|
|
|
88
|
-
|
|
131
|
+
# No live Agent loading or callbacks are needed for result discovery.
|
|
132
|
+
# @api public
|
|
133
|
+
def result(team_execution_id)
|
|
134
|
+
execution = read_execution(team_execution_id)
|
|
135
|
+
persistence.team_execution_result(execution.team_execution_id)
|
|
136
|
+
end
|
|
89
137
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
coordinator.invoke(input)
|
|
138
|
+
# @api public
|
|
139
|
+
def executions(after: nil, limit: 100)
|
|
140
|
+
persistence.list_team_executions(team_id, after: after, limit: limit)
|
|
94
141
|
end
|
|
95
142
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
143
|
+
# Requests cancellation of this run only. Existing Agent cancellation and
|
|
144
|
+
# Recovery settle admitted children; reserved absent children are not started.
|
|
145
|
+
# @api public
|
|
146
|
+
def cancel(team_execution_id)
|
|
147
|
+
assert_caller!
|
|
148
|
+
id = team_execution_id.to_s
|
|
149
|
+
update(id) do |current, _tx|
|
|
150
|
+
next current unless current.active?
|
|
151
|
+
current.with(metadata: current.metadata.merge("cancel_requested" => true))
|
|
152
|
+
end
|
|
153
|
+
token = @tokens_mutex.synchronize { @tokens[id] }
|
|
154
|
+
token&.cancel!
|
|
155
|
+
result(id)
|
|
156
|
+
end
|
|
101
157
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def initialize(id, store, metadata, listener, runtime, create:)
|
|
161
|
+
@team_id, @persistence, @listener, @runtime = id, store, listener, runtime
|
|
162
|
+
@tokens_mutex, @tokens = Mutex.new, {}
|
|
163
|
+
@coordinator_classes = {}
|
|
164
|
+
definition = self.class.team_definition
|
|
165
|
+
if create
|
|
166
|
+
now = Time.now.utc.iso8601(6)
|
|
167
|
+
intended = TeamRoot.new(team_id: id, team_definition_id: definition.fetch(:id),
|
|
168
|
+
team_definition_version: definition.fetch(:version), team_revision: 0,
|
|
169
|
+
lifecycle_status: "idle", created_at: now, updated_at: now, metadata: metadata)
|
|
170
|
+
begin
|
|
171
|
+
store.transaction { |tx| tx.teams.create(intended) }
|
|
172
|
+
rescue => error
|
|
173
|
+
confirmed = store.teams.load(id)
|
|
174
|
+
raise error unless confirmed.to_h == intended.to_h
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
root = store.teams.load(id)
|
|
178
|
+
unless root.team_definition_id == definition.fetch(:id) && root.team_definition_version == definition.fetch(:version)
|
|
179
|
+
raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def assert_caller!
|
|
184
|
+
raise Phronomy::EventLoopReentrancyError, "Team operation cannot block EventLoop" if Phronomy::Runtime.in_event_loop_context?
|
|
185
|
+
unless @runtime.equal?(Phronomy::Runtime.instance)
|
|
186
|
+
raise Phronomy::RuntimeShutdownError, "Team #{team_id} belongs to a previous Runtime"
|
|
109
187
|
end
|
|
188
|
+
end
|
|
110
189
|
|
|
111
|
-
|
|
190
|
+
def with_admission
|
|
191
|
+
assert_caller!
|
|
192
|
+
@runtime.__admit_multi_agent(self)
|
|
193
|
+
admitted = true
|
|
194
|
+
yield
|
|
195
|
+
ensure
|
|
196
|
+
@runtime.__release_multi_agent(self) if admitted
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def read_execution(id)
|
|
200
|
+
execution = persistence.team_executions.load(id)
|
|
201
|
+
unless execution.team_id == team_id
|
|
202
|
+
raise Phronomy::Persistence::ConflictError, "Team execution #{id} belongs to another Team"
|
|
203
|
+
end
|
|
204
|
+
execution
|
|
205
|
+
end
|
|
112
206
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
207
|
+
def definition_snapshot
|
|
208
|
+
worker = self.class._worker_agent
|
|
209
|
+
raise Phronomy::ConfigurationError, "Team pool agent is missing" unless worker
|
|
210
|
+
{"worker" => worker.agent_definition.transform_keys(&:to_s), "pool_size" => self.class._pool_size,
|
|
211
|
+
"on_error" => self.class._on_error.to_s,
|
|
212
|
+
"coordinator" => self.class.team_definition.transform_keys(&:to_s)}
|
|
213
|
+
end
|
|
117
214
|
|
|
215
|
+
def admit(input, config = {})
|
|
216
|
+
definition = definition_snapshot
|
|
217
|
+
id = SecureRandom.uuid
|
|
218
|
+
now = Time.now.utc.iso8601(6)
|
|
219
|
+
intended = nil
|
|
220
|
+
begin
|
|
221
|
+
persistence.transaction do |tx|
|
|
222
|
+
root = tx.teams.load(team_id)
|
|
223
|
+
raise Phronomy::AgentBusyError, "Team #{team_id} has an active run; use resume" unless tx.team_executions.list_active(team_id).empty?
|
|
224
|
+
intended = TeamExecution.new(team_execution_id: id, team_id: team_id,
|
|
225
|
+
execution_revision: 0, status: "active", phase: "coordinator", input_ref: tx.contents.put_text(input),
|
|
226
|
+
coordinator: {"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid, "state" => "reserved"},
|
|
227
|
+
workers: Array.new(self.class._pool_size) { |i| {"index" => i, "agent_id" => SecureRandom.uuid, "transcript_size" => 0} },
|
|
228
|
+
tasks: [], assignments: [], result_ref: nil, error_ref: nil, created_at: now, updated_at: now,
|
|
229
|
+
metadata: {"definition" => definition, "operations" => {}, "cancel_requested" => false,
|
|
230
|
+
"durable_context_ref" => config.key?(:durable_context) ? tx.contents.put_json(config.fetch(:durable_context)) : nil})
|
|
231
|
+
tx.team_executions.create_active(intended)
|
|
232
|
+
tx.teams.save(team_id, expected_revision: root.team_revision, root: root.with(lifecycle_status: "active"))
|
|
233
|
+
end
|
|
234
|
+
rescue => error
|
|
235
|
+
raise error unless intended
|
|
118
236
|
begin
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
assignments << entry
|
|
129
|
-
event_block&.call(entry.merge(type: :task_completed))
|
|
130
|
-
rescue => error
|
|
131
|
-
worker.status = :available
|
|
132
|
-
raise unless on_error == :skip
|
|
237
|
+
confirmed = read_execution(id)
|
|
238
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
239
|
+
raise error
|
|
240
|
+
end
|
|
241
|
+
raise error unless confirmed.to_h == intended.to_h
|
|
242
|
+
end
|
|
243
|
+
intended
|
|
244
|
+
end
|
|
133
245
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
246
|
+
# The body only derives immutable facts. Failed readback propagates; it is
|
|
247
|
+
# never interpreted as absence. A known CAS loser may rebase the same fact.
|
|
248
|
+
def update(id)
|
|
249
|
+
attempts = 0
|
|
250
|
+
begin
|
|
251
|
+
intended = nil
|
|
252
|
+
persistence.transaction do |tx|
|
|
253
|
+
current = tx.team_executions.load(id)
|
|
254
|
+
raise Phronomy::Persistence::ConflictError, "Team execution owner mismatch" unless current.team_id == team_id
|
|
255
|
+
intended = yield(current, tx)
|
|
256
|
+
next if intended.equal?(current)
|
|
257
|
+
tx.team_executions.save(id, expected_revision: current.execution_revision, execution: intended)
|
|
258
|
+
if intended.terminal? && current.active?
|
|
259
|
+
root = tx.teams.load(team_id)
|
|
260
|
+
tx.teams.save(team_id, expected_revision: root.team_revision, root: root.with(lifecycle_status: "idle"))
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
intended
|
|
264
|
+
rescue => error
|
|
265
|
+
confirmed = read_execution(id)
|
|
266
|
+
return confirmed if intended && confirmed.to_h == intended.to_h
|
|
267
|
+
if error.is_a?(Phronomy::Persistence::ConflictError) && (attempts += 1) < 8
|
|
268
|
+
retry
|
|
142
269
|
end
|
|
270
|
+
raise error
|
|
143
271
|
end
|
|
272
|
+
end
|
|
144
273
|
|
|
145
|
-
|
|
146
|
-
|
|
274
|
+
def run(id, config)
|
|
275
|
+
current = read_execution(id)
|
|
276
|
+
return terminal_value(current) if current.terminal?
|
|
277
|
+
unless current.metadata.fetch("definition") == definition_snapshot
|
|
278
|
+
raise Phronomy::ConfigurationError, "Team execution #{id} pool/definition mismatch"
|
|
279
|
+
end
|
|
280
|
+
token = Phronomy::Concurrency::CancellationToken.new
|
|
281
|
+
@tokens_mutex.synchronize { @tokens[id] = token }
|
|
282
|
+
external = config[:cancellation_token]
|
|
283
|
+
callback = proc { @runtime.offload.submit(on_full: :raise) { cancel(id) } }
|
|
284
|
+
external&.on_cancel(&callback)
|
|
285
|
+
token.cancel! if current.metadata["cancel_requested"]
|
|
286
|
+
begin
|
|
287
|
+
loop do
|
|
288
|
+
current = read_execution(id)
|
|
289
|
+
return terminal_value(current) if current.terminal?
|
|
290
|
+
token.cancel! if cancellation_requested?(current)
|
|
291
|
+
action, subject = next_run_action(current)
|
|
292
|
+
case action
|
|
293
|
+
when :coordinator
|
|
294
|
+
outcome = run_child(current, current.coordinator, coordinator_class(id),
|
|
295
|
+
persistence.contents.fetch_text(current.input_ref), "coordinator", config, token)
|
|
296
|
+
update(id) do |fresh, _tx|
|
|
297
|
+
fresh.with(phase: "workers", coordinator: fresh.coordinator.merge(
|
|
298
|
+
"state" => outcome[:status].to_s, "result_ref" => outcome[:result_ref], "error_ref" => outcome[:error_ref]
|
|
299
|
+
))
|
|
300
|
+
end
|
|
301
|
+
next
|
|
302
|
+
when :assignment
|
|
303
|
+
unfinished = subject
|
|
304
|
+
worker = current.workers.fetch(unfinished.fetch("worker"))
|
|
305
|
+
task = current.tasks.find { |item| item.fetch("id") == unfinished.fetch("task_id") }
|
|
306
|
+
outcome = run_child(current, worker.merge("execution_id" => unfinished.fetch("execution_id")),
|
|
307
|
+
self.class._worker_agent, task.fetch("description"), unfinished.fetch("task_id"), config, token)
|
|
308
|
+
saved = record_assignment(id, unfinished, outcome)
|
|
309
|
+
entry = assignment_values(saved).find { |item| item.fetch(:task).fetch(:id) == task.fetch("id") }
|
|
310
|
+
yield entry.merge(type: outcome[:error] ? :task_failed : :task_completed) if block_given?
|
|
311
|
+
next
|
|
312
|
+
when :failed
|
|
313
|
+
return terminal_value(finish_error(id, persistence.contents.fetch_json(subject.fetch("error_ref"))))
|
|
314
|
+
when :cancelled
|
|
315
|
+
return terminal_value(finish_error(id, {"class" => "Phronomy::CancellationError", "message" => "Team run cancelled"}, status: "cancelled"))
|
|
316
|
+
when :reserve
|
|
317
|
+
reserve_assignment(current, subject)
|
|
318
|
+
next
|
|
319
|
+
end
|
|
320
|
+
values = assignment_values(current)
|
|
321
|
+
aggregate = self.class._aggregator
|
|
322
|
+
begin
|
|
323
|
+
output = Phronomy::Agent::RecoverySupport.canonical_copy(aggregate ? aggregate.call(values) : values)
|
|
324
|
+
Phronomy::CanonicalJSON.dump(output)
|
|
325
|
+
rescue => error
|
|
326
|
+
return terminal_value(finish_error(id, Phronomy::Agent::RecoverySupport.resolution_failure(error)))
|
|
327
|
+
end
|
|
328
|
+
# Aggregate may be replayed only until the canonical outcome commits.
|
|
329
|
+
completed = update(id) do |fresh, tx|
|
|
330
|
+
next fresh if fresh.terminal? || fresh.metadata["cancel_requested"]
|
|
331
|
+
fresh.with(status: "completed", phase: "completed", result_ref: tx.contents.put_json(output))
|
|
332
|
+
end
|
|
333
|
+
next if completed.active?
|
|
334
|
+
return terminal_value(completed)
|
|
335
|
+
end
|
|
336
|
+
rescue Phronomy::CancellationError
|
|
337
|
+
# An absent reserved child is not started just to cancel it. Keep its
|
|
338
|
+
# identity in the terminal Team record for discovery.
|
|
339
|
+
if (failed = failed_child(read_execution(id)))
|
|
340
|
+
terminal_value(finish_error(id, persistence.contents.fetch_json(failed.fetch("error_ref"))))
|
|
341
|
+
else
|
|
342
|
+
terminal_value(finish_error(id, {"class" => "Phronomy::CancellationError", "message" => "Team run cancelled"}, status: "cancelled"))
|
|
343
|
+
end
|
|
344
|
+
ensure
|
|
345
|
+
external&.send(:unregister_cancel_callback, callback)
|
|
346
|
+
@tokens_mutex.synchronize { @tokens.delete(id) }
|
|
347
|
+
end
|
|
147
348
|
end
|
|
148
349
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
350
|
+
# Interpret only durable child states. Normal execution returns here after
|
|
351
|
+
# each child commit, just as resume does after losing its Runtime observer.
|
|
352
|
+
# Reconcile reservations before claiming completion; a cancelled absent
|
|
353
|
+
# child is handled by run_child without dispatching it.
|
|
354
|
+
def next_run_action(execution)
|
|
355
|
+
return [:coordinator, nil] if execution.phase == "coordinator"
|
|
356
|
+
unfinished = execution.assignments.find { |entry| entry.fetch("state") == "reserved" }
|
|
357
|
+
return [:assignment, unfinished] if unfinished
|
|
358
|
+
failed = failed_child(execution)
|
|
359
|
+
return [:failed, failed] if failed
|
|
360
|
+
return [:cancelled, nil] if cancellation_requested?(execution)
|
|
361
|
+
|
|
362
|
+
task = execution.tasks.find { |item| execution.assignments.none? { |entry| entry.fetch("task_id") == item.fetch("id") } }
|
|
363
|
+
task ? [:reserve, task] : [:aggregate, nil]
|
|
152
364
|
end
|
|
153
365
|
|
|
154
|
-
def
|
|
155
|
-
|
|
366
|
+
def failed_child(execution)
|
|
367
|
+
children = [execution.coordinator]
|
|
368
|
+
children += execution.assignments if execution.metadata.dig("definition", "on_error") == "raise"
|
|
369
|
+
# Filters deliberately block an Agent with a distinct terminal status.
|
|
370
|
+
# Team treats this as a child failure while retaining the blocked fact.
|
|
371
|
+
children.find { |entry| %w[failed blocked].include?(entry.fetch("state")) }
|
|
156
372
|
end
|
|
157
373
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
enqueue_tool = build_enqueue_tool(task_queue)
|
|
163
|
-
finalize_tool = build_finalize_tool(task_queue)
|
|
374
|
+
def cancellation_requested?(execution)
|
|
375
|
+
execution.metadata["cancel_requested"] || execution.coordinator.fetch("state") == "cancelled" ||
|
|
376
|
+
execution.assignments.any? { |entry| entry.fetch("state") == "cancelled" }
|
|
377
|
+
end
|
|
164
378
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
379
|
+
def run_child(current, slot, klass, input, purpose, config, token)
|
|
380
|
+
id = slot.fetch("agent_id")
|
|
381
|
+
child = klass.get(id)
|
|
382
|
+
unless child
|
|
383
|
+
begin
|
|
384
|
+
persistence.agents.load(id)
|
|
385
|
+
present = true
|
|
386
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
387
|
+
present = false
|
|
388
|
+
end
|
|
389
|
+
token.raise_if_cancelled! unless present
|
|
390
|
+
child = present ? klass.load(id, persistence: persistence, on_event: @listener) : klass.create(agent_id: id, persistence: persistence, on_event: @listener)
|
|
171
391
|
end
|
|
392
|
+
unless child.persistence.equal?(persistence)
|
|
393
|
+
raise Phronomy::ConfigurationError, "Team child #{id} Persistence mismatch"
|
|
394
|
+
end
|
|
395
|
+
child_config = config.merge(cancellation_token: token,
|
|
396
|
+
phronomy_coordination: {"kind" => "team", "team_id" => team_id,
|
|
397
|
+
"team_execution_id" => current.team_execution_id, "slot" => purpose})
|
|
398
|
+
if (context_ref = current.metadata["durable_context_ref"])
|
|
399
|
+
child_config = child_config.merge(durable_context: persistence.contents.fetch_json(context_ref))
|
|
400
|
+
end
|
|
401
|
+
Phronomy::Agent::ExactExecution.start(agent: child, execution_id: slot.fetch("execution_id"), input: input, config: child_config).wait_result
|
|
402
|
+
end
|
|
172
403
|
|
|
173
|
-
|
|
404
|
+
def reserve_assignment(current, task)
|
|
405
|
+
available = current.workers.map { |w| WorkerState.new(index: w.fetch("index"), agent_id: w.fetch("agent_id"), transcript_size: w.fetch("transcript_size"), status: :available) }.freeze
|
|
406
|
+
worker = self.class._scheduler ? self.class._scheduler.call(available) : available.min_by(&:transcript_size)
|
|
407
|
+
raise Phronomy::ConfigurationError, "Scheduler must select an available worker slot" unless available.include?(worker)
|
|
408
|
+
reserved = {"task_id" => task.fetch("id"), "worker" => worker.index,
|
|
409
|
+
"execution_id" => SecureRandom.uuid, "state" => "reserved", "result_ref" => nil, "error_ref" => nil}
|
|
410
|
+
update(current.team_execution_id) do |fresh, _tx|
|
|
411
|
+
next fresh if fresh.metadata["cancel_requested"] || fresh.assignments.any? { |entry| entry.fetch("task_id") == task.fetch("id") }
|
|
412
|
+
fresh.with(assignments: fresh.assignments + [reserved])
|
|
413
|
+
end
|
|
174
414
|
end
|
|
175
415
|
|
|
176
|
-
def
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
416
|
+
def record_assignment(id, assigned, outcome)
|
|
417
|
+
update(id) do |fresh, tx|
|
|
418
|
+
entries = fresh.assignments.map do |entry|
|
|
419
|
+
next entry unless entry.fetch("task_id") == assigned.fetch("task_id")
|
|
420
|
+
next entry unless entry.fetch("state") == "reserved"
|
|
421
|
+
raise Phronomy::Persistence::ConflictError, "Assignment execution changed" unless entry.fetch("execution_id") == outcome.fetch(:execution_id)
|
|
422
|
+
entry.merge("state" => outcome.fetch(:status).to_s,
|
|
423
|
+
"result_ref" => outcome[:result_ref], "error_ref" => outcome[:error_ref])
|
|
424
|
+
end
|
|
425
|
+
workers = fresh.workers.map do |worker|
|
|
426
|
+
next worker unless worker.fetch("index") == assigned.fetch("worker")
|
|
427
|
+
root = tx.agents.load(worker.fetch("agent_id"))
|
|
428
|
+
worker.merge("transcript_size" => root.journal_position)
|
|
429
|
+
end
|
|
430
|
+
fresh.with(assignments: entries, workers: workers)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
183
433
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
434
|
+
def assignment_values(execution)
|
|
435
|
+
execution.assignments.map do |entry|
|
|
436
|
+
task = execution.tasks.find { |item| item.fetch("id") == entry.fetch("task_id") }
|
|
437
|
+
{task: task.transform_keys(&:to_sym), worker: entry.fetch("worker"),
|
|
438
|
+
result: entry["result_ref"] && persistence.contents.fetch_text(entry.fetch("result_ref")),
|
|
439
|
+
error: entry["error_ref"] && persistence.contents.fetch_json(entry.fetch("error_ref"))}.freeze
|
|
440
|
+
end.freeze
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def finish_error(id, error, status: "failed")
|
|
444
|
+
update(id) do |fresh, tx|
|
|
445
|
+
next fresh if fresh.terminal?
|
|
446
|
+
fresh.with(status: status, phase: status, error_ref: tx.contents.put_json(error))
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def terminal_value(execution)
|
|
451
|
+
raise Phronomy::CancellationError, "Team run #{execution.team_execution_id} cancelled" if execution.status == "cancelled"
|
|
452
|
+
raise Phronomy::Agent::RecoverySupport.error_from_failure(persistence.contents.fetch_json(execution.error_ref)) if execution.error_ref
|
|
453
|
+
persistence.contents.fetch_json(execution.result_ref)
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def coordinator_class(id)
|
|
457
|
+
@coordinator_classes[id] ||= begin
|
|
458
|
+
definition = self.class.team_definition
|
|
459
|
+
model = self.class._coordinator_model
|
|
460
|
+
provider = self.class._coordinator_provider
|
|
461
|
+
instructions = self.class._coordinator_instructions
|
|
462
|
+
enqueue = build_operation_tool(id, :enqueue_task)
|
|
463
|
+
finalize = build_operation_tool(id, :finalize)
|
|
464
|
+
Class.new(Phronomy::Agent::Base) do
|
|
465
|
+
agent_definition id: "team:#{definition.fetch(:id)}:coordinator", version: definition.fetch(:version)
|
|
466
|
+
model(model) if model
|
|
467
|
+
provider(provider) if provider
|
|
468
|
+
instructions(instructions) if instructions
|
|
469
|
+
tools(enqueue => nil, finalize => nil)
|
|
470
|
+
define_method(:__framework_tool_replayable?) { |name| %w[enqueue_task finalize].include?(name) }
|
|
193
471
|
end
|
|
194
472
|
end
|
|
195
473
|
end
|
|
196
474
|
|
|
197
|
-
def
|
|
475
|
+
def build_operation_tool(run_id, operation)
|
|
476
|
+
team = self
|
|
198
477
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
199
|
-
|
|
200
|
-
|
|
478
|
+
def self.__framework_owned_operation? = true
|
|
479
|
+
tool_name operation.to_s
|
|
480
|
+
description((operation == :enqueue_task) ? "Add a task to the worker queue." : "Finish task generation.")
|
|
201
481
|
execution_mode :cooperative
|
|
202
|
-
|
|
482
|
+
if operation == :enqueue_task
|
|
483
|
+
param :description, type: :string, desc: "Worker task"
|
|
484
|
+
param :metadata, type: :string, desc: "Optional metadata", required: false
|
|
485
|
+
else
|
|
486
|
+
param :summary, type: :string, desc: "Task summary", required: false
|
|
487
|
+
end
|
|
488
|
+
define_method(:call_async) do |args, cancellation_token: nil, config: {}|
|
|
489
|
+
validated, schema_error = send(:validate_and_coerce, args)
|
|
490
|
+
raise Phronomy::ToolError, schema_error if schema_error
|
|
491
|
+
execute_async(**validated, cancellation_token: cancellation_token, config: config)
|
|
492
|
+
rescue => error
|
|
493
|
+
Phronomy::Task.deferred(name: "team-operation-failed").tap { |task| task.fail(error) }
|
|
494
|
+
end
|
|
495
|
+
define_method(:execute_async) do |config: {}, cancellation_token: nil, **arguments|
|
|
496
|
+
key = config.fetch(:phronomy_tool_invocation_id)
|
|
497
|
+
Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
|
|
498
|
+
team.send(:apply_operation, run_id, key, operation, arguments)
|
|
499
|
+
rescue Phronomy::CancellationError
|
|
500
|
+
raise
|
|
501
|
+
rescue => error
|
|
502
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Team operation #{key} needs reconciliation: #{error.message}"
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
private :execute_async
|
|
506
|
+
end
|
|
507
|
+
end
|
|
203
508
|
|
|
204
|
-
|
|
205
|
-
|
|
509
|
+
def apply_operation(run_id, key, operation, arguments)
|
|
510
|
+
argument_values = Phronomy::Agent::RecoverySupport.canonical_copy(arguments)
|
|
511
|
+
current = update(run_id) do |fresh, tx|
|
|
512
|
+
operations = fresh.metadata.fetch("operations")
|
|
513
|
+
if (prior = operations[key])
|
|
514
|
+
unless prior.fetch("operation") == operation.to_s && prior.fetch("arguments") == argument_values
|
|
515
|
+
raise Phronomy::Persistence::ConflictError, "Team operation #{key} identity mismatch"
|
|
516
|
+
end
|
|
517
|
+
next fresh
|
|
518
|
+
end
|
|
519
|
+
raise Phronomy::CancellationError, "Team run cancelled" if fresh.metadata["cancel_requested"]
|
|
520
|
+
raise Phronomy::Persistence::ConflictError, "Team task generation is closed" unless fresh.active? && fresh.phase == "coordinator"
|
|
521
|
+
coordinator = tx.executions.load(fresh.coordinator.fetch("execution_id"))
|
|
522
|
+
unless coordinator.agent_id == fresh.coordinator.fetch("agent_id")
|
|
523
|
+
raise Phronomy::Persistence::ConflictError, "Team coordinator owner mismatch"
|
|
524
|
+
end
|
|
525
|
+
batch = Array(coordinator.metadata[Phronomy::Agent::RecoverySupport::TOOL_BATCH_METADATA_KEY])
|
|
526
|
+
requested = batch.find { |entry| entry.fetch("tool_invocation_id") == key }
|
|
527
|
+
unless requested && requested.fetch("status") == "authorized" && requested.fetch("tool_name") == operation.to_s && requested.fetch("arguments").compact == argument_values
|
|
528
|
+
raise Phronomy::Persistence::ConflictError, "Team operation #{key} is not the authorized call"
|
|
529
|
+
end
|
|
530
|
+
# All calls passed the Agent authorization barrier. Commit this finite
|
|
531
|
+
# batch in Provider order, so finalize cannot overtake queued tasks.
|
|
532
|
+
tasks = fresh.tasks.dup
|
|
533
|
+
operations = operations.dup
|
|
534
|
+
metadata = fresh.metadata.dup
|
|
535
|
+
batch.each do |entry|
|
|
536
|
+
entry_id = entry.fetch("tool_invocation_id")
|
|
537
|
+
next if operations.key?(entry_id) || entry.fetch("status") != "authorized"
|
|
538
|
+
name = entry.fetch("tool_name")
|
|
539
|
+
next unless %w[enqueue_task finalize].include?(name)
|
|
540
|
+
values = entry.fetch("arguments").compact
|
|
541
|
+
if name == "enqueue_task"
|
|
542
|
+
raise Phronomy::ConfigurationError, "Cannot enqueue after finalize" if metadata["finalized"]
|
|
543
|
+
task = {"id" => Digest::SHA256.hexdigest(entry_id)[0, 32], "description" => values.fetch("description"), "metadata" => values["metadata"]}
|
|
544
|
+
tasks << task
|
|
545
|
+
output = "Task ##{tasks.length} enqueued: #{task.fetch("description")}"
|
|
546
|
+
else
|
|
547
|
+
output = "Finalized. #{tasks.size} task(s) enqueued. #{values["summary"]}".strip
|
|
548
|
+
metadata["finalized"] = true
|
|
549
|
+
end
|
|
550
|
+
operations[entry_id] = {"operation" => name, "arguments" => values, "result" => output}
|
|
206
551
|
end
|
|
552
|
+
metadata["operations"] = operations
|
|
553
|
+
fresh.with(tasks: tasks, metadata: metadata)
|
|
207
554
|
end
|
|
555
|
+
current.metadata.fetch("operations").fetch(key).fetch("result")
|
|
208
556
|
end
|
|
209
557
|
end
|
|
210
558
|
end
|