phronomy 0.22.0 → 0.23.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 +200 -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 +152 -0
- data/docs/architecture/persistence.md +175 -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 +82 -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 +112 -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/README.md +172 -0
- data/docs/features.md +31 -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/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- 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 +145 -72
- data/lib/phronomy/agent/base.rb +388 -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/execution_coordinator.rb +1975 -587
- 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 +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -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 +336 -102
- 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.rb +77 -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/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -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/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- 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 +1 -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/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/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- 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
|
@@ -3,21 +3,15 @@
|
|
|
3
3
|
require_relative "runtime/timer_queue"
|
|
4
4
|
require_relative "runtime/shutdown_result"
|
|
5
5
|
require_relative "runtime/timer_service"
|
|
6
|
+
require_relative "runtime/agent_ownership_registry"
|
|
6
7
|
|
|
7
8
|
module Phronomy
|
|
8
|
-
# Owns the EventLoop, offloaded synchronous work, timers and shutdown lifecycle.
|
|
9
|
-
#
|
|
10
|
-
# Runtime no longer schedules arbitrary Tasks. Framework control flow belongs
|
|
11
|
-
# to EventLoop/FSMSession; synchronous work that must not run on the EventLoop
|
|
12
|
-
# belongs to OffloadPool.
|
|
13
9
|
class Runtime
|
|
14
10
|
@instance_mutex = Mutex.new
|
|
15
11
|
|
|
16
12
|
class << self
|
|
17
13
|
def instance
|
|
18
|
-
instance_mutex.synchronize
|
|
19
|
-
@instance ||= new
|
|
20
|
-
end
|
|
14
|
+
instance_mutex.synchronize { @instance ||= new }
|
|
21
15
|
end
|
|
22
16
|
|
|
23
17
|
def default_if_initialized_for_test
|
|
@@ -69,7 +63,8 @@ module Phronomy
|
|
|
69
63
|
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
70
64
|
timer_queue_provider: -> { timer_queue }
|
|
71
65
|
)
|
|
72
|
-
@
|
|
66
|
+
@multi_agent_admissions = Phronomy::MultiAgent::AdmissionRegistry.new
|
|
67
|
+
@agent_ownership_registry = AgentOwnershipRegistry.new(runtime: self)
|
|
73
68
|
@lifecycle_mutex = Mutex.new
|
|
74
69
|
@shutdown_mutex = Mutex.new
|
|
75
70
|
@state = :running
|
|
@@ -95,7 +90,6 @@ module Phronomy
|
|
|
95
90
|
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
96
91
|
end
|
|
97
92
|
|
|
98
|
-
# Public timer access also ensures that the EventLoop that drives timers is alive.
|
|
99
93
|
def timer_queue
|
|
100
94
|
ensure_accepting_work!
|
|
101
95
|
timer = @timer_service.timer_queue
|
|
@@ -103,15 +97,77 @@ module Phronomy
|
|
|
103
97
|
timer
|
|
104
98
|
end
|
|
105
99
|
|
|
106
|
-
# Internal EventLoop access that does not recursively initialise EventLoop.
|
|
107
100
|
def __timer_queue
|
|
108
101
|
@timer_service.timer_queue
|
|
109
102
|
end
|
|
110
103
|
|
|
111
|
-
#
|
|
112
|
-
# state and
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
# Returns only an immutable process-local routing/ownership view. Mutable
|
|
105
|
+
# execution state remains inside EventLoop and is never exposed to callers.
|
|
106
|
+
# @api private
|
|
107
|
+
def __agent_execution_owner(execution_id)
|
|
108
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
109
|
+
loop_instance&.agent_execution_owner(execution_id)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @api private
|
|
113
|
+
def __create_agent(agent_id, expected_class:, &block)
|
|
114
|
+
@agent_ownership_registry.create(agent_id, expected_class: expected_class, &block)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @api private
|
|
118
|
+
def __load_agent(agent_id, expected_class:, &block)
|
|
119
|
+
@agent_ownership_registry.load(agent_id, expected_class: expected_class, &block)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @api private
|
|
123
|
+
def __get_agent(agent_id, expected_class:)
|
|
124
|
+
@agent_ownership_registry.get(agent_id, expected_class: expected_class)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @api private
|
|
128
|
+
def __agent_owned?(agent)
|
|
129
|
+
@agent_ownership_registry.owned?(agent)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @api private
|
|
133
|
+
def __begin_agent_purge(agent)
|
|
134
|
+
@agent_ownership_registry.begin_purge(agent)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @api private
|
|
138
|
+
def __complete_agent_purge(agent, token)
|
|
139
|
+
@agent_ownership_registry.complete_purge(agent, token)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @api private
|
|
143
|
+
def __abort_agent_purge(agent, token)
|
|
144
|
+
@agent_ownership_registry.abort_purge(agent, token)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @api private
|
|
148
|
+
def __leave_agent_purge_uncertain(agent, token)
|
|
149
|
+
@agent_ownership_registry.leave_purge_uncertain(agent, token)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @api private
|
|
153
|
+
def __agent_execution_admitted?(agent_id)
|
|
154
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
155
|
+
loop_instance&.agent_execution_admitted?(agent_id) || false
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @api private
|
|
159
|
+
def __admit_multi_agent(coordinator)
|
|
160
|
+
current_state = @lifecycle_mutex.synchronize { @state }
|
|
161
|
+
unless current_state == :running
|
|
162
|
+
raise Phronomy::RuntimeShutdownError,
|
|
163
|
+
"Runtime is #{current_state}; new Multi-Agent turns are not accepted"
|
|
164
|
+
end
|
|
165
|
+
@multi_agent_admissions.admit!(coordinator)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @api private
|
|
169
|
+
def __release_multi_agent(coordinator)
|
|
170
|
+
@multi_agent_admissions.release!(coordinator)
|
|
115
171
|
end
|
|
116
172
|
|
|
117
173
|
def event_loop
|
|
@@ -163,23 +219,22 @@ module Phronomy
|
|
|
163
219
|
@shutdown_mutex.synchronize do
|
|
164
220
|
return @shutdown_result if @shutdown_result
|
|
165
221
|
|
|
166
|
-
# Phase 1 — drain sessions with the full configured grace.
|
|
167
222
|
drain_deadline = monotonic_now + timeout
|
|
168
223
|
loop_instance = @lifecycle_mutex.synchronize do
|
|
169
224
|
@state = :draining unless @state == :failed
|
|
170
225
|
@event_loop
|
|
171
226
|
end
|
|
172
227
|
loop_instance&.begin_draining
|
|
228
|
+
@agent_ownership_registry.begin_draining
|
|
173
229
|
|
|
230
|
+
admission_idle = @multi_agent_admissions.wait_until_idle(drain_deadline)
|
|
231
|
+
agent_ownership_stable = @agent_ownership_registry.wait_until_stable(drain_deadline)
|
|
174
232
|
loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
|
|
175
233
|
|
|
176
234
|
@lifecycle_mutex.synchronize do
|
|
177
235
|
@state = :stopping unless @state == :failed
|
|
178
236
|
end
|
|
179
237
|
|
|
180
|
-
# Phase 2 — stop the EventLoop thread with a short independent budget.
|
|
181
|
-
# An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
|
|
182
|
-
# budget here is only a safety net for OS scheduling jitter.
|
|
183
238
|
stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
|
|
184
239
|
event_loop_status = if loop_instance
|
|
185
240
|
loop_instance.stop_and_join(deadline: stop_deadline)
|
|
@@ -188,7 +243,7 @@ module Phronomy
|
|
|
188
243
|
end
|
|
189
244
|
|
|
190
245
|
subsystem_error = shutdown_pools_and_timer
|
|
191
|
-
cleanup_complete = loop_idle &&
|
|
246
|
+
cleanup_complete = admission_idle && agent_ownership_stable && loop_idle &&
|
|
192
247
|
(!loop_instance || !loop_instance.thread_alive?) &&
|
|
193
248
|
event_loop_status != :cancel_timeout &&
|
|
194
249
|
subsystem_error.nil?
|
|
@@ -200,6 +255,8 @@ module Phronomy
|
|
|
200
255
|
:terminated
|
|
201
256
|
end
|
|
202
257
|
|
|
258
|
+
@agent_ownership_registry.shutdown! if cleanup_complete
|
|
259
|
+
|
|
203
260
|
result = ShutdownResult.new(
|
|
204
261
|
runtime_outcome: runtime_outcome,
|
|
205
262
|
cleanup_status: cleanup_complete ? :complete : :incomplete,
|
|
@@ -159,7 +159,7 @@ module Phronomy
|
|
|
159
159
|
feedback = next_state.review_notes.last
|
|
160
160
|
prompt = draft_prompt_builder.call(next_state.input, feedback)
|
|
161
161
|
|
|
162
|
-
draft_agent.
|
|
162
|
+
draft_agent.send(:__invoke_async_with_event_sink,
|
|
163
163
|
prompt,
|
|
164
164
|
on_event: ->(agent_event) {
|
|
165
165
|
case agent_event.type
|
|
@@ -169,7 +169,7 @@ module Phronomy
|
|
|
169
169
|
agent_event.payload[:output]
|
|
170
170
|
)
|
|
171
171
|
workflow.signal(
|
|
172
|
-
|
|
172
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
173
173
|
event: :draft_completed,
|
|
174
174
|
payload: {
|
|
175
175
|
request_id: request_id,
|
|
@@ -186,7 +186,7 @@ module Phronomy
|
|
|
186
186
|
)
|
|
187
187
|
rescue => error
|
|
188
188
|
workflow.signal(
|
|
189
|
-
|
|
189
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
190
190
|
event: :draft_failed,
|
|
191
191
|
payload: {
|
|
192
192
|
request_id: request_id,
|
|
@@ -196,7 +196,7 @@ module Phronomy
|
|
|
196
196
|
end
|
|
197
197
|
when :error, :timeout, :cancelled
|
|
198
198
|
workflow.signal(
|
|
199
|
-
|
|
199
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
200
200
|
event: :draft_failed,
|
|
201
201
|
payload: {
|
|
202
202
|
request_id: request_id,
|
|
@@ -209,7 +209,7 @@ module Phronomy
|
|
|
209
209
|
)
|
|
210
210
|
when :approval_required
|
|
211
211
|
workflow.signal(
|
|
212
|
-
|
|
212
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
213
213
|
event: :draft_failed,
|
|
214
214
|
payload: {
|
|
215
215
|
request_id: request_id,
|
|
@@ -219,8 +219,7 @@ module Phronomy
|
|
|
219
219
|
}
|
|
220
220
|
)
|
|
221
221
|
end
|
|
222
|
-
}
|
|
223
|
-
)
|
|
222
|
+
})
|
|
224
223
|
|
|
225
224
|
next_state
|
|
226
225
|
}
|
|
@@ -234,7 +233,7 @@ module Phronomy
|
|
|
234
233
|
next_state.citations
|
|
235
234
|
)
|
|
236
235
|
|
|
237
|
-
review_agent.
|
|
236
|
+
review_agent.send(:__invoke_async_with_event_sink,
|
|
238
237
|
prompt,
|
|
239
238
|
on_event: ->(agent_event) {
|
|
240
239
|
case agent_event.type
|
|
@@ -244,7 +243,7 @@ module Phronomy
|
|
|
244
243
|
agent_event.payload[:output]
|
|
245
244
|
)
|
|
246
245
|
workflow.signal(
|
|
247
|
-
|
|
246
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
248
247
|
event: :review_completed,
|
|
249
248
|
payload: {
|
|
250
249
|
request_id: request_id,
|
|
@@ -258,7 +257,7 @@ module Phronomy
|
|
|
258
257
|
)
|
|
259
258
|
rescue => error
|
|
260
259
|
workflow.signal(
|
|
261
|
-
|
|
260
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
262
261
|
event: :review_failed,
|
|
263
262
|
payload: {
|
|
264
263
|
request_id: request_id,
|
|
@@ -268,7 +267,7 @@ module Phronomy
|
|
|
268
267
|
end
|
|
269
268
|
when :error, :timeout, :cancelled
|
|
270
269
|
workflow.signal(
|
|
271
|
-
|
|
270
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
272
271
|
event: :review_failed,
|
|
273
272
|
payload: {
|
|
274
273
|
request_id: request_id,
|
|
@@ -281,7 +280,7 @@ module Phronomy
|
|
|
281
280
|
)
|
|
282
281
|
when :approval_required
|
|
283
282
|
workflow.signal(
|
|
284
|
-
|
|
283
|
+
workflow_instance_id: next_state.workflow_instance_id,
|
|
285
284
|
event: :review_failed,
|
|
286
285
|
payload: {
|
|
287
286
|
request_id: request_id,
|
|
@@ -291,8 +290,7 @@ module Phronomy
|
|
|
291
290
|
}
|
|
292
291
|
)
|
|
293
292
|
end
|
|
294
|
-
}
|
|
295
|
-
)
|
|
293
|
+
})
|
|
296
294
|
|
|
297
295
|
next_state
|
|
298
296
|
}
|
|
@@ -1,56 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Carries
|
|
4
|
+
# Carries per-invocation control, policy, and tracing context through the call stack.
|
|
5
5
|
#
|
|
6
6
|
# +InvocationContext+ is a plain struct-like value carrier that replaces
|
|
7
7
|
# ad-hoc +Thread.current[...]+ propagation.
|
|
8
8
|
# Pass it explicitly wherever context needs to cross a method boundary.
|
|
9
9
|
#
|
|
10
|
+
# Generic conversation/session identity is deliberately not part of this
|
|
11
|
+
# object. Use purpose-specific domain identifiers and application tracing
|
|
12
|
+
# metadata instead.
|
|
13
|
+
#
|
|
10
14
|
# @example Build a context for a new agent invocation
|
|
11
15
|
# ctx = Phronomy::InvocationContext.new(
|
|
12
|
-
#
|
|
16
|
+
# task_id: "request-123",
|
|
13
17
|
# cancellation_token: Phronomy::Concurrency::CancellationToken.timeout_after(30)
|
|
14
18
|
# )
|
|
15
19
|
# agent.invoke("Hello", invocation_context: ctx)
|
|
16
20
|
#
|
|
17
21
|
# @api public
|
|
18
22
|
class InvocationContext
|
|
19
|
-
attr_reader :
|
|
20
|
-
:
|
|
21
|
-
:
|
|
23
|
+
attr_reader :user_id, :cancellation_token, :deadline,
|
|
24
|
+
:token_budget, :approval_policy, :redaction_policy, :task_id,
|
|
25
|
+
:parent_task_id
|
|
22
26
|
|
|
23
|
-
# @param thread_id [String, nil]
|
|
24
|
-
# @param session_id [String, nil]
|
|
25
|
-
# @param user_id [String, nil]
|
|
26
|
-
# @param cancellation_token [CancellationToken, nil]
|
|
27
|
-
# @param deadline [Deadline, nil]
|
|
28
|
-
# @param tracer_span [Object, nil]
|
|
29
|
-
# @param token_budget [Integer, nil]
|
|
30
|
-
# @param approval_policy [#call, nil]
|
|
31
|
-
# @param redaction_policy [Object, nil]
|
|
32
|
-
# @param task_id [String, nil]
|
|
33
|
-
# @param parent_task_id [String, nil]
|
|
34
27
|
# @api public
|
|
35
28
|
def initialize(
|
|
36
|
-
thread_id: nil,
|
|
37
|
-
session_id: nil,
|
|
38
29
|
user_id: nil,
|
|
39
30
|
cancellation_token: nil,
|
|
40
31
|
deadline: nil,
|
|
41
|
-
tracer_span: nil,
|
|
42
32
|
token_budget: nil,
|
|
43
33
|
approval_policy: nil,
|
|
44
34
|
redaction_policy: nil,
|
|
45
35
|
task_id: nil,
|
|
46
36
|
parent_task_id: nil
|
|
47
37
|
)
|
|
48
|
-
@thread_id = thread_id
|
|
49
|
-
@session_id = session_id
|
|
50
38
|
@user_id = user_id
|
|
51
39
|
@cancellation_token = cancellation_token
|
|
52
40
|
@deadline = deadline
|
|
53
|
-
@tracer_span = tracer_span
|
|
54
41
|
@token_budget = token_budget
|
|
55
42
|
@approval_policy = approval_policy
|
|
56
43
|
@redaction_policy = redaction_policy
|
|
@@ -58,16 +45,12 @@ module Phronomy
|
|
|
58
45
|
@parent_task_id = parent_task_id
|
|
59
46
|
end
|
|
60
47
|
|
|
61
|
-
# Returns a new +InvocationContext+ with the given attributes merged in.
|
|
62
48
|
# @api private
|
|
63
49
|
def merge(**overrides)
|
|
64
50
|
InvocationContext.new(
|
|
65
|
-
thread_id: overrides.fetch(:thread_id, @thread_id),
|
|
66
|
-
session_id: overrides.fetch(:session_id, @session_id),
|
|
67
51
|
user_id: overrides.fetch(:user_id, @user_id),
|
|
68
52
|
cancellation_token: overrides.fetch(:cancellation_token, @cancellation_token),
|
|
69
53
|
deadline: overrides.fetch(:deadline, @deadline),
|
|
70
|
-
tracer_span: overrides.fetch(:tracer_span, @tracer_span),
|
|
71
54
|
token_budget: overrides.fetch(:token_budget, @token_budget),
|
|
72
55
|
approval_policy: overrides.fetch(:approval_policy, @approval_policy),
|
|
73
56
|
redaction_policy: overrides.fetch(:redaction_policy, @redaction_policy),
|
|
@@ -76,14 +59,11 @@ module Phronomy
|
|
|
76
59
|
)
|
|
77
60
|
end
|
|
78
61
|
|
|
79
|
-
# Convenience: returns the cancellation token or a new never-cancelled token.
|
|
80
62
|
# @api private
|
|
81
63
|
def effective_cancellation_token
|
|
82
64
|
@cancellation_token || Phronomy::Concurrency::CancellationToken.new
|
|
83
65
|
end
|
|
84
66
|
|
|
85
|
-
# Returns the cancellation token to use for an invocation, taking both the
|
|
86
|
-
# explicit cancellation_token and deadline into account.
|
|
87
67
|
# @api private
|
|
88
68
|
def effective_timeout_token
|
|
89
69
|
return @cancellation_token if @cancellation_token
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module MultiAgent
|
|
5
|
+
# Runtime-local admission for one user-facing coordination turn per main Agent.
|
|
6
|
+
# This owns exclusion only; active-agent mutation still belongs to EventLoop.
|
|
7
|
+
class AdmissionRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@cond = ConditionVariable.new
|
|
11
|
+
@owners = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def admit!(coordinator)
|
|
15
|
+
key = coordinator.object_id
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
if @owners.key?(key)
|
|
18
|
+
raise Phronomy::HandoffError,
|
|
19
|
+
"a Multi-Agent turn is already active for this main Agent instance"
|
|
20
|
+
end
|
|
21
|
+
@owners[key] = coordinator
|
|
22
|
+
end
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def release!(coordinator)
|
|
27
|
+
key = coordinator.object_id
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
removed = @owners.delete(key)
|
|
30
|
+
@cond.broadcast if @owners.empty?
|
|
31
|
+
!removed.nil?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def idle?
|
|
36
|
+
@mutex.synchronize { @owners.empty? }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def wait_until_idle(deadline)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
until @owners.empty?
|
|
42
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
return false if remaining <= 0
|
|
44
|
+
@cond.wait(@mutex, remaining)
|
|
45
|
+
end
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module MultiAgent
|
|
5
|
+
CoordinationState = Data.define(:active_agent, :active_handoff_context) do
|
|
6
|
+
def initialize(active_agent:, active_handoff_context: nil)
|
|
7
|
+
unless active_agent.is_a?(Phronomy::Agent::Base)
|
|
8
|
+
raise ArgumentError, "active_agent must be a Phronomy::Agent::Base"
|
|
9
|
+
end
|
|
10
|
+
if active_handoff_context && !active_handoff_context.is_a?(HandoffContext)
|
|
11
|
+
raise ArgumentError, "active_handoff_context must be a HandoffContext"
|
|
12
|
+
end
|
|
13
|
+
super
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module MultiAgent
|
|
5
|
+
class Coordinator
|
|
6
|
+
ATTACH_MUTEX = Mutex.new
|
|
7
|
+
private_constant :ATTACH_MUTEX
|
|
8
|
+
|
|
9
|
+
ApplyHandoffCommand = Data.define(:coordinator, :request, :context, :completion)
|
|
10
|
+
|
|
11
|
+
attr_reader :main_agent, :handoffs
|
|
12
|
+
|
|
13
|
+
def self.attach(main_agent:, handoffs:)
|
|
14
|
+
unless main_agent.is_a?(Phronomy::Agent::Base)
|
|
15
|
+
raise ArgumentError, "main_agent must be a Phronomy::Agent::Base"
|
|
16
|
+
end
|
|
17
|
+
normalized = Array(handoffs).freeze
|
|
18
|
+
|
|
19
|
+
ATTACH_MUTEX.synchronize do
|
|
20
|
+
existing = main_agent.instance_variable_get(:@_phronomy_multi_agent_coordinator)
|
|
21
|
+
if existing&.runtime_current?
|
|
22
|
+
existing.assert_compatible!(normalized)
|
|
23
|
+
return existing
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
created = new(main_agent: main_agent, handoffs: normalized)
|
|
27
|
+
main_agent.instance_variable_set(:@_phronomy_multi_agent_coordinator, created)
|
|
28
|
+
created
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(main_agent:, handoffs:)
|
|
33
|
+
@main_agent = main_agent
|
|
34
|
+
@handoffs = Array(handoffs).freeze
|
|
35
|
+
@runtime = Phronomy::Runtime.instance
|
|
36
|
+
validate_graph!
|
|
37
|
+
@bindings_by_source = build_bindings
|
|
38
|
+
@state_mutex = Mutex.new
|
|
39
|
+
@state = CoordinationState.new(active_agent: main_agent)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def runtime_current?
|
|
43
|
+
Phronomy::Runtime.instance.equal?(@runtime)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def snapshot
|
|
47
|
+
unless runtime_current?
|
|
48
|
+
raise Phronomy::RuntimeShutdownError,
|
|
49
|
+
"Multi-Agent coordination state belongs to a previous Runtime"
|
|
50
|
+
end
|
|
51
|
+
@state_mutex.synchronize { @state }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def outgoing_bindings(agent)
|
|
55
|
+
@bindings_by_source.fetch(agent.object_id, []).freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def transition!(request, context)
|
|
59
|
+
completion = Phronomy::Task.deferred(name: "multi-agent-handoff")
|
|
60
|
+
command = ApplyHandoffCommand.new(
|
|
61
|
+
coordinator: self,
|
|
62
|
+
request: request,
|
|
63
|
+
context: context,
|
|
64
|
+
completion: completion
|
|
65
|
+
)
|
|
66
|
+
posted = Phronomy::Runtime.instance.event_loop.post(
|
|
67
|
+
Phronomy::Event.new(
|
|
68
|
+
type: :agent_terminal_ready,
|
|
69
|
+
target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
|
|
70
|
+
payload: {command: command}
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
unless posted
|
|
74
|
+
completion.fail(
|
|
75
|
+
Phronomy::RuntimeShutdownError.new(
|
|
76
|
+
"EventLoop is not accepting Multi-Agent Handoff transitions"
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
completion.wait_result
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @api private
|
|
84
|
+
def deliver_on_event_loop(command)
|
|
85
|
+
runtime = Phronomy::Runtime.instance
|
|
86
|
+
unless runtime.event_loop.current?
|
|
87
|
+
raise Phronomy::Error,
|
|
88
|
+
"Multi-Agent coordination state may only be mutated on EventLoop"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
request = command.request
|
|
92
|
+
context = command.context
|
|
93
|
+
current = snapshot
|
|
94
|
+
unless request.handoff.source_agent.equal?(current.active_agent)
|
|
95
|
+
raise Phronomy::HandoffError,
|
|
96
|
+
"Handoff source is no longer the active Agent"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
next_state = CoordinationState.new(
|
|
100
|
+
active_agent: request.handoff.target_agent,
|
|
101
|
+
active_handoff_context: context
|
|
102
|
+
)
|
|
103
|
+
@state_mutex.synchronize { @state = next_state }
|
|
104
|
+
command.completion.complete(next_state)
|
|
105
|
+
rescue => error
|
|
106
|
+
command.completion.fail(error)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def assert_compatible!(handoffs)
|
|
110
|
+
incoming = graph_signature(handoffs)
|
|
111
|
+
current = graph_signature(@handoffs)
|
|
112
|
+
return true if incoming == current
|
|
113
|
+
|
|
114
|
+
raise Phronomy::ConfigurationError,
|
|
115
|
+
"a MultiAgent::Runner for this main Agent already exists with a different Handoff graph"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def validate_graph!
|
|
121
|
+
unless @handoffs.all? { |handoff| handoff.is_a?(Handoff) }
|
|
122
|
+
raise ArgumentError, "handoffs must contain only MultiAgent::Handoff values"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
duplicates = @handoffs.group_by do |handoff|
|
|
126
|
+
[handoff.source_agent.object_id, handoff.target_agent.object_id]
|
|
127
|
+
end.select { |_key, values| values.length > 1 }
|
|
128
|
+
unless duplicates.empty?
|
|
129
|
+
raise ArgumentError, "duplicate Source → Target Handoff edges are not allowed"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_bindings
|
|
134
|
+
@handoffs.group_by(&:source_agent).to_h do |source, edges|
|
|
135
|
+
[
|
|
136
|
+
source.object_id,
|
|
137
|
+
edges.map { |handoff| HandoffCapabilityFactory.build(handoff) }.freeze
|
|
138
|
+
]
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def graph_signature(handoffs)
|
|
143
|
+
Array(handoffs).map do |handoff|
|
|
144
|
+
[
|
|
145
|
+
handoff.source_agent.object_id,
|
|
146
|
+
handoff.target_agent.object_id,
|
|
147
|
+
handoff.policy.to_h,
|
|
148
|
+
handoff.description
|
|
149
|
+
]
|
|
150
|
+
end.sort_by { |row| [row[0], row[1], row[3]] }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|