phronomy 0.14.0 → 0.15.1

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/README.md +236 -57
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +553 -0
  14. data/lib/phronomy/agent/base.rb +242 -509
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -47
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
@@ -0,0 +1,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
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
12
+ module AsyncEventApi
13
+ # Invokes the agent synchronously and returns the terminal result.
14
+ #
15
+ # Provider errors are translated after the configured LLM adapter returns
16
+ # its final result. Phronomy does not replay the Agent invocation.
17
+ #
18
+ # @param input [String, Hash] user input for this invocation
19
+ # @param messages [Array<RubyLLM::Message>] conversation history
20
+ # @param thread_id [String, nil] conversation thread identifier
21
+ # @param config [Hash] additional runtime options
22
+ # @param invocation_context [Phronomy::InvocationContext, nil]
23
+ # first-class invocation context
24
+ # @param on_event [Proc, nil] listener for lifecycle and Tool events
25
+ # @return [Hash] terminal invocation result
26
+ # @raise [Phronomy::SchedulerReentrancyError] when called from the
27
+ # EventLoop thread or a guarded scheduler context
28
+ # @api public
29
+ def invoke(
30
+ input,
31
+ messages: [],
32
+ thread_id: nil,
33
+ config: {},
34
+ invocation_context: nil,
35
+ on_event: nil
36
+ )
37
+ if invocation_context
38
+ thread_id, config = _apply_invocation_context(
39
+ thread_id,
40
+ config,
41
+ invocation_context
42
+ )
43
+ end
44
+ _check_scheduler_reentrancy(:invoke, :invoke_async)
45
+
46
+ trace(
47
+ "agent.invoke",
48
+ input: input,
49
+ **_build_caller_meta(config)
50
+ ) do |_span|
51
+ result = invoke_async(
52
+ input,
53
+ messages: messages,
54
+ thread_id: thread_id,
55
+ config: config,
56
+ on_event: on_event
57
+ ).wait_result
58
+ [result, result[:usage]]
59
+ end
60
+ end
61
+
62
+ # Invokes the agent asynchronously.
63
+ #
64
+ # The returned Task settles after the terminal event listener returns.
65
+ # Lifecycle and Tool events are delivered from the Runtime-owned
66
+ # EventLoop thread.
67
+ #
68
+ # @param input [String, Hash] user input for this invocation
69
+ # @param messages [Array<RubyLLM::Message>] conversation history
70
+ # @param thread_id [String, nil] conversation thread identifier
71
+ # @param config [Hash] additional runtime options
72
+ # @param invocation_context [Phronomy::InvocationContext, nil]
73
+ # first-class invocation context
74
+ # @param on_tool_approval_required [Proc, nil] per-invocation approval
75
+ # notification listener
76
+ # @param on_event [Proc, nil] listener for lifecycle and Tool events
77
+ # @return [Phronomy::Task] Task resolving to the terminal result
78
+ # @api public
79
+ def invoke_async(
80
+ input,
81
+ messages: [],
82
+ thread_id: nil,
83
+ config: {},
84
+ invocation_context: nil,
85
+ on_tool_approval_required: nil,
86
+ on_event: nil
87
+ )
88
+ if invocation_context
89
+ thread_id, config = _apply_invocation_context(
90
+ thread_id,
91
+ config,
92
+ invocation_context
93
+ )
94
+ end
95
+
96
+ result_task = Phronomy::Task.deferred(
97
+ name:
98
+ "agent-#{(self.class.name || "anonymous").downcase}-async"
99
+ )
100
+ approval_snapshot = _approval_configuration_snapshot(
101
+ on_tool_approval_required
102
+ )
103
+ _start_invocation(
104
+ result_task,
105
+ input,
106
+ messages: messages,
107
+ thread_id: thread_id,
108
+ config: config,
109
+ approval_snapshot: approval_snapshot,
110
+ mode: :invoke,
111
+ on_event: on_event
112
+ )
113
+ result_task
114
+ end
115
+
116
+ # Invokes the agent asynchronously and emits streaming events.
117
+ #
118
+ # Accepts either +on_event:+ or a block. Supplying both is rejected.
119
+ # Token events are emitted only by streaming invocations; lifecycle and
120
+ # Tool events use the same contract as {#invoke_async}.
121
+ #
122
+ # @param input [String, Hash] user input for this invocation
123
+ # @param messages [Array<RubyLLM::Message>] conversation history
124
+ # @param thread_id [String, nil] conversation thread identifier
125
+ # @param config [Hash] additional runtime options
126
+ # @param invocation_context [Phronomy::InvocationContext, nil]
127
+ # first-class invocation context
128
+ # @param on_tool_approval_required [Proc, nil] per-invocation approval
129
+ # notification listener
130
+ # @param on_event [Proc, nil] event listener
131
+ # @yieldparam event [Phronomy::Agent::StreamEvent]
132
+ # @return [Phronomy::Task] Task resolving to the terminal result
133
+ # @raise [ArgumentError] when no listener is supplied or both listener
134
+ # forms are supplied
135
+ # @api public
136
+ def stream_async(
137
+ input,
138
+ messages: [],
139
+ thread_id: nil,
140
+ config: {},
141
+ invocation_context: nil,
142
+ on_tool_approval_required: nil,
143
+ on_event: nil,
144
+ &block
145
+ )
146
+ listener = resolve_event_listener(on_event, block)
147
+ unless listener
148
+ raise ArgumentError,
149
+ "stream_async requires on_event: or a block"
150
+ end
151
+
152
+ if invocation_context
153
+ thread_id, config = _apply_invocation_context(
154
+ thread_id,
155
+ config,
156
+ invocation_context
157
+ )
158
+ end
159
+
160
+ result_task = Phronomy::Task.deferred(
161
+ name:
162
+ "agent-#{(self.class.name || "anonymous").downcase}" \
163
+ "-stream-async"
164
+ )
165
+ approval_snapshot = _approval_configuration_snapshot(
166
+ on_tool_approval_required
167
+ )
168
+ _start_invocation(
169
+ result_task,
170
+ input,
171
+ messages: messages,
172
+ thread_id: thread_id,
173
+ config: config,
174
+ approval_snapshot: approval_snapshot,
175
+ mode: :stream,
176
+ on_event: listener
177
+ )
178
+ result_task
179
+ end
180
+
181
+ # Invokes the agent synchronously and emits streaming events.
182
+ #
183
+ # Accepts either +on_event:+ or a block. Event callbacks run on the
184
+ # Runtime-owned EventLoop thread; the calling thread waits for the final
185
+ # Task result.
186
+ #
187
+ # @param input [String, Hash] user input for this invocation
188
+ # @param messages [Array<RubyLLM::Message>] conversation history
189
+ # @param thread_id [String, nil] conversation thread identifier
190
+ # @param config [Hash] additional runtime options
191
+ # @param invocation_context [Phronomy::InvocationContext, nil]
192
+ # first-class invocation context
193
+ # @param on_tool_approval_required [Proc, nil] per-invocation approval
194
+ # notification listener
195
+ # @param on_event [Proc, nil] event listener
196
+ # @yieldparam event [Phronomy::Agent::StreamEvent]
197
+ # @return [Hash] terminal invocation result
198
+ # @raise [ArgumentError] when no listener is supplied or both listener
199
+ # forms are supplied
200
+ # @raise [Phronomy::SchedulerReentrancyError] when called from the
201
+ # EventLoop thread or a guarded scheduler context
202
+ # @api public
203
+ def stream(
204
+ input,
205
+ messages: [],
206
+ thread_id: nil,
207
+ config: {},
208
+ invocation_context: nil,
209
+ on_tool_approval_required: nil,
210
+ on_event: nil,
211
+ &block
212
+ )
213
+ listener = resolve_event_listener(on_event, block)
214
+ unless listener
215
+ raise ArgumentError,
216
+ "stream requires on_event: or a block"
217
+ end
218
+
219
+ if invocation_context
220
+ thread_id, config = _apply_invocation_context(
221
+ thread_id,
222
+ config,
223
+ invocation_context
224
+ )
225
+ end
226
+ _check_scheduler_reentrancy(:stream, :stream_async)
227
+
228
+ trace(
229
+ "agent.stream",
230
+ input: input,
231
+ **_build_caller_meta(config)
232
+ ) do |_span|
233
+ result = stream_async(
234
+ input,
235
+ messages: messages,
236
+ thread_id: thread_id,
237
+ config: config,
238
+ on_tool_approval_required:
239
+ on_tool_approval_required,
240
+ on_event: listener
241
+ ).wait_result
242
+ [result, result[:usage]]
243
+ end
244
+ end
245
+
246
+ private
247
+
248
+ def resolve_event_listener(keyword_listener, block_listener)
249
+ if keyword_listener && block_listener
250
+ raise ArgumentError,
251
+ "Provide either on_event: or a block, not both"
252
+ end
253
+ keyword_listener || block_listener
254
+ end
255
+
256
+ # Installs the Application listener before EventLoop admission.
257
+ # Cancellation is checked by the first Agent entry action on the EventLoop
258
+ # thread so even pre-cancelled invocations produce a terminal event before
259
+ # the returned Task settles.
260
+ # @api private
261
+ def _start_invocation(
262
+ result_task,
263
+ input,
264
+ messages:,
265
+ thread_id:,
266
+ config:,
267
+ approval_snapshot:,
268
+ mode: :invoke,
269
+ on_event: nil
270
+ )
271
+ effective_config =
272
+ thread_id ? config.merge(thread_id: thread_id) : config
273
+ runtime = Phronomy::Runtime.instance
274
+ event_loop = runtime.event_loop
275
+ session = Agent::AgentInvocationSessionBuilder.build(
276
+ agent: self,
277
+ input: input,
278
+ messages: messages,
279
+ config: effective_config,
280
+ approval_policy: approval_snapshot[:policy],
281
+ approval_listener: approval_snapshot[:listener],
282
+ mode: mode,
283
+ on_event: on_event,
284
+ runtime: runtime
285
+ )
286
+ callback_error_policy =
287
+ Phronomy.configuration.stream_callback_error_policy
288
+ source_task = Phronomy::Task.deferred(
289
+ name: "#{result_task.name}-source"
290
+ )
291
+ source_task.on_complete do |invocation, error|
292
+ completed_invocation = invocation || session.context
293
+ _handle_agent_completion(
294
+ result_task: result_task,
295
+ invocation: completed_invocation,
296
+ error: error,
297
+ mode: mode,
298
+ listener: on_event,
299
+ event_loop: event_loop,
300
+ callback_error_policy: callback_error_policy
301
+ )
302
+ end
303
+
304
+ event_loop.register(session, completion: source_task)
305
+ rescue => error
306
+ _fail_result_task(result_task, error)
307
+ end
308
+
309
+ def _handle_agent_completion(
310
+ result_task:,
311
+ invocation:,
312
+ error:,
313
+ mode:,
314
+ listener:,
315
+ event_loop:,
316
+ callback_error_policy:
317
+ )
318
+ if listener && !event_loop.current?
319
+ completion_error = error || Phronomy::Error.new(
320
+ "Agent event delivery occurred outside the EventLoop"
321
+ )
322
+ _fail_result_task(
323
+ result_task,
324
+ _translated_error(completion_error)
325
+ )
326
+ return
327
+ end
328
+
329
+ result = nil
330
+ execution_error = nil
331
+ begin
332
+ raise error if error
333
+
334
+ result = _extract_invoke_result(invocation)
335
+ rescue => caught
336
+ execution_error = _translated_error(caught)
337
+ end
338
+ if execution_error
339
+ execution_error = normalize_terminal_error(
340
+ execution_error,
341
+ invocation
342
+ )
343
+ end
344
+
345
+ if execution_error
346
+ terminal_event = StreamEvent.new(
347
+ type: terminal_error_event_type(execution_error),
348
+ payload: {error: execution_error}
349
+ )
350
+ callback_error = _deliver_stream_event(
351
+ listener,
352
+ terminal_event
353
+ )
354
+ if callback_error
355
+ report_agent_event_callback_error(
356
+ callback_error,
357
+ event: terminal_event,
358
+ invocation_id: invocation&.id,
359
+ callback_error_policy: callback_error_policy
360
+ )
361
+ end
362
+
363
+ # Listener failure never replaces an Agent execution failure.
364
+ _fail_result_task(result_task, execution_error)
365
+ return
366
+ end
367
+
368
+ terminal_event = _build_stream_terminal_event(result)
369
+ callback_error = _deliver_stream_event(
370
+ listener,
371
+ terminal_event
372
+ )
373
+
374
+ unless callback_error
375
+ _complete_result_task(result_task, result)
376
+ return
377
+ end
378
+
379
+ report_agent_event_callback_error(
380
+ callback_error,
381
+ event: terminal_event,
382
+ invocation_id: invocation&.id,
383
+ callback_error_policy: callback_error_policy
384
+ )
385
+
386
+ if callback_error_policy == :fail_task
387
+ wrapped = _build_stream_callback_error(
388
+ event_type: terminal_event.type,
389
+ callback_error: callback_error,
390
+ result: result
391
+ )
392
+ _fail_result_task(result_task, wrapped)
393
+ else
394
+ _complete_result_task(result_task, result)
395
+ end
396
+ end
397
+
398
+ def terminal_error_event_type(error)
399
+ case error
400
+ when Phronomy::TimeoutError
401
+ :timeout
402
+ when Phronomy::CancellationError
403
+ :cancelled
404
+ else
405
+ :error
406
+ end
407
+ end
408
+
409
+ def normalize_terminal_error(error, invocation)
410
+ return error unless error.is_a?(Phronomy::CancellationError)
411
+ return error unless invocation_timeout_expired?(invocation)
412
+
413
+ timeout_error = Phronomy::TimeoutError.new(error.message)
414
+ timeout_error.set_backtrace(error.backtrace)
415
+ timeout_error
416
+ end
417
+
418
+ def invocation_timeout_expired?(invocation)
419
+ config = invocation&.config || {}
420
+ invocation_context = config[:invocation_context]
421
+ deadline = invocation_context&.deadline
422
+ return true if deadline&.expired?
423
+
424
+ token = config[:cancellation_token]
425
+ return false unless token
426
+
427
+ remaining =
428
+ if token.respond_to?(:remaining_monotonic_seconds)
429
+ token.remaining_monotonic_seconds
430
+ end
431
+ return true if remaining == 0.0
432
+
433
+ wall_deadline = token.deadline if token.respond_to?(:deadline)
434
+ wall_deadline && Time.now >= wall_deadline
435
+ end
436
+
437
+ def report_agent_event_callback_error(
438
+ callback_error,
439
+ event:,
440
+ invocation_id:,
441
+ callback_error_policy:
442
+ )
443
+ _report_stream_callback_error(
444
+ callback_error,
445
+ event: event,
446
+ invocation_id: invocation_id,
447
+ callback_error_policy: callback_error_policy
448
+ )
449
+ end
450
+
451
+ def _register_tool_invocation_session(
452
+ event_loop,
453
+ runtime,
454
+ child,
455
+ session
456
+ )
457
+ completion = Phronomy::Task.deferred(
458
+ name: "tool-session:#{child.id}"
459
+ )
460
+ completion.on_complete do |_result, error|
461
+ next unless error
462
+
463
+ child.mark_framework_failed!(error)
464
+ runtime.event_loop.post_to_session(
465
+ Phronomy::Event.new(
466
+ type: :tool_failed,
467
+ target_id: child.parent_agent_invocation_id,
468
+ payload: {tool_invocation_id: child.id}
469
+ )
470
+ )
471
+ end
472
+ event_loop.register(session, completion: completion)
473
+ end
474
+
475
+ # Resumes a suspended AgentInvocation and its pending Tool sessions.
476
+ #
477
+ # Parent completion handling is installed before EventLoop registration,
478
+ # and the parent session is registered before child sessions so immediate
479
+ # child events cannot be lost.
480
+ # @api private
481
+ def _start_approval_resume(
482
+ result_task,
483
+ invocation,
484
+ approved:,
485
+ config:
486
+ )
487
+ invocation.merge_config!(config)
488
+ invocation.begin_approval_resume!(approved: approved)
489
+ runtime = Phronomy::Runtime.instance
490
+ event_loop = runtime.event_loop
491
+ source_task = Phronomy::Task.deferred(
492
+ name: "#{result_task.name}-source"
493
+ )
494
+ parent_session =
495
+ Agent::AgentInvocationSessionBuilder.build_for_resume(
496
+ agent_invocation: invocation,
497
+ resume_event: :resume,
498
+ resume_phase: :suspended,
499
+ runtime: runtime
500
+ )
501
+ listener = invocation.event_listener
502
+ mode = invocation.mode
503
+ callback_error_policy =
504
+ Phronomy.configuration.stream_callback_error_policy
505
+
506
+ source_task.on_complete do |completed_invocation, error|
507
+ invocation_result = completed_invocation || parent_session.context
508
+ _handle_agent_completion(
509
+ result_task: result_task,
510
+ invocation: invocation_result,
511
+ error: error,
512
+ mode: mode,
513
+ listener: listener,
514
+ event_loop: event_loop,
515
+ callback_error_policy: callback_error_policy
516
+ )
517
+ end
518
+
519
+ event_loop.register(
520
+ parent_session,
521
+ completion: source_task
522
+ )
523
+
524
+ invocation.tool_invocations.each do |child|
525
+ child_session =
526
+ if child.awaiting_approval?
527
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
528
+ tool_invocation: child,
529
+ resume_event: approved ? :approve : :reject,
530
+ resume_phase: :awaiting_approval,
531
+ runtime: runtime
532
+ )
533
+ elsif !approved && child.authorized?
534
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
535
+ tool_invocation: child,
536
+ resume_event: :cancel,
537
+ resume_phase: :authorized,
538
+ runtime: runtime
539
+ )
540
+ end
541
+ if child_session
542
+ _register_tool_invocation_session(
543
+ event_loop,
544
+ runtime,
545
+ child,
546
+ child_session
547
+ )
548
+ end
549
+ end
550
+ end
551
+ end
552
+ end
553
+ end