phronomy 0.30.1 → 0.32.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/CHANGELOG.md +129 -0
- data/README.md +13 -2
- data/docs/architecture/agent-tool-child-lifetime.md +141 -0
- data/docs/architecture/dx05-agent-scope.md +4 -2
- data/docs/architecture/structured-output-and-scope.md +73 -13
- data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
- data/docs/features.md +2 -0
- data/docs/getting-started.md +26 -0
- data/docs/migrations/0.31.md +72 -0
- data/docs/runtime-and-concurrency.md +159 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +77 -8
- data/lib/phronomy/agent/base.rb +28 -3
- data/lib/phronomy/agent/composition/run_once.rb +11 -10
- data/lib/phronomy/agent/context_preparation.rb +1 -0
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
- data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
- data/lib/phronomy/agent/execution_change.rb +4 -0
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
- data/lib/phronomy/agent/output_contract.rb +47 -5
- data/lib/phronomy/agent/persistence/codec.rb +16 -2
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
- data/lib/phronomy/agent/runtime_input.rb +7 -0
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +16 -3
- data/lib/phronomy/execution/async_operation.rb +16 -0
- data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
- data/lib/phronomy/generation/generator_verifier.rb +6 -3
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/tools/agent.rb +58 -22
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +9 -0
- data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
- data/scripts/api_snapshot.rb +2 -0
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +8 -5
- data/sig/phronomy/execution/async_operation.rbs +1 -0
- data/sig/phronomy/generation/generator_verifier.rbs +1 -1
- data/sig/phronomy/multi_agent.rbs +2 -0
- data/sig/phronomy/tools/agent.rbs +14 -0
- data/sig/phronomy/workflow.rbs +1 -0
- metadata +8 -2
|
@@ -147,8 +147,9 @@ module Phronomy
|
|
|
147
147
|
ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
|
|
148
148
|
assistants = ordered.select { |item| item.kind == :assistant_message }
|
|
149
149
|
tool_messages = ordered.select { |item| item.kind == :tool_message }
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
tool_messages_by_call = tool_messages.group_by { |item| tool_exchange_key(item, item.tool_call_id) }
|
|
151
|
+
tool_messages_by_call.each_value do |messages|
|
|
152
|
+
tool_call_id = messages.first.tool_call_id
|
|
152
153
|
if tool_call_id.nil? || tool_call_id.empty?
|
|
153
154
|
raise ArgumentError, "Tool message Context item requires tool_call_id"
|
|
154
155
|
end
|
|
@@ -160,11 +161,12 @@ module Phronomy
|
|
|
160
161
|
assistant_by_tool_call = {}
|
|
161
162
|
assistants.each do |assistant|
|
|
162
163
|
assistant.tool_call_ids.each do |tool_call_id|
|
|
163
|
-
|
|
164
|
+
key = tool_exchange_key(assistant, tool_call_id)
|
|
165
|
+
if assistant_by_tool_call.key?(key)
|
|
164
166
|
raise ArgumentError,
|
|
165
167
|
"duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
|
|
166
168
|
end
|
|
167
|
-
assistant_by_tool_call[
|
|
169
|
+
assistant_by_tool_call[key] = assistant
|
|
168
170
|
end
|
|
169
171
|
end
|
|
170
172
|
|
|
@@ -173,26 +175,25 @@ module Phronomy
|
|
|
173
175
|
ordered.each do |item|
|
|
174
176
|
next if claimed[item.id]
|
|
175
177
|
|
|
176
|
-
if item.kind == :assistant_message && !item.tool_call_ids.empty?
|
|
177
|
-
|
|
178
|
-
|
|
178
|
+
assistant = if item.kind == :assistant_message && !item.tool_call_ids.empty?
|
|
179
|
+
item
|
|
180
|
+
elsif item.kind == :tool_message
|
|
181
|
+
assistant_by_tool_call.fetch(tool_exchange_key(item, item.tool_call_id)) do
|
|
182
|
+
raise ArgumentError, "orphan Tool message in Context Policy input: #{item.tool_call_id}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
group = if assistant
|
|
187
|
+
messages = assistant.tool_call_ids.map do |tool_call_id|
|
|
188
|
+
key = tool_exchange_key(assistant, tool_call_id)
|
|
189
|
+
tool_messages_by_call.fetch(key) do
|
|
179
190
|
raise ArgumentError,
|
|
180
191
|
"assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
|
|
181
192
|
end.first
|
|
182
193
|
end
|
|
183
|
-
|
|
184
|
-
elsif item.kind == :tool_message
|
|
185
|
-
assistant = assistant_by_tool_call[item.tool_call_id]
|
|
186
|
-
unless assistant
|
|
187
|
-
raise ArgumentError,
|
|
188
|
-
"orphan Tool message in Context Policy input: #{item.tool_call_id}"
|
|
189
|
-
end
|
|
190
|
-
next if claimed[assistant.id]
|
|
191
|
-
group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
|
|
192
|
-
tool_messages_by_id.fetch(tool_call_id).first
|
|
193
|
-
end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
|
|
194
|
+
[assistant, *messages].sort_by { |member| [member.sequence || 0, member.id] }
|
|
194
195
|
else
|
|
195
|
-
|
|
196
|
+
[item]
|
|
196
197
|
end
|
|
197
198
|
|
|
198
199
|
group.each { |member| claimed[member.id] = true }
|
|
@@ -202,6 +203,13 @@ module Phronomy
|
|
|
202
203
|
mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
|
|
203
204
|
end
|
|
204
205
|
|
|
206
|
+
# Providers can number Tool calls afresh for each LLM response. Both native
|
|
207
|
+
# journal records carry the same durable LLM call ID, including after reload.
|
|
208
|
+
def tool_exchange_key(item, tool_call_id)
|
|
209
|
+
origin = item.provenance
|
|
210
|
+
[origin.agent_id, origin.execution_id, origin.llm_call_id, tool_call_id]
|
|
211
|
+
end
|
|
212
|
+
|
|
205
213
|
def mark_framework_required(groups, execution_id:, call_mode:)
|
|
206
214
|
return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
|
|
207
215
|
|
|
@@ -96,6 +96,7 @@ module Phronomy
|
|
|
96
96
|
recursion_limit_scope: :session,
|
|
97
97
|
resume_event: nil,
|
|
98
98
|
resume_phase: nil,
|
|
99
|
+
require_resume_transition: false,
|
|
99
100
|
stable_observer: nil,
|
|
100
101
|
context_metadata: {},
|
|
101
102
|
event_sink: nil,
|
|
@@ -132,6 +133,7 @@ module Phronomy
|
|
|
132
133
|
@event_loop = event_loop
|
|
133
134
|
@resume_event = resume_event
|
|
134
135
|
@resume_phase = resume_phase
|
|
136
|
+
@require_resume_transition = require_resume_transition
|
|
135
137
|
@stable_observer = stable_observer
|
|
136
138
|
@terminal_policy = terminal_policy
|
|
137
139
|
@terminal_lifecycle_state = :running
|
|
@@ -153,7 +155,8 @@ module Phronomy
|
|
|
153
155
|
type: @resume_event,
|
|
154
156
|
target_id: @id,
|
|
155
157
|
payload: nil
|
|
156
|
-
)
|
|
158
|
+
),
|
|
159
|
+
require_transition: @require_resume_transition
|
|
157
160
|
)
|
|
158
161
|
else
|
|
159
162
|
@current_state = @entry_point
|
|
@@ -165,6 +168,10 @@ module Phronomy
|
|
|
165
168
|
end
|
|
166
169
|
rescue => error
|
|
167
170
|
finish_with_error(error)
|
|
171
|
+
ensure
|
|
172
|
+
# The session owns its context between events. A tracker retained by the
|
|
173
|
+
# state-machine library or VM must not retain that live context as well.
|
|
174
|
+
@tracker.context = nil if @tracker
|
|
168
175
|
end
|
|
169
176
|
|
|
170
177
|
def handle(event)
|
|
@@ -190,6 +197,9 @@ module Phronomy
|
|
|
190
197
|
fire_and_advance!(event)
|
|
191
198
|
rescue => error
|
|
192
199
|
finish_with_error(error)
|
|
200
|
+
ensure
|
|
201
|
+
# fire_and_advance! rebinds the context for the next accepted transition.
|
|
202
|
+
@tracker.context = nil if @tracker
|
|
193
203
|
end
|
|
194
204
|
|
|
195
205
|
private
|
|
@@ -235,7 +245,7 @@ module Phronomy
|
|
|
235
245
|
end
|
|
236
246
|
end
|
|
237
247
|
|
|
238
|
-
def fire_and_advance!(event)
|
|
248
|
+
def fire_and_advance!(event, require_transition: false)
|
|
239
249
|
automatic = event.type == :state_completed
|
|
240
250
|
exhausted = if @recursion_limit_scope == :automatic_chain
|
|
241
251
|
automatic && @automatic_steps >= @recursion_limit
|
|
@@ -251,7 +261,10 @@ module Phronomy
|
|
|
251
261
|
clear_selected_transition!
|
|
252
262
|
@tracker.current_event = event if @tracker.respond_to?(:current_event=)
|
|
253
263
|
transitioned = fire_event!(@tracker, event.type, @current_state)
|
|
254
|
-
|
|
264
|
+
unless transitioned
|
|
265
|
+
raise ArgumentError, "Event #{event.type.inspect} was rejected at #{@current_state.inspect}" if require_transition
|
|
266
|
+
return
|
|
267
|
+
end
|
|
255
268
|
|
|
256
269
|
@ctx = @tracker.context
|
|
257
270
|
@context = @ctx
|
|
@@ -25,6 +25,22 @@ module Phronomy
|
|
|
25
25
|
map(source, name: name, on_error: on_error, &transform)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Finalize owned work after its logical and physical boundaries. Cleanup is
|
|
29
|
+
# asynchronous too. The owner supplies cleanup and combined-error meaning;
|
|
30
|
+
# neither observer cancellation nor an Execution scope can skip cleanup.
|
|
31
|
+
# @api private
|
|
32
|
+
def self.finalize(name:, cleanup:, on_cleanup_error:)
|
|
33
|
+
source = begin
|
|
34
|
+
value = yield
|
|
35
|
+
raise TypeError, "finalized operation must return a TaskResult" unless value.is_a?(TaskResult)
|
|
36
|
+
value
|
|
37
|
+
rescue => error
|
|
38
|
+
TaskResult.failed(error, name: name)
|
|
39
|
+
end
|
|
40
|
+
Concurrency::ResultFinalization.new(source, name: name,
|
|
41
|
+
cleanup: cleanup, on_cleanup_error: on_cleanup_error).start
|
|
42
|
+
end
|
|
43
|
+
|
|
28
44
|
# Short synchronous fallback/validation only; never offloads or awaits work.
|
|
29
45
|
def self.capture(name:, on_error: nil)
|
|
30
46
|
value = yield
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# Completion composition for an owner-supplied asynchronous finalizer.
|
|
6
|
+
# This has no cancellation authority and no domain release policy.
|
|
7
|
+
# @api private
|
|
8
|
+
class ResultFinalization
|
|
9
|
+
def initialize(source, name:, cleanup:, on_cleanup_error:)
|
|
10
|
+
@source = source
|
|
11
|
+
@cleanup = cleanup
|
|
12
|
+
@on_cleanup_error = on_cleanup_error
|
|
13
|
+
@result = PhysicalCompletionTask.deferred(name: name)
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@phase = :source
|
|
16
|
+
@outcomes = {}
|
|
17
|
+
@physical = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
observe(@source, :source)
|
|
22
|
+
@result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def observe(task, phase)
|
|
28
|
+
physical = task.respond_to?(:on_physical_complete)
|
|
29
|
+
@mutex.synchronize { @physical[phase] = !physical }
|
|
30
|
+
if physical
|
|
31
|
+
task.on_physical_complete do
|
|
32
|
+
@mutex.synchronize { @physical[phase] = true unless @phase == :finished }
|
|
33
|
+
advance
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
task.on_complete do |value, error|
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
if @phase == phase && !@outcomes.key?(phase)
|
|
39
|
+
status = if error
|
|
40
|
+
(task.status == :cancelled) ? :cancelled : :failed
|
|
41
|
+
else
|
|
42
|
+
:completed
|
|
43
|
+
end
|
|
44
|
+
@outcomes[phase] = [status, value, error]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
advance
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def advance
|
|
52
|
+
action = @mutex.synchronize do
|
|
53
|
+
phase = @phase
|
|
54
|
+
next unless @outcomes.key?(phase) && @physical[phase]
|
|
55
|
+
@phase = (phase == :source) ? :cleanup : :finished
|
|
56
|
+
phase
|
|
57
|
+
end
|
|
58
|
+
case action
|
|
59
|
+
when :source then start_cleanup
|
|
60
|
+
when :cleanup then finish
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start_cleanup
|
|
65
|
+
task = begin
|
|
66
|
+
value = @cleanup.call
|
|
67
|
+
raise TypeError, "asynchronous cleanup must return a TaskResult" unless value.is_a?(TaskResult)
|
|
68
|
+
value
|
|
69
|
+
rescue => error
|
|
70
|
+
TaskResult.failed(error)
|
|
71
|
+
end
|
|
72
|
+
observe(task, :cleanup)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finish
|
|
76
|
+
status, value, error = @outcomes.fetch(:source)
|
|
77
|
+
cleanup_error = @outcomes.fetch(:cleanup).last
|
|
78
|
+
if cleanup_error
|
|
79
|
+
begin
|
|
80
|
+
error = @on_cleanup_error.call(value, error, cleanup_error)
|
|
81
|
+
raise TypeError, "cleanup error policy must return or raise an Exception" unless error.is_a?(Exception)
|
|
82
|
+
rescue => policy_error
|
|
83
|
+
error = policy_error
|
|
84
|
+
end
|
|
85
|
+
status = :failed
|
|
86
|
+
end
|
|
87
|
+
# Completed handles must not keep the owner or its source/cleanup
|
|
88
|
+
# closures alive. A domain cleanup error may deliberately retain them.
|
|
89
|
+
@source = @cleanup = @on_cleanup_error = nil
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@outcomes.clear
|
|
92
|
+
@physical.clear
|
|
93
|
+
end
|
|
94
|
+
begin
|
|
95
|
+
@result.mark_physical_complete!
|
|
96
|
+
ensure
|
|
97
|
+
case status
|
|
98
|
+
when :completed then @result.complete(value)
|
|
99
|
+
when :cancelled then @result.cancel!(error)
|
|
100
|
+
else @result.fail(error)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -8,8 +8,8 @@ module Phronomy
|
|
|
8
8
|
# still pending. Generation owns the request-to-Workflow correlation.
|
|
9
9
|
# @api private
|
|
10
10
|
class AgentOperation
|
|
11
|
-
def initialize(agent_class, output_schema:)
|
|
12
|
-
@output_schema = output_schema
|
|
11
|
+
def initialize(agent_class, output_schema:, schema_delivery:)
|
|
12
|
+
@output_schema, @schema_delivery = output_schema, schema_delivery
|
|
13
13
|
@mutex = Mutex.new
|
|
14
14
|
@pending = []
|
|
15
15
|
@agent = agent_class.new(on_event: method(:receive_event))
|
|
@@ -21,7 +21,7 @@ module Phronomy
|
|
|
21
21
|
# own result settles: Agent admission itself may run after an earlier
|
|
22
22
|
# request completes, so a pending submission is not necessarily busy.
|
|
23
23
|
@mutex.synchronize { @pending << [request, listener] }
|
|
24
|
-
task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
|
|
24
|
+
task = @agent.invoke_async(input, config: config, output_schema: @output_schema, schema_delivery: @schema_delivery)
|
|
25
25
|
task.on_complete do |result, error|
|
|
26
26
|
release(request)
|
|
27
27
|
remember_execution(result[:execution_id]) if result.is_a?(Hash)
|
|
@@ -34,10 +34,13 @@ module Phronomy
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def initialize(draft_agent:, review_agent:, draft_prompt_builder:, review_prompt_builder:,
|
|
37
|
-
citation_schema: Protocol::DEFAULT_CITATION,
|
|
37
|
+
citation_schema: Protocol::DEFAULT_CITATION, schema_delivery: :native,
|
|
38
38
|
confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
|
|
39
39
|
max_iterations: DEFAULT_MAX_ITERATIONS, raise_if_unaccepted: false, on_approval: nil)
|
|
40
40
|
ensure_blocking_allowed!(:new)
|
|
41
|
+
unless %i[native none].include?(schema_delivery)
|
|
42
|
+
raise ArgumentError, "schema_delivery must be :native or :none"
|
|
43
|
+
end
|
|
41
44
|
unless confidence_threshold.is_a?(Numeric) && confidence_threshold.real? && confidence_threshold.to_f.finite? &&
|
|
42
45
|
(0.0..1.0).cover?(confidence_threshold)
|
|
43
46
|
raise ArgumentError, "confidence_threshold must be a finite number in 0..1"
|
|
@@ -62,8 +65,8 @@ module Phronomy
|
|
|
62
65
|
@draft_prompt_builder, @review_prompt_builder = draft_prompt_builder, review_prompt_builder
|
|
63
66
|
draft_schema = Protocol.draft(citation_schema)
|
|
64
67
|
begin
|
|
65
|
-
@operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema)
|
|
66
|
-
@operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW)
|
|
68
|
+
@operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema, schema_delivery: schema_delivery)
|
|
69
|
+
@operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW, schema_delivery: schema_delivery)
|
|
67
70
|
rescue Exception => primary # rubocop:disable Lint/RescueException -- release partially acquired resources
|
|
68
71
|
begin
|
|
69
72
|
release!
|
|
@@ -63,7 +63,7 @@ module Phronomy
|
|
|
63
63
|
callback_error = nil
|
|
64
64
|
chat = build_chat(request)
|
|
65
65
|
capture = StructuredResponseCapture.new
|
|
66
|
-
capture.bind(chat) if
|
|
66
|
+
capture.bind(chat) if block_given?
|
|
67
67
|
token&.raise_if_cancelled!
|
|
68
68
|
chat.after_message do |message|
|
|
69
69
|
raise ToolsRequested.new(message) if message.tool_call?
|
|
@@ -24,7 +24,9 @@ module Phronomy
|
|
|
24
24
|
|
|
25
25
|
def record(data)
|
|
26
26
|
choice = data.dig("choices", 0) || {}
|
|
27
|
-
|
|
27
|
+
provider_delta = data["delta"]
|
|
28
|
+
stop_reason = provider_delta["stop_reason"] if provider_delta.is_a?(Hash)
|
|
29
|
+
@raw_finish_reason = choice["finish_reason"] || stop_reason ||
|
|
28
30
|
data.dig("candidates", 0, "finishReason") || @raw_finish_reason
|
|
29
31
|
delta = choice.dig("delta", "refusal")
|
|
30
32
|
delta ||= data["delta"] if data["type"] == "response.refusal.delta"
|
|
@@ -83,6 +83,91 @@ module Phronomy
|
|
|
83
83
|
extension&.state_ref ? persistence.contents.fetch_json(extension.state_ref) : {"children" => [], "cancel_requested" => false}
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Validate both ends of the reservation in one durable snapshot. Only
|
|
87
|
+
# one direct, unfinished child belongs to this hosted handoff.
|
|
88
|
+
def child_approval_wait(parent_id, parent_execution_id, execution_id, approval_id, parent_revision: nil, live_child: nil)
|
|
89
|
+
store = persistence.agent_store
|
|
90
|
+
persistence.coordinator.atomic do |scope|
|
|
91
|
+
extension = store.execution_extension(agent_id: parent_id, execution_id: parent_execution_id,
|
|
92
|
+
binding_key: BINDING_KEY, scope: scope)
|
|
93
|
+
next unless extension&.state_ref && extension.binding_version == 1
|
|
94
|
+
snapshot = state_for(extension)
|
|
95
|
+
children = snapshot.fetch("children")
|
|
96
|
+
next unless !snapshot["cancel_requested"] && children.size == 1
|
|
97
|
+
child = children.first
|
|
98
|
+
next unless !child.fetch("imported") && child.fetch("execution_id") == execution_id
|
|
99
|
+
next unless matching_child_class?(child, live_child)
|
|
100
|
+
next unless store.single_dispatch_wait?(agent_id: parent_id, execution_id: parent_execution_id,
|
|
101
|
+
invocation_id: child.fetch("slot"), name: "dispatch_to_#{child.fetch("name")}", expected_revision: parent_revision, scope: scope)
|
|
102
|
+
next unless hosted_retentions?(parent_id, parent_execution_id, scope)
|
|
103
|
+
|
|
104
|
+
child_id = child.fetch("agent_id")
|
|
105
|
+
owner = {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
|
|
106
|
+
"parent_agent_id" => parent_id, "slot" => child.fetch("slot")}
|
|
107
|
+
retention = Phronomy::Agent::Retention.new(agent_id: child_id, execution_id: execution_id,
|
|
108
|
+
owner_key: "subagent:#{parent_execution_id}")
|
|
109
|
+
wait = store.approval_wait_for(agent_id: child_id, execution_id: execution_id,
|
|
110
|
+
correlation: owner, retentions: [retention], scope: scope)
|
|
111
|
+
wait if wait&.approval_request_id == approval_id
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A terminal record alone does not show that its result reached the
|
|
116
|
+
# parent. Check the native slot, exact reservation and holds together.
|
|
117
|
+
def completed_child_pair?(parent_id, parent_execution_id, child, live_child:, scope:)
|
|
118
|
+
store = persistence.agent_store
|
|
119
|
+
return false unless store.terminal_execution?(agent_id: parent_id, execution_id: parent_execution_id, scope: scope)
|
|
120
|
+
children = stored_children(parent_id, parent_execution_id, scope)
|
|
121
|
+
return false unless children&.size == 1
|
|
122
|
+
slot = children.first
|
|
123
|
+
return false unless slot.fetch("imported") == true && slot.fetch("agent_id") == child.agent_id &&
|
|
124
|
+
slot.fetch("execution_id") == child.execution_id && matching_child_class?(slot, live_child)
|
|
125
|
+
|
|
126
|
+
owner = {"kind" => "subagent", "parent_agent_id" => parent_id, "parent_execution_id" => parent_execution_id,
|
|
127
|
+
"slot" => slot.fetch("slot")}
|
|
128
|
+
return false unless store.terminal_execution?(agent_id: child.agent_id, execution_id: child.execution_id,
|
|
129
|
+
reservation: owner, scope: scope)
|
|
130
|
+
hold = Phronomy::Agent::Retention.new(agent_id: child.agent_id, execution_id: child.execution_id,
|
|
131
|
+
owner_key: "subagent:#{parent_execution_id}")
|
|
132
|
+
return false unless store.retained_references(agent_id: child.agent_id, scope: scope) == [hold]
|
|
133
|
+
|
|
134
|
+
hosted_retentions?(parent_id, parent_execution_id, scope) do |prior_id|
|
|
135
|
+
prior = stored_children(parent_id, prior_id, scope)
|
|
136
|
+
prior && !prior.empty? && prior.all? { |entry| entry.fetch("imported") == true }
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private def stored_children(parent_id, execution_id, scope)
|
|
141
|
+
extension = persistence.agent_store.execution_extension(agent_id: parent_id, execution_id: execution_id,
|
|
142
|
+
binding_key: BINDING_KEY, scope: scope)
|
|
143
|
+
state_for(extension).fetch("children") if extension&.state_ref && extension.binding_version == 1
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
private def matching_child_class?(slot, live_child)
|
|
147
|
+
klass = self.class.registered_child_class(@agent_class, slot)
|
|
148
|
+
klass && (!live_child || (live_child.agent_id == slot.fetch("agent_id") && live_child.is_a?(klass) &&
|
|
149
|
+
live_child.class.agent_definition == klass.agent_definition && live_child.persistence.equal?(persistence.agent_store)))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Retaining the history of an earlier completed native dispatch on this
|
|
153
|
+
# same Agent does not give anyone a live interest in the current run.
|
|
154
|
+
private def hosted_retentions?(agent_id, execution_id, scope)
|
|
155
|
+
store = persistence.agent_store
|
|
156
|
+
current = Phronomy::Agent::Retention.new(agent_id: agent_id, execution_id: execution_id, owner_key: "subagent:#{execution_id}")
|
|
157
|
+
retentions = store.retained_references(agent_id: agent_id, scope: scope)
|
|
158
|
+
retentions.include?(current) && (retentions - [current]).all? do |retention|
|
|
159
|
+
retention.execution_id && retention.owner_key == "subagent:#{retention.execution_id}" &&
|
|
160
|
+
store.terminal_execution?(agent_id: agent_id, execution_id: retention.execution_id, scope: scope) &&
|
|
161
|
+
(!block_given? || yield(retention.execution_id))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.registered_child_class(parent_class, child)
|
|
166
|
+
settings = parent_class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
|
|
167
|
+
klass = settings&.fetch(:agent_class)
|
|
168
|
+
klass if klass && klass.agent_definition.transform_keys(&:to_s) == child.fetch("definition")
|
|
169
|
+
end
|
|
170
|
+
|
|
86
171
|
def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
|
|
87
172
|
completion = Phronomy::TaskResult.deferred(name: "durable-subagent:#{tool_invocation_id}")
|
|
88
173
|
preparation = Phronomy::Execution.submit(on_full: :raise) do
|
|
@@ -92,11 +177,8 @@ module Phronomy
|
|
|
92
177
|
snapshot = store.contents.fetch_json(extension.state_ref)
|
|
93
178
|
child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
|
|
94
179
|
raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
raise Phronomy::ConfigurationError, "Registered child definition changed"
|
|
98
|
-
end
|
|
99
|
-
klass = settings.fetch(:agent_class)
|
|
180
|
+
klass = registered_child_class(parent.class, child)
|
|
181
|
+
raise Phronomy::ConfigurationError, "Registered child definition changed" unless klass
|
|
100
182
|
id = child.fetch("agent_id")
|
|
101
183
|
retention = Phronomy::Agent::Retention.new(agent_id: id, execution_id: child.fetch("execution_id"), owner_key: "subagent:#{parent_execution_id}")
|
|
102
184
|
agent = klass.get(id)
|
|
@@ -29,6 +29,16 @@ module Phronomy
|
|
|
29
29
|
@participant = @coordination_store && DurableSubagentCoordinator.new(agent_class: self.class, persistence: @coordination_store)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Release the local parent and its directly dispatched child after the
|
|
33
|
+
# child's outcome has been imported. The stored history remains intact.
|
|
34
|
+
# @api public
|
|
35
|
+
def unload_with_completed_child!(parent_execution_id, execution_id:)
|
|
36
|
+
owner = {"kind" => "subagent", "parent_agent_id" => agent_id, "parent_execution_id" => parent_execution_id}
|
|
37
|
+
__unload_with_peer_execution!(execution_id, reserved_by: owner) do |child, live_child, scope|
|
|
38
|
+
@participant&.completed_child_pair?(agent_id, parent_execution_id, child, live_child: live_child, scope: scope)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# Current composition, never serialized in an execution record.
|
|
33
43
|
def __invocation_config(config)
|
|
34
44
|
if !self.class.registered_subagents.empty? && !@participant
|
|
@@ -38,6 +48,14 @@ module Phronomy
|
|
|
38
48
|
phronomy_execution_participant: @participant).compact
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
# Answers the current wait of a recovered direct child. If the same
|
|
52
|
+
# execution suspends again the host can release the pair with this handle.
|
|
53
|
+
# @api public
|
|
54
|
+
def approve_hosted_child(parent_execution_id, execution_id:, approval_request_id:, approved: true, config: {})
|
|
55
|
+
resume_hosted_child_approval(parent_execution_id, execution_id: execution_id,
|
|
56
|
+
approval_request_id: approval_request_id, approved: approved, config: config)
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
|
|
42
60
|
# A subagent Tool is logically asynchronous: ToolInvocation starts the
|
|
43
61
|
# child Agent and resumes when its completion TaskResult settles. It must not
|
|
@@ -224,6 +242,8 @@ module Phronomy
|
|
|
224
242
|
|
|
225
243
|
private
|
|
226
244
|
|
|
245
|
+
def hosted_child_approval_reader = @participant&.method(:child_approval_wait)
|
|
246
|
+
|
|
227
247
|
def prepare_tool_class(tool_class, invocation: nil)
|
|
228
248
|
prepared = super
|
|
229
249
|
return prepared unless self.class._subagent_tool_classes.include?(tool_class)
|
|
@@ -234,28 +254,20 @@ module Phronomy
|
|
|
234
254
|
end&.last
|
|
235
255
|
inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
|
|
236
256
|
|
|
237
|
-
|
|
257
|
+
context = {parent: self}
|
|
238
258
|
# Invocation-owned Tools inherit the durable child slot's knowledge,
|
|
239
259
|
# captured by the existing preparation transaction off EventLoop.
|
|
240
|
-
|
|
260
|
+
context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
|
|
241
261
|
if invocation
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
end
|
|
245
|
-
captured_context.freeze
|
|
246
|
-
|
|
247
|
-
effective_name = prepared.new.name
|
|
248
|
-
Class.new(prepared) do
|
|
249
|
-
tool_name effective_name
|
|
250
|
-
define_method(:call) do |args, **kwargs|
|
|
251
|
-
self._orchestrator_context = captured_context
|
|
252
|
-
super(args, **kwargs)
|
|
253
|
-
end
|
|
254
|
-
define_method(:call_async) do |args, **kwargs|
|
|
255
|
-
self._orchestrator_context = captured_context
|
|
256
|
-
super(args, **kwargs)
|
|
257
|
-
end
|
|
262
|
+
context[:config] = invocation.config
|
|
263
|
+
context[:invocation_context] = invocation.config[:invocation_context]
|
|
258
264
|
end
|
|
265
|
+
|
|
266
|
+
# Cached Tool methods can outlive an execution. Only the instance used
|
|
267
|
+
# for this dispatch should hold its parent and invocation configuration.
|
|
268
|
+
tool = prepared.new
|
|
269
|
+
tool._orchestrator_context = context.freeze
|
|
270
|
+
tool
|
|
259
271
|
end
|
|
260
272
|
|
|
261
273
|
def active_knowledge_snapshot = knowledge_snapshot
|
data/lib/phronomy/tools/agent.rb
CHANGED
|
@@ -7,13 +7,30 @@ module Phronomy
|
|
|
7
7
|
# Agent-backed Tools are logically asynchronous rather than offloaded
|
|
8
8
|
# synchronous operations. Their ToolInvocation starts the child Agent and then
|
|
9
9
|
# returns to EventLoop immediately; the Tool completion handle settles when the
|
|
10
|
-
# child Agent
|
|
11
|
-
# consumed merely to wait for another Agent.
|
|
10
|
+
# child Agent's physical work and release finish. An OffloadPool worker is
|
|
11
|
+
# therefore never consumed merely to wait for another Agent.
|
|
12
12
|
class Agent < Phronomy::Tool::Base
|
|
13
13
|
execution_mode :cooperative
|
|
14
14
|
description "Wraps a Phronomy::Agent as a Tool"
|
|
15
15
|
param :input, type: :string, desc: "The input to forward to the wrapped Agent"
|
|
16
16
|
|
|
17
|
+
# A private child's operation ended, but its safe release failed. The live
|
|
18
|
+
# reference supports retrying unload_async without repeating business work.
|
|
19
|
+
# This error and its child reference are process-local, not durable state.
|
|
20
|
+
# @api public
|
|
21
|
+
class CleanupError < Phronomy::ToolError
|
|
22
|
+
attr_reader :child_agent, :agent_id, :result, :primary_error, :cleanup_error
|
|
23
|
+
|
|
24
|
+
def initialize(child_agent:, result:, primary_error:, cleanup_error:)
|
|
25
|
+
@child_agent = child_agent
|
|
26
|
+
@agent_id = child_agent.agent_id
|
|
27
|
+
@result = result
|
|
28
|
+
@primary_error = primary_error
|
|
29
|
+
@cleanup_error = cleanup_error
|
|
30
|
+
super("Agent Tool cleanup failed for #{@agent_id.inspect}", code: "tool.agent_cleanup_failed")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
17
34
|
class << self
|
|
18
35
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
19
36
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
@@ -35,36 +52,34 @@ module Phronomy
|
|
|
35
52
|
# Preserve the synchronous Tool API for top-level callers. ToolInvocation
|
|
36
53
|
# never uses this path for Agent-backed Tools; it calls #call_async.
|
|
37
54
|
klass.define_method(:execute) do |input:, cancellation_token: nil|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
if Phronomy::WaitPolicy.blocking_forbidden?
|
|
56
|
+
raise Phronomy::EventLoopReentrancyError,
|
|
57
|
+
"Agent Tool execute cannot block EventLoop; use call_async"
|
|
41
58
|
end
|
|
42
|
-
|
|
43
|
-
# Preserve the creation arguments previously supplied by run_once.
|
|
44
|
-
persistence = Phronomy::Agent::DefaultPersistence.build
|
|
45
|
-
agent = agent_class.create(
|
|
46
|
-
context: nil,
|
|
47
|
-
knowledge: [],
|
|
48
|
-
persistence: persistence,
|
|
49
|
-
on_event: nil
|
|
50
|
-
)
|
|
51
|
-
result = agent.invoke(input, **invoke_options)
|
|
52
|
-
result[:output].to_s
|
|
59
|
+
execute_async(input: input, cancellation_token: cancellation_token).wait_result
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
# Internal asynchronous execution protocol used by Agent#call_async.
|
|
56
|
-
#
|
|
57
|
-
#
|
|
63
|
+
# C04 decides whether release is safe; C13 composes completion. The
|
|
64
|
+
# concrete Tool supplies only creation, output and cleanup policies.
|
|
58
65
|
klass.define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
unless config.nil? || config.is_a?(Hash)
|
|
67
|
+
raise ArgumentError, "Agent Tool config must be a Hash"
|
|
68
|
+
end
|
|
61
69
|
task_config = (config || {}).dup
|
|
62
70
|
if cancellation_token && !task_config[:cancellation_token]
|
|
63
71
|
task_config[:cancellation_token] = cancellation_token
|
|
64
72
|
end
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
|
|
74
|
+
persistence = Phronomy::Agent::DefaultPersistence.build
|
|
75
|
+
agent = agent_class.create(persistence: persistence)
|
|
76
|
+
Phronomy::AsyncOperation.finalize(name: "agent-tool-#{name}-release",
|
|
77
|
+
cleanup: -> { agent.unload_async },
|
|
78
|
+
on_cleanup_error: ->(value, primary, cleanup) { child_cleanup_failed(agent, value, primary, cleanup) }) do
|
|
79
|
+
Phronomy::AsyncOperation.call(name: "agent-tool-#{name}-invoke",
|
|
80
|
+
transform: ->(result) { result[:output].to_s }) do
|
|
81
|
+
agent.invoke_async(input, config: task_config)
|
|
82
|
+
end
|
|
68
83
|
end
|
|
69
84
|
end
|
|
70
85
|
klass.send(:private, :execute_async)
|
|
@@ -102,6 +117,27 @@ module Phronomy
|
|
|
102
117
|
|
|
103
118
|
private
|
|
104
119
|
|
|
120
|
+
def child_cleanup_failed(agent, result, primary_error, cleanup_error)
|
|
121
|
+
failure = CleanupError.new(child_agent: agent, result: result,
|
|
122
|
+
primary_error: primary_error, cleanup_error: cleanup_error)
|
|
123
|
+
# Always report cleanup failures: an outer observer may already have
|
|
124
|
+
# cancelled its handle, so returning an error alone is not sufficient.
|
|
125
|
+
report_child_cleanup_failure(failure)
|
|
126
|
+
raise failure, cause: primary_error || cleanup_error
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def report_child_cleanup_failure(failure)
|
|
130
|
+
details = {"agent_id" => failure.agent_id, "code" => failure.code,
|
|
131
|
+
"cleanup_error" => Phronomy::Error.diagnostic(failure.cleanup_error)}
|
|
132
|
+
details["primary_error"] = Phronomy::Error.diagnostic(failure.primary_error) if failure.primary_error
|
|
133
|
+
message = "[Phronomy] Agent Tool cleanup failed: #{details.inspect}"
|
|
134
|
+
logger = Phronomy::RuntimeSettings.current.logger
|
|
135
|
+
logger ? logger.error(message) : warn(message)
|
|
136
|
+
rescue
|
|
137
|
+
# Diagnostics cannot change the combined error or repeat the operation.
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
105
141
|
# Subclasses created by .from_agent override this method.
|
|
106
142
|
# It deliberately remains private so it is not part of the public Tool API.
|
|
107
143
|
def execute_async(input:, cancellation_token: nil, config: {})
|
data/lib/phronomy/version.rb
CHANGED