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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Runtime-local Team identity and construction exclusion; no execution state.
|
|
6
|
+
# @api private
|
|
7
|
+
class TeamOwnershipRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@condition = ConditionVariable.new
|
|
11
|
+
@owners = {}
|
|
12
|
+
@constructing = {}
|
|
13
|
+
@draining = false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fetch(id, klass:, create:, persistence:)
|
|
17
|
+
@mutex.synchronize do
|
|
18
|
+
loop do
|
|
19
|
+
raise Phronomy::RuntimeShutdownError, "Runtime is draining" if @draining
|
|
20
|
+
if (owner = @owners[id])
|
|
21
|
+
raise Phronomy::Persistence::ConflictError, "Team #{id} already exists" if create
|
|
22
|
+
unless owner.is_a?(klass) && owner.persistence.equal?(persistence)
|
|
23
|
+
raise Phronomy::ConfigurationError, "Team #{id} owner or Persistence mismatch"
|
|
24
|
+
end
|
|
25
|
+
return owner
|
|
26
|
+
end
|
|
27
|
+
break unless @constructing[id]
|
|
28
|
+
@condition.wait(@mutex)
|
|
29
|
+
end
|
|
30
|
+
@constructing[id] = true
|
|
31
|
+
end
|
|
32
|
+
begin
|
|
33
|
+
owner = yield
|
|
34
|
+
@mutex.synchronize { @owners[id] = owner }
|
|
35
|
+
owner
|
|
36
|
+
ensure
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
@constructing.delete(id)
|
|
39
|
+
@condition.broadcast
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def get(id, klass:)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
owner = @owners[id]
|
|
47
|
+
if owner && !owner.is_a?(klass)
|
|
48
|
+
raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
|
|
49
|
+
end
|
|
50
|
+
owner
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def begin_draining
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@draining = true
|
|
57
|
+
@condition.broadcast
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def wait_until_stable(deadline)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
until @constructing.empty?
|
|
64
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
return false if remaining <= 0
|
|
66
|
+
@condition.wait(@mutex, remaining)
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def shutdown!
|
|
73
|
+
@mutex.synchronize { @owners.clear }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -3,21 +3,16 @@
|
|
|
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"
|
|
7
|
+
require_relative "runtime/team_ownership_registry"
|
|
6
8
|
|
|
7
9
|
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
10
|
class Runtime
|
|
14
11
|
@instance_mutex = Mutex.new
|
|
15
12
|
|
|
16
13
|
class << self
|
|
17
14
|
def instance
|
|
18
|
-
instance_mutex.synchronize
|
|
19
|
-
@instance ||= new
|
|
20
|
-
end
|
|
15
|
+
instance_mutex.synchronize { @instance ||= new }
|
|
21
16
|
end
|
|
22
17
|
|
|
23
18
|
def default_if_initialized_for_test
|
|
@@ -69,7 +64,9 @@ module Phronomy
|
|
|
69
64
|
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
70
65
|
timer_queue_provider: -> { timer_queue }
|
|
71
66
|
)
|
|
72
|
-
@
|
|
67
|
+
@multi_agent_admissions = Phronomy::MultiAgent::AdmissionRegistry.new
|
|
68
|
+
@agent_ownership_registry = AgentOwnershipRegistry.new(runtime: self)
|
|
69
|
+
@team_ownership_registry = TeamOwnershipRegistry.new
|
|
73
70
|
@lifecycle_mutex = Mutex.new
|
|
74
71
|
@shutdown_mutex = Mutex.new
|
|
75
72
|
@state = :running
|
|
@@ -95,7 +92,6 @@ module Phronomy
|
|
|
95
92
|
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
96
93
|
end
|
|
97
94
|
|
|
98
|
-
# Public timer access also ensures that the EventLoop that drives timers is alive.
|
|
99
95
|
def timer_queue
|
|
100
96
|
ensure_accepting_work!
|
|
101
97
|
timer = @timer_service.timer_queue
|
|
@@ -103,15 +99,87 @@ module Phronomy
|
|
|
103
99
|
timer
|
|
104
100
|
end
|
|
105
101
|
|
|
106
|
-
# Internal EventLoop access that does not recursively initialise EventLoop.
|
|
107
102
|
def __timer_queue
|
|
108
103
|
@timer_service.timer_queue
|
|
109
104
|
end
|
|
110
105
|
|
|
111
|
-
#
|
|
112
|
-
# state and
|
|
113
|
-
|
|
114
|
-
|
|
106
|
+
# Returns only an immutable process-local routing/ownership view. Mutable
|
|
107
|
+
# execution state remains inside EventLoop and is never exposed to callers.
|
|
108
|
+
# @api private
|
|
109
|
+
def __agent_execution_owner(execution_id)
|
|
110
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
111
|
+
loop_instance&.agent_execution_owner(execution_id)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @api private
|
|
115
|
+
def __team_owner(id, klass:, create:, persistence:, &block)
|
|
116
|
+
@team_ownership_registry.fetch(id, klass: klass, create: create, persistence: persistence, &block)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def __get_team(id, klass:)
|
|
121
|
+
@team_ownership_registry.get(id.to_s, klass: klass)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @api private
|
|
125
|
+
def __create_agent(agent_id, expected_class:, &block)
|
|
126
|
+
@agent_ownership_registry.create(agent_id, expected_class: expected_class, &block)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @api private
|
|
130
|
+
def __load_agent(agent_id, expected_class:, &block)
|
|
131
|
+
@agent_ownership_registry.load(agent_id, expected_class: expected_class, &block)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def __get_agent(agent_id, expected_class:)
|
|
136
|
+
@agent_ownership_registry.get(agent_id, expected_class: expected_class)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# @api private
|
|
140
|
+
def __agent_owned?(agent)
|
|
141
|
+
@agent_ownership_registry.owned?(agent)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @api private
|
|
145
|
+
def __begin_agent_purge(agent)
|
|
146
|
+
@agent_ownership_registry.begin_purge(agent)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def __complete_agent_purge(agent, token)
|
|
151
|
+
@agent_ownership_registry.complete_purge(agent, token)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# @api private
|
|
155
|
+
def __abort_agent_purge(agent, token)
|
|
156
|
+
@agent_ownership_registry.abort_purge(agent, token)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def __leave_agent_purge_uncertain(agent, token)
|
|
161
|
+
@agent_ownership_registry.leave_purge_uncertain(agent, token)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# @api private
|
|
165
|
+
def __agent_execution_admitted?(agent_id)
|
|
166
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
167
|
+
loop_instance&.agent_execution_admitted?(agent_id) || false
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @api private
|
|
171
|
+
def __admit_multi_agent(coordinator)
|
|
172
|
+
current_state = @lifecycle_mutex.synchronize { @state }
|
|
173
|
+
unless current_state == :running
|
|
174
|
+
raise Phronomy::RuntimeShutdownError,
|
|
175
|
+
"Runtime is #{current_state}; new Multi-Agent turns are not accepted"
|
|
176
|
+
end
|
|
177
|
+
@multi_agent_admissions.admit!(coordinator)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @api private
|
|
181
|
+
def __release_multi_agent(coordinator)
|
|
182
|
+
@multi_agent_admissions.release!(coordinator)
|
|
115
183
|
end
|
|
116
184
|
|
|
117
185
|
def event_loop
|
|
@@ -163,23 +231,24 @@ module Phronomy
|
|
|
163
231
|
@shutdown_mutex.synchronize do
|
|
164
232
|
return @shutdown_result if @shutdown_result
|
|
165
233
|
|
|
166
|
-
# Phase 1 — drain sessions with the full configured grace.
|
|
167
234
|
drain_deadline = monotonic_now + timeout
|
|
168
235
|
loop_instance = @lifecycle_mutex.synchronize do
|
|
169
236
|
@state = :draining unless @state == :failed
|
|
170
237
|
@event_loop
|
|
171
238
|
end
|
|
172
239
|
loop_instance&.begin_draining
|
|
240
|
+
@agent_ownership_registry.begin_draining
|
|
241
|
+
@team_ownership_registry.begin_draining
|
|
173
242
|
|
|
243
|
+
admission_idle = @multi_agent_admissions.wait_until_idle(drain_deadline)
|
|
244
|
+
agent_ownership_stable = @agent_ownership_registry.wait_until_stable(drain_deadline)
|
|
245
|
+
team_ownership_stable = @team_ownership_registry.wait_until_stable(drain_deadline)
|
|
174
246
|
loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
|
|
175
247
|
|
|
176
248
|
@lifecycle_mutex.synchronize do
|
|
177
249
|
@state = :stopping unless @state == :failed
|
|
178
250
|
end
|
|
179
251
|
|
|
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
252
|
stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
|
|
184
253
|
event_loop_status = if loop_instance
|
|
185
254
|
loop_instance.stop_and_join(deadline: stop_deadline)
|
|
@@ -188,7 +257,7 @@ module Phronomy
|
|
|
188
257
|
end
|
|
189
258
|
|
|
190
259
|
subsystem_error = shutdown_pools_and_timer
|
|
191
|
-
cleanup_complete = loop_idle &&
|
|
260
|
+
cleanup_complete = admission_idle && agent_ownership_stable && team_ownership_stable && loop_idle &&
|
|
192
261
|
(!loop_instance || !loop_instance.thread_alive?) &&
|
|
193
262
|
event_loop_status != :cancel_timeout &&
|
|
194
263
|
subsystem_error.nil?
|
|
@@ -200,6 +269,9 @@ module Phronomy
|
|
|
200
269
|
:terminated
|
|
201
270
|
end
|
|
202
271
|
|
|
272
|
+
@agent_ownership_registry.shutdown! if cleanup_complete
|
|
273
|
+
@team_ownership_registry.shutdown! if cleanup_complete
|
|
274
|
+
|
|
203
275
|
result = ShutdownResult.new(
|
|
204
276
|
runtime_outcome: runtime_outcome,
|
|
205
277
|
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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module MultiAgent
|
|
7
|
+
# Agent-owned transactions reserve child work; Task callbacks only wake observers.
|
|
8
|
+
# @api private
|
|
9
|
+
class DurableSubagentCoordinator
|
|
10
|
+
KEY = "multi_agent_coordination_ref"
|
|
11
|
+
|
|
12
|
+
def self.prepare(parent, execution, tx:)
|
|
13
|
+
ref = execution.metadata[KEY]
|
|
14
|
+
snapshot = ref ? tx.contents.fetch_json(ref) : {"kind" => "static_subagent", "children" => []}
|
|
15
|
+
children = snapshot.fetch("children").map(&:dup)
|
|
16
|
+
children.each do |child|
|
|
17
|
+
begin
|
|
18
|
+
exact = tx.executions.load(child.fetch("execution_id"))
|
|
19
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
raise Phronomy::Persistence::ConflictError, "Child owner mismatch" unless exact.agent_id == child.fetch("agent_id")
|
|
23
|
+
child.merge!("state" => exact.status.to_s, "result_ref" => exact.result_ref, "error_ref" => exact.error_ref)
|
|
24
|
+
end
|
|
25
|
+
Array(execution.metadata[Phronomy::Agent::RecoverySupport::TOOL_BATCH_METADATA_KEY]).each do |tool|
|
|
26
|
+
registration = parent.class.registered_subagents.find { |name, _| "dispatch_to_#{name}" == tool.fetch("tool_name") }
|
|
27
|
+
next unless registration
|
|
28
|
+
next if children.any? { |child| child.fetch("slot") == tool.fetch("tool_invocation_id") }
|
|
29
|
+
name, settings = registration
|
|
30
|
+
definition = settings.fetch(:agent_class).agent_definition
|
|
31
|
+
knowledge = settings.fetch(:inherit_knowledge) ? parent.send(:active_knowledge_snapshot) : []
|
|
32
|
+
children << {
|
|
33
|
+
"slot" => tool.fetch("tool_invocation_id"), "name" => name.to_s,
|
|
34
|
+
"definition" => definition.transform_keys(&:to_s),
|
|
35
|
+
"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid,
|
|
36
|
+
"input_ref" => tx.contents.put_text(tool.fetch("arguments").fetch("input")),
|
|
37
|
+
"durable_context_ref" => execution.metadata["durable_context_ref"],
|
|
38
|
+
"knowledge_ref" => tx.contents.put_json(Phronomy::Agent::RecoverySupport.canonical_copy(knowledge)),
|
|
39
|
+
"state" => "reserved", "result_ref" => nil, "error_ref" => nil,
|
|
40
|
+
"on_error" => settings.fetch(:on_error).to_s
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
return execution if children.empty?
|
|
44
|
+
value = tx.contents.put_json(snapshot.merge("children" => children))
|
|
45
|
+
execution.with(execution_revision: execution.execution_revision, metadata: execution.metadata.merge(KEY => value))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
|
|
49
|
+
runtime = Phronomy::Runtime.instance
|
|
50
|
+
completion = Phronomy::Task.deferred(name: "durable-subagent:#{tool_invocation_id}")
|
|
51
|
+
preparation = runtime.offload.submit(on_full: :raise) do
|
|
52
|
+
current = parent.persistence.executions.load(parent_execution_id)
|
|
53
|
+
raise Phronomy::Persistence::ConflictError, "Parent owner mismatch" unless current.agent_id == parent.agent_id
|
|
54
|
+
snapshot = parent.persistence.contents.fetch_json(current.metadata.fetch(KEY))
|
|
55
|
+
child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
|
|
56
|
+
raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
|
|
57
|
+
definition = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
|
|
58
|
+
unless definition && definition.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
|
|
59
|
+
raise Phronomy::ConfigurationError, "Registered child definition changed: #{child.fetch("name")}"
|
|
60
|
+
end
|
|
61
|
+
klass = definition.fetch(:agent_class)
|
|
62
|
+
id = child.fetch("agent_id")
|
|
63
|
+
agent = klass.get(id)
|
|
64
|
+
unless agent
|
|
65
|
+
begin
|
|
66
|
+
parent.persistence.agents.load(id)
|
|
67
|
+
exists = true
|
|
68
|
+
rescue Phronomy::Persistence::NotFoundError
|
|
69
|
+
exists = false
|
|
70
|
+
end
|
|
71
|
+
listener = parent.send(:_phronomy_event_listener)
|
|
72
|
+
agent = if exists
|
|
73
|
+
klass.load(id, persistence: parent.persistence, on_event: listener)
|
|
74
|
+
else
|
|
75
|
+
knowledge = parent.persistence.contents.fetch_json(child.fetch("knowledge_ref"))
|
|
76
|
+
context = Phronomy::Agent::ContextImporter::ImportedContext.new(records: knowledge.map do |item|
|
|
77
|
+
Phronomy::Agent::ContextImporter::ImportedRecord.new(kind: :knowledge,
|
|
78
|
+
channel: :context, role: :user, content: item.fetch("content"),
|
|
79
|
+
content_format: :text, metadata: item.fetch("metadata"))
|
|
80
|
+
end)
|
|
81
|
+
klass.create(agent_id: id, persistence: parent.persistence,
|
|
82
|
+
context: context, on_event: listener)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
unless agent.persistence.equal?(parent.persistence)
|
|
86
|
+
raise Phronomy::ConfigurationError, "Child Persistence instance mismatch"
|
|
87
|
+
end
|
|
88
|
+
durable_context = child["durable_context_ref"] && parent.persistence.contents.fetch_json(child["durable_context_ref"])
|
|
89
|
+
[child, agent, parent.persistence.contents.fetch_text(child.fetch("input_ref")), durable_context]
|
|
90
|
+
end
|
|
91
|
+
preparation.on_complete do |prepared, failure|
|
|
92
|
+
if failure
|
|
93
|
+
completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child reservation requires recovery: #{failure.message}"))
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
child, agent, input, durable_context = prepared
|
|
97
|
+
child_config = {cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new,
|
|
98
|
+
invocation_context: config[:invocation_context],
|
|
99
|
+
phronomy_coordination: {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
|
|
100
|
+
"parent_agent_id" => parent.agent_id, "slot" => tool_invocation_id}}.compact
|
|
101
|
+
child_config = child_config.merge(durable_context: durable_context) if child["durable_context_ref"]
|
|
102
|
+
source = Phronomy::Agent::ExactExecution.start(agent: agent,
|
|
103
|
+
execution_id: child.fetch("execution_id"), input: input, config: child_config)
|
|
104
|
+
source.on_complete do |result, error|
|
|
105
|
+
if error
|
|
106
|
+
if error.is_a?(Phronomy::CancellationError)
|
|
107
|
+
completion.fail(error)
|
|
108
|
+
else
|
|
109
|
+
completion.fail(Phronomy::ExecutionRehydrationRequiredError.new(
|
|
110
|
+
"Child #{child.fetch("execution_id")} is unfinished: #{error.message}"
|
|
111
|
+
))
|
|
112
|
+
end
|
|
113
|
+
elsif result[:error]
|
|
114
|
+
if child.fetch("on_error") == "skip"
|
|
115
|
+
completion.complete(nil)
|
|
116
|
+
else
|
|
117
|
+
completion.fail(Phronomy::Agent::RecoverySupport.error_from_failure(result[:error]))
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
completion.complete(result[:output])
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
rescue => error
|
|
124
|
+
completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child coordination requires recovery: #{error.message}"))
|
|
125
|
+
end
|
|
126
|
+
completion
|
|
127
|
+
rescue => error
|
|
128
|
+
completion ||= Phronomy::Task.deferred(name: "durable-subagent")
|
|
129
|
+
completion.fail(error)
|
|
130
|
+
completion
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|