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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +13 -2
  4. data/docs/architecture/agent-tool-child-lifetime.md +141 -0
  5. data/docs/architecture/dx05-agent-scope.md +4 -2
  6. data/docs/architecture/structured-output-and-scope.md +73 -13
  7. data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
  8. data/docs/features.md +2 -0
  9. data/docs/getting-started.md +26 -0
  10. data/docs/migrations/0.31.md +72 -0
  11. data/docs/runtime-and-concurrency.md +159 -0
  12. data/lib/phronomy/agent/approval_wait.rb +8 -0
  13. data/lib/phronomy/agent/async_event_api.rb +77 -8
  14. data/lib/phronomy/agent/base.rb +28 -3
  15. data/lib/phronomy/agent/composition/run_once.rb +11 -10
  16. data/lib/phronomy/agent/context_preparation.rb +1 -0
  17. data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
  18. data/lib/phronomy/agent/execution/exact_execution.rb +9 -3
  19. data/lib/phronomy/agent/execution/execution_coordinator.rb +95 -2
  20. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
  21. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
  22. data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
  23. data/lib/phronomy/agent/execution_change.rb +4 -0
  24. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  25. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +70 -0
  26. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  27. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  28. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  29. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  30. data/lib/phronomy/agent/output_contract.rb +47 -5
  31. data/lib/phronomy/agent/persistence/codec.rb +16 -2
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  34. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
  35. data/lib/phronomy/agent/runtime_input.rb +7 -0
  36. data/lib/phronomy/agent/store.rb +52 -0
  37. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  38. data/lib/phronomy/engine/fsm_session.rb +16 -3
  39. data/lib/phronomy/execution/async_operation.rb +16 -0
  40. data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
  41. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
  42. data/lib/phronomy/generation/generator_verifier.rb +6 -3
  43. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
  44. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
  45. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  46. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  47. data/lib/phronomy/tools/agent.rb +58 -22
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  50. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  51. data/scripts/api_snapshot.rb +2 -0
  52. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  53. data/sig/phronomy/agent/store.rbs +1 -0
  54. data/sig/phronomy/agent.rbs +8 -5
  55. data/sig/phronomy/execution/async_operation.rbs +1 -0
  56. data/sig/phronomy/generation/generator_verifier.rbs +1 -1
  57. data/sig/phronomy/multi_agent.rbs +2 -0
  58. data/sig/phronomy/tools/agent.rbs +14 -0
  59. data/sig/phronomy/workflow.rbs +1 -0
  60. 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
- tool_messages_by_id = tool_messages.group_by(&:tool_call_id)
151
- tool_messages_by_id.each do |tool_call_id, messages|
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
- if assistant_by_tool_call.key?(tool_call_id)
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[tool_call_id] = assistant
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
- messages = item.tool_call_ids.map do |tool_call_id|
178
- tool_messages_by_id.fetch(tool_call_id) do
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
- group = ([item] + messages).uniq.sort_by { |member| [member.sequence || 0, member.id] }
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
- group = [item]
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
- return unless transitioned
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 request.response_schema && block_given?
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
- @raw_finish_reason = choice["finish_reason"] || data.dig("delta", "stop_reason") ||
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
- settings = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
96
- unless settings && settings.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
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
- captured_context = {parent: self}
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
- captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
260
+ context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
241
261
  if invocation
242
- captured_context[:config] = invocation.config
243
- captured_context[:invocation_context] = invocation.config[:invocation_context]
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
@@ -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 FSMSession finishes. An OffloadPool worker is therefore never
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
- invoke_options = {}
39
- if cancellation_token
40
- invoke_options[:config] = {cancellation_token: cancellation_token}
55
+ if Phronomy::WaitPolicy.blocking_forbidden?
56
+ raise Phronomy::EventLoopReentrancyError,
57
+ "Agent Tool execute cannot block EventLoop; use call_async"
41
58
  end
42
- # Use the same supplied store factory as the asynchronous path.
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
- # The child Agent owns its own FSMSession/EventLoop lifecycle; this
57
- # method only returns its completion handle and performs a short map.
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
- persistence = Phronomy::Agent::DefaultPersistence.build
60
- agent = agent_class.create(persistence: persistence)
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
- agent.invoke_async(input, config: task_config).map do |result|
67
- result[:output].to_s
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: {})
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.30.1"
4
+ VERSION = "0.32.0"
5
5
  end