phronomy 0.15.0 → 0.16.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -18
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -47
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -132,6 +132,17 @@ module Phronomy
132
132
  ],
133
133
  resume: [
134
134
  {from: :suspended, to: :waiting_for_tools, guard: nil}
135
+ ],
136
+ application_callback_failed: [
137
+ {from: :filtering_input, to: :failed, guard: nil},
138
+ {from: :building_context, to: :failed, guard: nil},
139
+ {from: :calling_llm, to: :failed, guard: nil},
140
+ {from: :starting_tools, to: :failed, guard: nil},
141
+ {from: :evaluating_tools, to: :failed, guard: nil},
142
+ {from: :waiting_for_tools, to: :failed, guard: nil},
143
+ {from: :dispatching_tools, to: :failed, guard: nil},
144
+ {from: :recording_tool_results, to: :failed, guard: nil},
145
+ {from: :output_filtering, to: :failed, guard: nil}
135
146
  ]
136
147
  )
137
148
  end
@@ -206,30 +217,58 @@ module Phronomy
206
217
  invocation.chat,
207
218
  invocation.config
208
219
  )
209
- install_tool_interceptors(invocation.chat)
220
+ install_tool_interceptors(invocation.chat, invocation)
210
221
  invocation
211
222
  end
212
223
  private_class_method :building_context_action
213
224
 
214
- def self.install_tool_interceptors(chat)
225
+ # RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
226
+ # before before_tool_call is fired. Capture that Message at the control
227
+ # boundary instead of trying to recover it after the exception unwinds.
228
+ def self.install_tool_interceptors(chat, invocation = nil)
229
+ unless chat.respond_to?(:before_tool_call)
230
+ raise Phronomy::ConfigurationError,
231
+ "Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
232
+ end
233
+
215
234
  if chat.respond_to?(:on_tool_call_batch)
216
235
  chat.on_tool_call_batch do |tool_calls|
217
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_calls)
236
+ raise build_tool_interception(chat, tool_calls, invocation)
218
237
  end
219
238
  end
220
239
 
221
- if chat.respond_to?(:before_tool_call)
222
- chat.before_tool_call do |tool_call|
223
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
224
- end
225
- else
226
- chat.on_tool_call do |tool_call|
227
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
228
- end
240
+ chat.before_tool_call do |tool_call|
241
+ raise build_tool_interception(chat, [tool_call], invocation)
229
242
  end
230
243
  end
231
244
  private_class_method :install_tool_interceptors
232
245
 
246
+ def self.build_tool_interception(chat, fallback_tool_calls, invocation)
247
+ assistant_message = chat.messages.last
248
+ unless assistant_message&.respond_to?(:role) &&
249
+ assistant_message.role.to_sym == :assistant &&
250
+ assistant_message.respond_to?(:tool_calls)
251
+ raise Phronomy::Error,
252
+ "RubyLLM Tool callback fired before the complete assistant message was observable"
253
+ end
254
+
255
+ message_tool_calls = assistant_message.tool_calls
256
+ tool_calls = if message_tool_calls.respond_to?(:values)
257
+ message_tool_calls.values
258
+ else
259
+ Array(message_tool_calls)
260
+ end
261
+ tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
262
+
263
+ ToolCallIntercepted.new(
264
+ tool_calls,
265
+ assistant_message: assistant_message,
266
+ assistant_outcome: ProviderCallOutcome.capture(assistant_message),
267
+ llm_call_id: invocation&.current_llm_call_id
268
+ )
269
+ end
270
+ private_class_method :build_tool_interception
271
+
233
272
  def self.calling_llm_action(agent, runtime, invocation)
234
273
  user_message = invocation.user_message_sent ?
235
274
  nil :
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ class AgentRoot
8
+ LIFECYCLE_STATUSES = %i[idle active suspended closed invalidated].freeze
9
+
10
+ ATTRIBUTES = %i[
11
+ agent_id agent_definition_id definition_version agent_revision
12
+ context_revision journal_position lifecycle_status transcript_generation memory_generation
13
+ created_at updated_at metadata
14
+ ].freeze
15
+
16
+ attr_reader(*ATTRIBUTES)
17
+
18
+ def self.create(agent_id:, agent_definition_id:, definition_version:, metadata: {})
19
+ now = Time.now.utc.iso8601(6)
20
+ new(
21
+ agent_id: agent_id,
22
+ agent_definition_id: agent_definition_id,
23
+ definition_version: definition_version,
24
+ agent_revision: 0,
25
+ context_revision: 0,
26
+ journal_position: 0,
27
+ lifecycle_status: :idle,
28
+ transcript_generation: 0,
29
+ memory_generation: 0,
30
+ created_at: now,
31
+ updated_at: now,
32
+ metadata: metadata
33
+ )
34
+ end
35
+
36
+ def initialize(**attributes)
37
+ ATTRIBUTES.each do |name|
38
+ value = attributes.fetch(name)
39
+ value = value.to_sym if name == :lifecycle_status
40
+ instance_variable_set("@#{name}", Immutable.copy(value))
41
+ end
42
+ unless LIFECYCLE_STATUSES.include?(lifecycle_status)
43
+ raise ArgumentError, "unknown Agent lifecycle status: #{lifecycle_status.inspect}"
44
+ end
45
+ raise ArgumentError, "agent_revision must be non-negative" if agent_revision.negative?
46
+ raise ArgumentError, "context_revision must be non-negative" if context_revision.negative?
47
+ raise ArgumentError, "journal_position must be non-negative" if journal_position.negative?
48
+ Immutable.validate_canonical_json!(metadata, label: "Agent metadata")
49
+ freeze
50
+ end
51
+
52
+ def with(**changes)
53
+ values = to_h.transform_keys(&:to_sym).merge(changes)
54
+ values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
55
+ self.class.new(**values)
56
+ end
57
+
58
+ def to_h
59
+ ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
60
+ end
61
+
62
+ def self.from_h(hash)
63
+ new(**ATTRIBUTES.to_h { |name| [name, hash.fetch(name.to_s)] })
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,39 +2,17 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Symmetric Agent async event contract layered onto Agent::Base.
6
- #
7
- # invoke_async and stream_async share lifecycle/tool events. stream_async
8
- # additionally emits :token events. The returned Task remains a normal Task
9
- # and is settled after the terminal event listener returns.
10
- #
11
- # @api private
5
+ # Public Agent execution API. Agent instances are always stateful and all
6
+ # operations are coordinated through Phronomy::Persistence.
12
7
  module AsyncEventApi
13
- def invoke(
14
- input,
15
- messages: [],
16
- thread_id: nil,
17
- config: {},
18
- invocation_context: nil,
19
- on_event: nil
20
- )
8
+ def invoke(input, thread_id: nil, config: {}, invocation_context: nil, on_event: nil)
21
9
  if invocation_context
22
- thread_id, config = _apply_invocation_context(
23
- thread_id,
24
- config,
25
- invocation_context
26
- )
10
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
27
11
  end
28
12
  _check_scheduler_reentrancy(:invoke, :invoke_async)
29
-
30
- trace(
31
- "agent.invoke",
32
- input: input,
33
- **_build_caller_meta(config)
34
- ) do |_span|
13
+ trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
35
14
  result = invoke_async(
36
15
  input,
37
- messages: messages,
38
16
  thread_id: thread_id,
39
17
  config: config,
40
18
  on_event: on_event
@@ -45,7 +23,6 @@ module Phronomy
45
23
 
46
24
  def invoke_async(
47
25
  input,
48
- messages: [],
49
26
  thread_id: nil,
50
27
  config: {},
51
28
  invocation_context: nil,
@@ -53,81 +30,22 @@ module Phronomy
53
30
  on_event: nil
54
31
  )
55
32
  if invocation_context
56
- thread_id, config = _apply_invocation_context(
57
- thread_id,
58
- config,
59
- invocation_context
60
- )
33
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
61
34
  end
62
-
63
- result_task = Phronomy::Task.deferred(
64
- name:
65
- "agent-#{(self.class.name || "anonymous").downcase}-async"
66
- )
67
- approval_snapshot = _approval_configuration_snapshot(
68
- on_tool_approval_required
69
- )
70
- _start_invocation(
71
- result_task,
35
+ approval = _approval_configuration_snapshot(on_tool_approval_required)
36
+ execution_coordinator.start(
72
37
  input,
73
- messages: messages,
74
38
  thread_id: thread_id,
75
39
  config: config,
76
- approval_snapshot: approval_snapshot,
77
40
  mode: :invoke,
41
+ approval_policy: approval[:policy],
42
+ approval_listener: approval[:listener],
78
43
  on_event: on_event
79
44
  )
80
- result_task
81
- end
82
-
83
- def stream_async(
84
- input,
85
- messages: [],
86
- thread_id: nil,
87
- config: {},
88
- invocation_context: nil,
89
- on_tool_approval_required: nil,
90
- on_event: nil,
91
- &block
92
- )
93
- listener = resolve_event_listener(on_event, block)
94
- unless listener
95
- raise ArgumentError,
96
- "stream_async requires on_event: or a block"
97
- end
98
-
99
- if invocation_context
100
- thread_id, config = _apply_invocation_context(
101
- thread_id,
102
- config,
103
- invocation_context
104
- )
105
- end
106
-
107
- result_task = Phronomy::Task.deferred(
108
- name:
109
- "agent-#{(self.class.name || "anonymous").downcase}" \
110
- "-stream-async"
111
- )
112
- approval_snapshot = _approval_configuration_snapshot(
113
- on_tool_approval_required
114
- )
115
- _start_invocation(
116
- result_task,
117
- input,
118
- messages: messages,
119
- thread_id: thread_id,
120
- config: config,
121
- approval_snapshot: approval_snapshot,
122
- mode: :stream,
123
- on_event: listener
124
- )
125
- result_task
126
45
  end
127
46
 
128
47
  def stream(
129
48
  input,
130
- messages: [],
131
49
  thread_id: nil,
132
50
  config: {},
133
51
  invocation_context: nil,
@@ -136,335 +54,79 @@ module Phronomy
136
54
  &block
137
55
  )
138
56
  listener = resolve_event_listener(on_event, block)
139
- unless listener
140
- raise ArgumentError,
141
- "stream requires on_event: or a block"
142
- end
143
-
57
+ raise ArgumentError, "stream requires on_event: or a block" unless listener
144
58
  if invocation_context
145
- thread_id, config = _apply_invocation_context(
146
- thread_id,
147
- config,
148
- invocation_context
149
- )
59
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
150
60
  end
151
61
  _check_scheduler_reentrancy(:stream, :stream_async)
152
-
153
- trace(
154
- "agent.stream",
155
- input: input,
156
- **_build_caller_meta(config)
157
- ) do |_span|
62
+ trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
158
63
  result = stream_async(
159
64
  input,
160
- messages: messages,
161
65
  thread_id: thread_id,
162
66
  config: config,
163
- on_tool_approval_required:
164
- on_tool_approval_required,
67
+ on_tool_approval_required: on_tool_approval_required,
165
68
  on_event: listener
166
69
  ).wait_result
167
70
  [result, result[:usage]]
168
71
  end
169
72
  end
170
73
 
171
- private
172
-
173
- def resolve_event_listener(keyword_listener, block_listener)
174
- if keyword_listener && block_listener
175
- raise ArgumentError,
176
- "Provide either on_event: or a block, not both"
177
- end
178
- keyword_listener || block_listener
179
- end
180
-
181
- # Installs the Application listener before EventLoop admission.
182
- # Cancellation is checked by the first Agent entry action on the EventLoop
183
- # thread so even pre-cancelled invocations produce a terminal event before
184
- # the returned Task settles.
185
- def _start_invocation(
186
- result_task,
74
+ def stream_async(
187
75
  input,
188
- messages:,
189
- thread_id:,
190
- config:,
191
- approval_snapshot:,
192
- mode: :invoke,
193
- on_event: nil
194
- )
195
- effective_config =
196
- thread_id ? config.merge(thread_id: thread_id) : config
197
- runtime = Phronomy::Runtime.instance
198
- event_loop = runtime.event_loop
199
- session = Agent::AgentInvocationSessionBuilder.build(
200
- agent: self,
201
- input: input,
202
- messages: messages,
203
- config: effective_config,
204
- approval_policy: approval_snapshot[:policy],
205
- approval_listener: approval_snapshot[:listener],
206
- mode: mode,
207
- on_event: on_event,
208
- runtime: runtime
209
- )
210
- callback_error_policy =
211
- Phronomy.configuration.stream_callback_error_policy
212
- source_task = Phronomy::Task.deferred(
213
- name: "#{result_task.name}-source"
214
- )
215
- source_task.on_complete do |invocation, error|
216
- completed_invocation = invocation || session.context
217
- _handle_agent_completion(
218
- result_task: result_task,
219
- invocation: completed_invocation,
220
- error: error,
221
- mode: mode,
222
- listener: on_event,
223
- event_loop: event_loop,
224
- callback_error_policy: callback_error_policy
225
- )
226
- end
227
-
228
- event_loop.register(session, completion: source_task)
229
- rescue => error
230
- _fail_result_task(result_task, error)
231
- end
232
-
233
- def _handle_agent_completion(
234
- result_task:,
235
- invocation:,
236
- error:,
237
- mode:,
238
- listener:,
239
- event_loop:,
240
- callback_error_policy:
76
+ thread_id: nil,
77
+ config: {},
78
+ invocation_context: nil,
79
+ on_tool_approval_required: nil,
80
+ on_event: nil,
81
+ &block
241
82
  )
242
- if listener && !event_loop.current?
243
- completion_error = error || Phronomy::Error.new(
244
- "Agent event delivery occurred outside the EventLoop"
245
- )
246
- _fail_result_task(
247
- result_task,
248
- _translated_error(completion_error)
249
- )
250
- return
251
- end
252
-
253
- result = nil
254
- execution_error = nil
255
- begin
256
- raise error if error
257
-
258
- result = _extract_invoke_result(invocation)
259
- rescue => caught
260
- execution_error = _translated_error(caught)
261
- end
262
- if execution_error
263
- execution_error = normalize_terminal_error(
264
- execution_error,
265
- invocation
266
- )
267
- end
268
-
269
- if execution_error
270
- terminal_event = StreamEvent.new(
271
- type: terminal_error_event_type(execution_error),
272
- payload: {error: execution_error}
273
- )
274
- callback_error = _deliver_stream_event(
275
- listener,
276
- terminal_event
277
- )
278
- if callback_error
279
- report_agent_event_callback_error(
280
- callback_error,
281
- event: terminal_event,
282
- invocation_id: invocation&.id,
283
- callback_error_policy: callback_error_policy
284
- )
285
- end
286
-
287
- # Listener failure never replaces an Agent execution failure.
288
- _fail_result_task(result_task, execution_error)
289
- return
290
- end
291
-
292
- terminal_event = _build_stream_terminal_event(result)
293
- callback_error = _deliver_stream_event(
294
- listener,
295
- terminal_event
296
- )
297
-
298
- unless callback_error
299
- _complete_result_task(result_task, result)
300
- return
83
+ listener = resolve_event_listener(on_event, block)
84
+ raise ArgumentError, "stream_async requires on_event: or a block" unless listener
85
+ if invocation_context
86
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
301
87
  end
302
-
303
- report_agent_event_callback_error(
304
- callback_error,
305
- event: terminal_event,
306
- invocation_id: invocation&.id,
307
- callback_error_policy: callback_error_policy
88
+ approval = _approval_configuration_snapshot(on_tool_approval_required)
89
+ execution_coordinator.start(
90
+ input,
91
+ thread_id: thread_id,
92
+ config: config,
93
+ mode: :stream,
94
+ approval_policy: approval[:policy],
95
+ approval_listener: approval[:listener],
96
+ on_event: listener
308
97
  )
309
-
310
- if callback_error_policy == :fail_task
311
- wrapped = _build_stream_callback_error(
312
- event_type: terminal_event.type,
313
- callback_error: callback_error,
314
- result: result
315
- )
316
- _fail_result_task(result_task, wrapped)
317
- else
318
- _complete_result_task(result_task, result)
319
- end
320
98
  end
321
99
 
322
- def terminal_error_event_type(error)
323
- case error
324
- when Phronomy::TimeoutError
325
- :timeout
326
- when Phronomy::CancellationError
327
- :cancelled
328
- else
329
- :error
330
- end
100
+ def approve(execution_id, approval_request_id:, approved: true, config: {})
101
+ _check_scheduler_reentrancy(:approve, :approve_async)
102
+ approve_async(
103
+ execution_id,
104
+ approval_request_id: approval_request_id,
105
+ approved: approved,
106
+ config: config
107
+ ).wait_result
331
108
  end
332
109
 
333
- def normalize_terminal_error(error, invocation)
334
- return error unless error.is_a?(Phronomy::CancellationError)
335
- return error unless invocation_timeout_expired?(invocation)
336
-
337
- timeout_error = Phronomy::TimeoutError.new(error.message)
338
- timeout_error.set_backtrace(error.backtrace)
339
- timeout_error
340
- end
341
-
342
- def invocation_timeout_expired?(invocation)
343
- config = invocation&.config || {}
344
- invocation_context = config[:invocation_context]
345
- deadline = invocation_context&.deadline
346
- return true if deadline&.expired?
347
-
348
- token = config[:cancellation_token]
349
- return false unless token
350
-
351
- remaining =
352
- if token.respond_to?(:remaining_monotonic_seconds)
353
- token.remaining_monotonic_seconds
354
- end
355
- return true if remaining == 0.0
356
-
357
- wall_deadline = token.deadline if token.respond_to?(:deadline)
358
- wall_deadline && Time.now >= wall_deadline
359
- end
360
-
361
- def report_agent_event_callback_error(
362
- callback_error,
363
- event:,
364
- invocation_id:,
365
- callback_error_policy:
366
- )
367
- _report_stream_callback_error(
368
- callback_error,
369
- event: event,
370
- invocation_id: invocation_id,
371
- callback_error_policy: callback_error_policy
110
+ def approve_async(execution_id, approval_request_id:, approved: true, config: {})
111
+ execution_coordinator.resume(
112
+ execution_id,
113
+ approval_request_id: approval_request_id,
114
+ approved: approved,
115
+ config: config
372
116
  )
373
117
  end
374
118
 
375
- def _register_tool_invocation_session(
376
- event_loop,
377
- runtime,
378
- child,
379
- session
380
- )
381
- completion = Phronomy::Task.deferred(
382
- name: "tool-session:#{child.id}"
383
- )
384
- completion.on_complete do |_result, error|
385
- next unless error
119
+ private
386
120
 
387
- child.mark_framework_failed!(error)
388
- runtime.event_loop.post_to_session(
389
- Phronomy::Event.new(
390
- type: :tool_failed,
391
- target_id: child.parent_agent_invocation_id,
392
- payload: {tool_invocation_id: child.id}
393
- )
394
- )
395
- end
396
- event_loop.register(session, completion: completion)
121
+ def execution_coordinator
122
+ @execution_coordinator ||= Agent::ExecutionCoordinator.new(self)
397
123
  end
398
124
 
399
- def _start_approval_resume(
400
- result_task,
401
- invocation,
402
- approved:,
403
- config:
404
- )
405
- invocation.merge_config!(config)
406
- invocation.begin_approval_resume!(approved: approved)
407
- runtime = Phronomy::Runtime.instance
408
- event_loop = runtime.event_loop
409
- source_task = Phronomy::Task.deferred(
410
- name: "#{result_task.name}-source"
411
- )
412
- parent_session =
413
- Agent::AgentInvocationSessionBuilder.build_for_resume(
414
- agent_invocation: invocation,
415
- resume_event: :resume,
416
- resume_phase: :suspended,
417
- runtime: runtime
418
- )
419
- listener = invocation.event_listener
420
- mode = invocation.mode
421
- callback_error_policy =
422
- Phronomy.configuration.stream_callback_error_policy
423
-
424
- source_task.on_complete do |completed_invocation, error|
425
- invocation_result = completed_invocation || parent_session.context
426
- _handle_agent_completion(
427
- result_task: result_task,
428
- invocation: invocation_result,
429
- error: error,
430
- mode: mode,
431
- listener: listener,
432
- event_loop: event_loop,
433
- callback_error_policy: callback_error_policy
434
- )
435
- end
436
-
437
- event_loop.register(
438
- parent_session,
439
- completion: source_task
440
- )
441
-
442
- invocation.tool_invocations.each do |child|
443
- child_session =
444
- if child.awaiting_approval?
445
- Agent::ToolInvocationSessionBuilder.build_for_resume(
446
- tool_invocation: child,
447
- resume_event: approved ? :approve : :reject,
448
- resume_phase: :awaiting_approval,
449
- runtime: runtime
450
- )
451
- elsif !approved && child.authorized?
452
- Agent::ToolInvocationSessionBuilder.build_for_resume(
453
- tool_invocation: child,
454
- resume_event: :cancel,
455
- resume_phase: :authorized,
456
- runtime: runtime
457
- )
458
- end
459
- if child_session
460
- _register_tool_invocation_session(
461
- event_loop,
462
- runtime,
463
- child,
464
- child_session
465
- )
466
- end
125
+ def resolve_event_listener(keyword_listener, block_listener)
126
+ if keyword_listener && block_listener
127
+ raise ArgumentError, "Provide either on_event: or a block, not both"
467
128
  end
129
+ keyword_listener || block_listener
468
130
  end
469
131
  end
470
132
  end