phronomy 0.14.0 → 0.15.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -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 +471 -0
  14. data/lib/phronomy/agent/base.rb +500 -411
  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 -7
  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
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require_relative "concerns/retryable"
5
4
  require_relative "concerns/filterable"
6
5
  require_relative "concerns/before_completion"
7
6
  require_relative "concerns/error_translation"
@@ -12,7 +11,7 @@ module Phronomy
12
11
  #
13
12
  # Subclass this to create a conversational agent powered by an LLM.
14
13
  # DSL class methods configure the model, instructions, tools, memory,
15
- # and retry behaviour. Instance methods handle invocation.
14
+ # and execution hooks. Instance methods handle invocation.
16
15
  #
17
16
  # @example Minimal agent
18
17
  # class GreetingAgent < Phronomy::Agent::Base
@@ -31,11 +30,13 @@ module Phronomy
31
30
  # end
32
31
  class Base
33
32
  include Phronomy::Runnable
34
- include Concerns::Retryable
35
33
  include Concerns::Filterable
36
34
  include Concerns::BeforeCompletion
37
35
  include Concerns::ErrorTranslation
38
36
 
37
+ APPROVAL_CONFIGURATION_INIT_MUTEX = Mutex.new
38
+ private_constant :APPROVAL_CONFIGURATION_INIT_MUTEX
39
+
39
40
  class << self
40
41
  # Sets or reads the LLM model identifier for this agent.
41
42
  # When called without an argument, returns the stored model or the
@@ -187,66 +188,6 @@ module Phronomy
187
188
  end
188
189
  end
189
190
 
190
- # Sets or reads the maximum number of tool calls executed concurrently
191
- # when the LLM returns multiple tool calls in a single response
192
- # (ParallelToolChat mode, active inside an AgentFSM IO thread).
193
- #
194
- # Defaults to 10. Set to 1 to force sequential execution.
195
- # Inherited by subclasses; the most-specific definition wins.
196
- #
197
- # @param val [Integer, nil]
198
- # @return [Integer]
199
- # @example
200
- # class MyAgent < Phronomy::Agent::Base
201
- # max_parallel_tools 4
202
- # end
203
- # @api public
204
- def max_parallel_tools(val = nil)
205
- if val.nil?
206
- @max_parallel_tools ||
207
- (superclass.respond_to?(:max_parallel_tools) ? superclass.max_parallel_tools : 10)
208
- else
209
- unless val.is_a?(Integer) && val >= 1
210
- raise ArgumentError,
211
- "max_parallel_tools must be a positive Integer (>= 1), got #{val.inspect}"
212
- end
213
- @max_parallel_tools = val
214
- end
215
- end
216
-
217
- # Sets or reads the per-invocation timeout (in seconds) for EventLoop-mode
218
- # agent calls. When set, +invoke+ raises {Phronomy::TimeoutError} if the
219
- # agent does not finish within the given number of seconds.
220
- #
221
- # Has no effect when EventLoop mode is disabled (direct invoke path).
222
- # Defaults to +nil+ (no timeout).
223
- # Inherited by subclasses; the most-specific definition wins.
224
- #
225
- # When the timeout fires, a {Phronomy::Concurrency::CancellationScope} is cancelled
226
- # and its token is propagated to the FSM config so that in-flight LLM,
227
- # tool, and RAG calls observe cancellation via their +cancellation_token:+
228
- # keyword argument. +Phronomy::TimeoutError+ is raised to the caller.
229
- #
230
- # @param val [Numeric, nil]
231
- # @return [Numeric, nil]
232
- # @example
233
- # class MyAgent < Phronomy::Agent::Base
234
- # invoke_timeout 30
235
- # end
236
- # @api public
237
- def invoke_timeout(val = nil)
238
- if val.nil?
239
- return @invoke_timeout if defined?(@invoke_timeout)
240
- superclass.respond_to?(:invoke_timeout) ? superclass.invoke_timeout : nil
241
- else
242
- unless val.is_a?(Numeric) && val > 0
243
- raise ArgumentError,
244
- "invoke_timeout must be a positive number, got #{val.inspect}"
245
- end
246
- @invoke_timeout = val
247
- end
248
- end
249
-
250
191
  # Registers one or more static knowledge sources on the agent class.
251
192
  # Static source content is fetched and memoized at the **class** level
252
193
  # the first time +invoke+ is called. The cache persists for the lifetime
@@ -373,21 +314,35 @@ module Phronomy
373
314
  end
374
315
  end
375
316
 
376
- # Continues a suspended invocation identified by +session_id+.
377
- #
378
- # Instantiates a fresh agent and delegates to the instance-level #approve.
379
- # When +approved: false+, the agent rejects the pending tool call and ends
380
- # the invocation.
381
- #
382
- # @param session_id [String] the session_id from the suspended result hash
383
- # @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
384
- # @param config [Hash] same runtime options as {#invoke}
385
- # @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
386
- # another approval-required tool is encountered during continuation
387
- # @raise [ArgumentError] when no suspended session matches +session_id+
317
+ # Continues a suspended AgentInvocation.
318
+ # @param agent_invocation_id [String]
319
+ # @param approval_request_id [String]
320
+ # @param approved [Boolean]
321
+ # @param config [Hash]
388
322
  # @api public
389
- def approve(session_id, approved: true, config: {})
390
- new.approve(session_id, approved: approved, config: config)
323
+ def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
324
+ new.approve(
325
+ agent_invocation_id,
326
+ approval_request_id: approval_request_id,
327
+ approved: approved,
328
+ config: config
329
+ )
330
+ end
331
+
332
+ # Continues a suspended AgentInvocation without blocking the caller.
333
+ # @param agent_invocation_id [String]
334
+ # @param approval_request_id [String]
335
+ # @param approved [Boolean]
336
+ # @param config [Hash]
337
+ # @return [Phronomy::Task]
338
+ # @api public
339
+ def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
340
+ new.approve_async(
341
+ agent_invocation_id,
342
+ approval_request_id: approval_request_id,
343
+ approved: approved,
344
+ config: config
345
+ )
391
346
  end
392
347
  end
393
348
 
@@ -409,38 +364,31 @@ module Phronomy
409
364
  @_handoff_tools || []
410
365
  end
411
366
 
412
- # Registers a synchronous approval callback that is invoked before
413
- # executing any tool that has +requires_approval true+ set.
414
- # The block receives the tool name (String) and the arguments Hash, and
415
- # must return a truthy value to allow execution.
416
- # Returning a falsy value causes the tool to return a denial message.
417
- #
418
- # When no handler is registered and a tool with +requires_approval+ is
419
- # called, #invoke returns a suspended result hash containing a
420
- # +session_id+. Call #approve to continue execution.
421
- #
422
- # @example
423
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
367
+ # Registers the final Agent/Application authorization policy.
368
+ # The block runs on the Runtime authorization pool and must return
369
+ # :allow, :require_approval, or :reject.
424
370
  # @return [self]
425
371
  # @api public
426
- def on_approval_required(&block)
427
- @approval_handler = block
372
+ def tool_approval_policy(&block)
373
+ raise ArgumentError, "tool_approval_policy requires a block" unless block
374
+
375
+ _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
428
376
  self
429
377
  end
430
378
 
431
- # Registers a scope policy callable for this agent instance.
432
- #
433
- # The callable receives +(tool_class, scope, agent)+ and must return
434
- # +:allow+, +:reject+, or +:approve+.
435
- #
436
- # @param policy [#call]
437
- # @return [void]
379
+ # Registers a non-blocking Application notification listener.
380
+ # @return [self]
438
381
  # @api public
439
- attr_writer :scope_policy
382
+ def on_tool_approval_required(&block)
383
+ raise ArgumentError, "on_tool_approval_required requires a block" unless block
384
+
385
+ _approval_configuration_mutex.synchronize { @tool_approval_listener = block }
386
+ self
387
+ end
440
388
 
441
389
  # Invokes the agent with the given input and returns a result Hash.
442
- # Applies the retry policy configured via {.retry_policy} when transient
443
- # errors occur. {Phronomy::FilterBlockError} is never retried.
390
+ # Provider errors are translated after the configured LLM adapter returns
391
+ # its final result. Phronomy does not replay the Agent invocation.
444
392
  #
445
393
  # @param input [String, Hash] the user message; a Hash may supply
446
394
  # +:message+, +:query+, or +:user+ as the text key, plus any template
@@ -486,38 +434,17 @@ module Phronomy
486
434
  if invocation_context
487
435
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
488
436
  end
489
- _check_scheduler_reentrancy
490
-
491
- timeout_sec = self.class.invoke_timeout
492
- unless timeout_sec
493
- return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
494
- result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
495
- [result, result[:usage]]
496
- end
497
- end
498
-
499
- # invoke_timeout: create a CancellationScope with deadline, pass its token
500
- # to the async invocation, and use scope.pop_queue so the calling thread
501
- # unblocks as soon as either the result arrives or the deadline fires.
502
- scope = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
503
- scope.deadline_in(timeout_sec)
504
- effective_config = config.merge(cancellation_token: scope.token)
505
- task = invoke_async(input, messages: messages, thread_id: thread_id, config: effective_config)
506
-
507
- # Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
508
- completion_queue = Phronomy::Concurrency::AsyncQueue.new
509
- Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
510
- completion_queue.push(task.wait_result)
511
- rescue => e
512
- completion_queue.push(e)
513
- end
437
+ _check_scheduler_reentrancy(:invoke, :invoke_async)
514
438
 
515
- result = scope.pop_queue(completion_queue) do
516
- raise Phronomy::TimeoutError,
517
- "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
439
+ trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
440
+ result = invoke_async(
441
+ input,
442
+ messages: messages,
443
+ thread_id: thread_id,
444
+ config: config
445
+ ).wait_result
446
+ [result, result[:usage]]
518
447
  end
519
- raise result if result.is_a?(Exception)
520
- result
521
448
  end
522
449
 
523
450
  # Invokes this agent asynchronously and returns a {Phronomy::Task}.
@@ -542,39 +469,81 @@ module Phronomy
542
469
  # @param invocation_context [Phronomy::InvocationContext, nil]
543
470
  # @return [Phronomy::Task]
544
471
  # @api public
545
- def invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
472
+ def invoke_async(input, messages: [], thread_id: nil, config: {},
473
+ invocation_context: nil, on_tool_approval_required: nil)
546
474
  if invocation_context
547
475
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
548
476
  end
549
477
  result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
550
- _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
478
+ approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
479
+ _start_invocation(
480
+ result_task, input,
481
+ messages: messages, thread_id: thread_id, config: config,
482
+ approval_snapshot: approval_snapshot
483
+ )
551
484
  result_task
552
485
  end
553
486
 
554
- # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
555
- # as they are produced by the underlying LLM.
487
+ # Invokes this agent asynchronously and delivers stream events from the
488
+ # Runtime-owned EventLoop thread.
556
489
  #
557
- # Events emitted (in order):
558
- # :token — each content delta from the LLM
559
- # :tool_call — when the LLM requests a tool
560
- # :tool_result — after a tool completes
561
- # :done — final event carrying output, messages, and usage
562
- # :error — if an unrecoverable error occurs
490
+ # The callback must return quickly. Blocking I/O, synchronous Agent calls,
491
+ # sleep, and heavy CPU work must be delegated by the Application.
563
492
  #
564
- # @param input [String, Hash] same as #invoke
565
- # @param messages [Array<RubyLLM::Message>] same as #invoke
566
- # @param thread_id [String, nil] same as #invoke
567
- # @param config [Hash] same as #invoke
493
+ # @return [Phronomy::Task] final invocation result
494
+ # @api public
495
+ def stream_async(input, messages: [], thread_id: nil, config: {},
496
+ invocation_context: nil, on_tool_approval_required: nil, &block)
497
+ raise ArgumentError, "stream_async requires a block" unless block
498
+
499
+ if invocation_context
500
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
501
+ end
502
+
503
+ result_task = Phronomy::Task.deferred(
504
+ name: "agent-#{(self.class.name || "anonymous").downcase}-stream-async"
505
+ )
506
+ approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
507
+ _start_invocation(
508
+ result_task,
509
+ input,
510
+ messages: messages,
511
+ thread_id: thread_id,
512
+ config: config,
513
+ approval_snapshot: approval_snapshot,
514
+ mode: :stream,
515
+ on_event: block
516
+ )
517
+ result_task
518
+ end
519
+
520
+ # Synchronous wrapper around {#stream_async}.
521
+ #
522
+ # Stream callbacks execute on the EventLoop thread, not on the thread that
523
+ # calls this method. This method only blocks while waiting for the final Task.
568
524
  # @yield [Phronomy::Agent::StreamEvent]
569
- # @return [Hash] { output:, messages:, usage: } — same as #invoke
525
+ # @return [Hash] same result shape as #invoke
570
526
  # @api public
571
- def stream(input, messages: [], thread_id: nil, config: {}, &block)
572
- return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
527
+ def stream(input, messages: [], thread_id: nil, config: {},
528
+ invocation_context: nil, on_tool_approval_required: nil, &block)
529
+ raise ArgumentError, "stream requires a block" unless block
573
530
 
574
- _stream_impl(input, messages: messages, thread_id: thread_id, config: config, &block)
575
- rescue => e
576
- block&.call(StreamEvent.new(type: :error, payload: {error: e}))
577
- raise
531
+ if invocation_context
532
+ thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
533
+ end
534
+ _check_scheduler_reentrancy(:stream, :stream_async)
535
+
536
+ trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
537
+ result = stream_async(
538
+ input,
539
+ messages: messages,
540
+ thread_id: thread_id,
541
+ config: config,
542
+ on_tool_approval_required: on_tool_approval_required,
543
+ &block
544
+ ).wait_result
545
+ [result, result[:usage]]
546
+ end
578
547
  end
579
548
 
580
549
  # @deprecated The context version cache has been removed. Returns nil.
@@ -604,12 +573,18 @@ module Phronomy
604
573
  [effective_thread_id, effective_config]
605
574
  end
606
575
 
607
- def _check_scheduler_reentrancy
576
+ def _check_scheduler_reentrancy(sync_method, async_method)
577
+ if Phronomy::Runtime.instance.event_loop.current?
578
+ raise Phronomy::SchedulerReentrancyError,
579
+ "#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
580
+ "Use #{async_method} and return immediately."
581
+ end
582
+
608
583
  return unless Phronomy::Task.current
609
584
 
610
- msg = "#{self.class.name}#invoke called from inside a scheduler task. " \
585
+ msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
611
586
  "This blocks the scheduler until the inner invocation completes, preventing " \
612
- "other tasks from making progress. Use invoke_async + await instead."
587
+ "other tasks from making progress. Use #{async_method} + await instead."
613
588
  if Phronomy.configuration.strict_runtime_guards
614
589
  raise Phronomy::SchedulerReentrancyError, msg
615
590
  elsif Phronomy.configuration.logger
@@ -619,48 +594,6 @@ module Phronomy
619
594
  end
620
595
  end
621
596
 
622
- # Streaming implementation for #stream.
623
- def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
624
- trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
625
- input = run_input_filters!(input)
626
-
627
- chat = build_chat
628
- user_message = extract_message(input)
629
- context = build_context(
630
- input,
631
- messages: messages,
632
- thread_id: thread_id,
633
- config: config,
634
- budget: build_token_budget,
635
- instruction: build_instructions(input),
636
- tools: self.class.tools + _handoff_tools
637
- )
638
- _apply_context_to_chat(chat, context)
639
-
640
- current_tool_call = nil
641
- chat.on_tool_call do |tool_call|
642
- current_tool_call = tool_call
643
- block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
644
- end
645
- chat.on_tool_result do |tool_result|
646
- block.call(StreamEvent.new(type: :tool_result, payload: {
647
- tool_call_id: current_tool_call&.id,
648
- tool_name: current_tool_call&.name,
649
- tool_result: tool_result
650
- }))
651
- end
652
-
653
- run_before_completion_hooks!(chat, config)
654
-
655
- output, usage = _drain_stream(chat, user_message, config, &block)
656
- output = run_output_filters!(output)
657
-
658
- result = {output: output, messages: chat.messages, usage: usage}
659
- block.call(StreamEvent.new(type: :done, payload: result))
660
- [result, usage]
661
- end
662
- end
663
-
664
597
  # Assembles the LLM context (system prompt + conversation messages)
665
598
  # for a single invocation. Subclasses may override this method to
666
599
  # inject custom context editing logic without having to override
@@ -828,160 +761,398 @@ module Phronomy
828
761
  end
829
762
  protected :instance_knowledge_chunks
830
763
 
831
- # Runs the agent invocation through the FSM-based execution engine.
832
- # Called by Retryable#_invoke_impl (which wraps it in a retry loop).
833
- # Returns the result hash: { output:, messages:, usage: } on success,
834
- # or { suspended: true, session_id:, messages: } when awaiting approval.
764
+ # Starts one AgentInvocation and resolves +result_task+ from that session.
765
+ # Phronomy translates the adapter's final error but never starts another
766
+ # AgentInvocation automatically.
835
767
  # @api private
836
- def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
768
+ def _start_invocation(result_task, input, messages:, thread_id:, config:,
769
+ approval_snapshot:, mode: :invoke, on_event: nil)
837
770
  effective_config = thread_id ? config.merge(thread_id: thread_id) : config
838
- # Fail fast when the token is already cancelled before any LLM call.
839
771
  check_cancellation!(effective_config, "invocation cancelled")
840
772
  runtime = Phronomy::Runtime.instance
841
773
  event_loop = runtime.event_loop
842
- trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
843
- session = Agent::InvocationSession.build(
844
- agent: self,
845
- input: input,
846
- messages: messages,
847
- config: effective_config,
848
- runtime: runtime
849
- )
850
- completion_queue = event_loop.register(session)
851
- ctx = completion_queue.pop
852
- raise ctx if ctx.is_a?(Exception)
853
- result = _extract_invoke_result(ctx, session.id)
854
- [result, result[:usage]]
855
- end
856
- end
857
-
858
- # Starts a single invocation attempt and wires retry/translation onto result_task.
859
- # Non-blocking: registers with EventLoop and returns immediately.
860
- # On error, retries via timer_queue when policy allows; otherwise translates
861
- # and resolves result_task as failed.
862
- # @api private
863
- def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
864
- effective_config = thread_id ? config.merge(thread_id: thread_id) : config
865
- check_cancellation!(effective_config, "invocation cancelled")
866
- runtime = Phronomy::Runtime.instance
867
- event_loop = runtime.event_loop
868
- session = Agent::InvocationSession.build(
774
+ session = Agent::AgentInvocationSessionBuilder.build(
869
775
  agent: self,
870
776
  input: input,
871
777
  messages: messages,
872
778
  config: effective_config,
779
+ approval_policy: approval_snapshot[:policy],
780
+ approval_listener: approval_snapshot[:listener],
781
+ mode: mode,
782
+ on_event: on_event,
873
783
  runtime: runtime
874
784
  )
875
- source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
785
+ callback_error_policy =
786
+ Phronomy.configuration.stream_callback_error_policy
787
+ source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
788
+ source_task.on_complete do |invocation, error|
789
+ _handle_agent_completion(
790
+ result_task: result_task,
791
+ invocation: invocation,
792
+ error: error,
793
+ mode: mode,
794
+ listener: on_event,
795
+ event_loop: event_loop,
796
+ callback_error_policy: callback_error_policy
797
+ )
798
+ end
799
+
800
+ # Register completion handling before EventLoop admission. Otherwise an
801
+ # immediately finishing session can complete source_task before the
802
+ # callback is installed, causing Task#on_complete to run on this thread.
876
803
  event_loop.register(session, completion: source_task)
877
- session_id = session.id
878
- policy = self.class._retry_policy
879
-
880
- source_task.on_complete do |ctx, error|
881
- retriable = error &&
882
- !error.is_a?(Phronomy::FilterBlockError) &&
883
- !error.is_a?(Phronomy::CancellationError) &&
884
- policy && attempt < policy[:times]
885
-
886
- if retriable
887
- wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
888
- # Call _sleep_proc for instrumentation (test spy records the duration;
889
- # in production this is a no-op since timer_queue handles the actual delay).
890
- self.class._sleep_proc.call(wait) if wait > 0
891
- do_retry = -> {
892
- _start_invoke_attempt(
893
- result_task, input,
894
- messages: messages, thread_id: thread_id, config: config,
895
- attempt: attempt + 1
804
+ rescue => e
805
+ _fail_result_task(result_task, e)
806
+ end
807
+
808
+ def _complete_result_task(task, result)
809
+ task.backend.unblock(result, nil)
810
+ task.transition!(:completed, value: result)
811
+ end
812
+
813
+ def _fail_result_task(task, error)
814
+ task.backend.unblock(nil, error)
815
+ task.transition!(:failed, error: error)
816
+ end
817
+
818
+ def _translated_error(error)
819
+ translate_and_reraise!(error)
820
+ rescue => translated
821
+ translated
822
+ end
823
+
824
+ # Completes one Agent execution interval. Execution failures and
825
+ # Application callback failures are deliberately handled in separate
826
+ # exception domains.
827
+ def _handle_agent_completion(result_task:, invocation:, error:, mode:, listener:,
828
+ event_loop:, callback_error_policy:)
829
+ if mode == :stream && !event_loop.current?
830
+ completion_error = error || Phronomy::Error.new(
831
+ "Stream completion occurred outside the EventLoop"
832
+ )
833
+ _fail_result_task(result_task, _translated_error(completion_error))
834
+ return
835
+ end
836
+
837
+ result = nil
838
+ execution_error = nil
839
+ begin
840
+ raise error if error
841
+
842
+ result = _extract_invoke_result(invocation)
843
+ rescue => e
844
+ execution_error = _translated_error(e)
845
+ end
846
+
847
+ if execution_error
848
+ if mode == :stream
849
+ event = StreamEvent.new(
850
+ type: :error,
851
+ payload: {error: execution_error}
852
+ )
853
+ callback_error = _deliver_stream_event(listener, event)
854
+ if callback_error
855
+ _report_stream_callback_error(
856
+ callback_error,
857
+ event: event,
858
+ invocation_id: invocation&.id,
859
+ callback_error_policy: callback_error_policy
896
860
  )
897
- }
898
- if wait > 0
899
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
900
- else
901
- do_retry.call
902
- end
903
- elsif error
904
- begin
905
- translate_and_reraise!(error)
906
- rescue => translated
907
- result_task.backend.unblock(nil, translated)
908
- result_task.transition!(:failed, error: translated)
909
- end
910
- else
911
- begin
912
- result = _extract_invoke_result(ctx, session_id)
913
- result_task.backend.unblock(result, nil)
914
- result_task.transition!(:completed, value: result)
915
- rescue => e
916
- result_task.backend.unblock(nil, e)
917
- result_task.transition!(:failed, error: e)
918
861
  end
919
862
  end
863
+
864
+ # An Application failure while consuming :error never replaces the
865
+ # original Agent/LLM/Tool/Runtime failure.
866
+ _fail_result_task(result_task, execution_error)
867
+ return
868
+ end
869
+
870
+ unless mode == :stream
871
+ _complete_result_task(result_task, result)
872
+ return
873
+ end
874
+
875
+ event = _build_stream_terminal_event(result)
876
+ callback_error = _deliver_stream_event(listener, event)
877
+ unless callback_error
878
+ _complete_result_task(result_task, result)
879
+ return
880
+ end
881
+
882
+ _report_stream_callback_error(
883
+ callback_error,
884
+ event: event,
885
+ invocation_id: invocation&.id,
886
+ callback_error_policy: callback_error_policy
887
+ )
888
+
889
+ if callback_error_policy == :fail_task
890
+ wrapped = _build_stream_callback_error(
891
+ event_type: event.type,
892
+ callback_error: callback_error,
893
+ result: result
894
+ )
895
+ _fail_result_task(result_task, wrapped)
896
+ else
897
+ _complete_result_task(result_task, result)
920
898
  end
921
- rescue => e
922
- result_task.backend.unblock(nil, e)
923
- result_task.transition!(:failed, error: e)
924
899
  end
925
900
 
926
- # Continues a suspended invocation identified by +session_id+.
927
- # When +approved: true+, executes the pending tool and continues.
928
- # When +approved: false+, rejects the tool call and ends the invocation.
929
- #
930
- # @param session_id [String]
931
- # @param approved [Boolean]
932
- # @param config [Hash]
933
- # @return [Hash]
934
- # @api public
935
- def approve(session_id, approved: true, config: {})
936
- ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
937
- raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
938
-
939
- # Reset approval_required so executing_tool_action proceeds instead of
940
- # re-suspending when called after the :approve FSM transition.
941
- ctx.approval_required = false
942
- ctx.approved = true if approved # signals executing_tool to run the tool
943
- ctx.rejected = !approved # signals _extract_invoke_result for rejection
944
-
945
- if approved
946
- _resume_fsm(ctx, :approve)
901
+ def _build_stream_terminal_event(result)
902
+ if result[:suspended]
903
+ StreamEvent.new(
904
+ type: :approval_required,
905
+ payload: {request: result[:approval_request]}
906
+ )
947
907
  else
948
- _resume_fsm(ctx, :reject)
908
+ StreamEvent.new(type: :done, payload: result)
949
909
  end
950
910
  end
911
+
912
+ # Returns the Application exception instead of allowing it to escape the
913
+ # shared EventLoop. A nil return means delivery succeeded or no listener
914
+ # was registered.
915
+ def _deliver_stream_event(listener, event)
916
+ return unless listener
917
+
918
+ listener.call(event)
919
+ nil
920
+ rescue => callback_error
921
+ callback_error
922
+ end
923
+
924
+ def _build_stream_callback_error(event_type:, callback_error:, result:)
925
+ wrapped = Phronomy::StreamCallbackError.new(
926
+ event_type: event_type,
927
+ original_error: callback_error,
928
+ result: result
929
+ )
930
+
931
+ begin
932
+ raise wrapped, cause: callback_error
933
+ rescue Phronomy::StreamCallbackError => error
934
+ error.set_backtrace(callback_error.backtrace)
935
+ error
936
+ end
937
+ end
938
+
939
+ def _report_stream_callback_error(callback_error, event:, invocation_id:,
940
+ callback_error_policy:)
941
+ lines = [
942
+ "[Phronomy] Stream callback failed",
943
+ "event=#{event.type.inspect}",
944
+ "agent_invocation_id=#{invocation_id || "unknown"}",
945
+ "policy=#{callback_error_policy.inspect}",
946
+ "error=#{callback_error.class}: #{callback_error.message}"
947
+ ]
948
+ Array(callback_error.backtrace).each { |line| lines << " #{line}" }
949
+ _warn_stream_callback_error(lines.join("\n"))
950
+ rescue => reporting_error
951
+ _kernel_warn_safely(
952
+ "[Phronomy] Failed to report stream callback error: " \
953
+ "#{reporting_error.class}: #{reporting_error.message}"
954
+ )
955
+ end
956
+
957
+ def _warn_stream_callback_error(message)
958
+ logger = Phronomy.configuration.logger
959
+ unless logger
960
+ _kernel_warn_safely(message)
961
+ return
962
+ end
963
+
964
+ logger.warn(message)
965
+ rescue => logger_error
966
+ _kernel_warn_safely(
967
+ "#{message}\n" \
968
+ "[Phronomy] Logger failed while reporting a stream callback error: " \
969
+ "#{logger_error.class}: #{logger_error.message}"
970
+ )
971
+ end
972
+
973
+ def _kernel_warn_safely(message)
974
+ Kernel.warn(message)
975
+ rescue
976
+ nil
977
+ end
978
+
979
+ # Continues a suspended AgentInvocation. The parent session is registered
980
+ # asynchronously; this method is only the synchronous wrapper.
981
+ # @return [Hash]
982
+ # @api public
983
+ def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
984
+ _check_scheduler_reentrancy(:approve, :approve_async)
985
+ approve_async(
986
+ agent_invocation_id,
987
+ approval_request_id: approval_request_id,
988
+ approved: approved,
989
+ config: config
990
+ ).wait_result
991
+ end
951
992
  public :approve
952
993
 
953
- # Builds and runs a resume FSMSession for the given context and event.
994
+ # Continues a suspended AgentInvocation without blocking the caller.
995
+ #
996
+ # This method is safe to call from an EventLoop stream callback. The
997
+ # returned Task completes when the resumed AgentInvocation finishes,
998
+ # suspends again, or fails.
999
+ # @return [Phronomy::Task]
1000
+ # @api public
1001
+ def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
1002
+ result_task = Phronomy::Task.deferred(
1003
+ name: "agent-approval-resume:#{agent_invocation_id}"
1004
+ )
1005
+
1006
+ begin
1007
+ entry = Agent::AgentInvocationRegistry.consume_approval(
1008
+ agent_invocation_id, approval_request_id
1009
+ )
1010
+ unless entry
1011
+ raise ArgumentError,
1012
+ "No pending approval found for AgentInvocation #{agent_invocation_id}"
1013
+ end
1014
+
1015
+ _start_approval_resume(
1016
+ result_task,
1017
+ entry.invocation,
1018
+ approved: approved,
1019
+ config: config
1020
+ )
1021
+ rescue => e
1022
+ _fail_result_task(result_task, e)
1023
+ end
1024
+
1025
+ result_task
1026
+ end
1027
+ public :approve_async
1028
+
1029
+ # Parent completion handling is installed before EventLoop registration,
1030
+ # and the parent session is registered before child sessions so immediate
1031
+ # child events cannot be lost.
954
1032
  # @api private
955
- def _resume_fsm(ctx, event)
1033
+ def _start_approval_resume(result_task, invocation, approved:, config:)
1034
+ invocation.merge_config!(config)
1035
+ invocation.begin_approval_resume!(approved: approved)
956
1036
  runtime = Phronomy::Runtime.instance
957
1037
  event_loop = runtime.event_loop
958
- session = Agent::InvocationSession.build_for_resume(
959
- agent: self,
960
- context: ctx,
961
- resume_event: event,
962
- resume_phase: :awaiting_approval,
1038
+ source_task = Phronomy::Task.deferred(
1039
+ name: "#{result_task.name}-source"
1040
+ )
1041
+ parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
1042
+ agent_invocation: invocation,
1043
+ resume_event: :resume,
1044
+ resume_phase: :suspended,
963
1045
  runtime: runtime
964
1046
  )
965
- completion_queue = event_loop.register(session)
966
- resumed_ctx = completion_queue.pop
967
- raise resumed_ctx if resumed_ctx.is_a?(Exception)
968
- _extract_invoke_result(resumed_ctx, session.id)
1047
+ stream_listener = invocation.stream_listener
1048
+ mode = stream_listener ? :stream : :invoke
1049
+ callback_error_policy =
1050
+ Phronomy.configuration.stream_callback_error_policy
1051
+
1052
+ source_task.on_complete do |completed_invocation, error|
1053
+ _handle_agent_completion(
1054
+ result_task: result_task,
1055
+ invocation: completed_invocation,
1056
+ error: error,
1057
+ mode: mode,
1058
+ listener: stream_listener,
1059
+ event_loop: event_loop,
1060
+ callback_error_policy: callback_error_policy
1061
+ )
1062
+ end
1063
+
1064
+ # The parent must exist before any child can post an immediate result.
1065
+ event_loop.register(parent_session, completion: source_task)
1066
+
1067
+ invocation.tool_invocations.each do |child|
1068
+ child_session = if child.awaiting_approval?
1069
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
1070
+ tool_invocation: child,
1071
+ resume_event: approved ? :approve : :reject,
1072
+ resume_phase: :awaiting_approval,
1073
+ runtime: runtime
1074
+ )
1075
+ elsif !approved && child.authorized?
1076
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
1077
+ tool_invocation: child,
1078
+ resume_event: :cancel,
1079
+ resume_phase: :authorized,
1080
+ runtime: runtime
1081
+ )
1082
+ end
1083
+ _register_tool_invocation_session(event_loop, runtime, child, child_session) if child_session
1084
+ end
969
1085
  end
970
1086
 
971
- # Interprets the InvocationContext after FSM completion/halt and returns
972
- # the appropriate result hash or raises the block error.
973
- # @api private
974
- def _extract_invoke_result(ctx, session_id)
975
- if ctx.phase == :awaiting_approval
976
- Agent::SuspendedSessionRegistry.store(session_id, ctx)
977
- {suspended: true, session_id: session_id, messages: ctx.messages}
978
- elsif ctx.input_blocked? || ctx.output_blocked?
979
- raise ctx.block_error
980
- elsif ctx.rejected
981
- # Rejected path: :reject event → :blocked terminal
982
- {rejected: true, messages: ctx.messages}
1087
+ def _extract_invoke_result(invocation)
1088
+ if invocation.phase == :suspended
1089
+ request = invocation.approval_request
1090
+ Agent::AgentInvocationRegistry.store_suspended(invocation, request)
1091
+ _dispatch_tool_approval_notification(invocation, request)
1092
+ {
1093
+ suspended: true,
1094
+ agent_invocation_id: invocation.id,
1095
+ approval_request: request,
1096
+ messages: invocation.messages
1097
+ }
1098
+ elsif invocation.input_blocked? || invocation.output_blocked?
1099
+ raise invocation.block_error
1100
+ elsif invocation.error
1101
+ raise invocation.error
1102
+ elsif invocation.rejected
1103
+ {rejected: true, messages: invocation.messages}
1104
+ else
1105
+ {output: invocation.output, messages: invocation.messages, usage: invocation.usage}
1106
+ end
1107
+ end
1108
+
1109
+ def _register_tool_invocation_session(event_loop, runtime, child, session)
1110
+ completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
1111
+ completion.on_complete do |_result, error|
1112
+ next unless error
1113
+
1114
+ child.mark_framework_failed!(error)
1115
+ runtime.event_loop.post(
1116
+ Phronomy::Event.new(
1117
+ type: :tool_failed,
1118
+ target_id: child.parent_agent_invocation_id,
1119
+ payload: {tool_invocation_id: child.id}
1120
+ )
1121
+ )
1122
+ end
1123
+ event_loop.register(session, completion: completion)
1124
+ end
1125
+
1126
+ def _dispatch_tool_approval_notification(invocation, request)
1127
+ listener = invocation.approval_listener
1128
+ return unless listener
1129
+
1130
+ Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
1131
+ listener.call(request)
1132
+ end
1133
+ rescue => e
1134
+ message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
1135
+ if Phronomy.configuration.logger
1136
+ Phronomy.configuration.logger.warn(message)
983
1137
  else
984
- {output: ctx.output, messages: ctx.messages, usage: ctx.usage}
1138
+ Kernel.warn(message)
1139
+ end
1140
+ end
1141
+
1142
+ def _approval_configuration_mutex
1143
+ return @approval_configuration_mutex if @approval_configuration_mutex
1144
+
1145
+ APPROVAL_CONFIGURATION_INIT_MUTEX.synchronize do
1146
+ @approval_configuration_mutex ||= Mutex.new
1147
+ end
1148
+ end
1149
+
1150
+ def _approval_configuration_snapshot(invocation_listener = nil)
1151
+ _approval_configuration_mutex.synchronize do
1152
+ {
1153
+ policy: @tool_approval_policy,
1154
+ listener: invocation_listener || @tool_approval_listener
1155
+ }.freeze
985
1156
  end
986
1157
  end
987
1158
 
@@ -1002,22 +1173,6 @@ module Phronomy
1002
1173
  context[:messages].each { |msg| chat.messages << msg }
1003
1174
  end
1004
1175
 
1005
- def _drain_stream(chat, user_message, config, &block)
1006
- adapter = Phronomy.configuration.llm_adapter
1007
- chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
1008
- pending = adapter.stream_async(chat, user_message, config: config, enqueue_to: chunk_queue)
1009
-
1010
- loop do
1011
- chunk = chunk_queue.pop
1012
- break if chunk.nil?
1013
- block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
1014
- check_cancellation!(config, "invocation cancelled during streaming")
1015
- end
1016
-
1017
- response = pending.blocking_wait
1018
- [response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
1019
- end
1020
-
1021
1176
  # Builds a TokenBudget for this agent's model if possible.
1022
1177
  # When context_window is set at the class level, that value is used directly
1023
1178
  # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
@@ -1065,7 +1220,7 @@ module Phronomy
1065
1220
  t = self.class.temperature
1066
1221
  parallel_class = build_chat_class
1067
1222
  chat = if parallel_class
1068
- parallel_class.new(max_parallel_tools: self.class.max_parallel_tools, **opts)
1223
+ parallel_class.new(**opts)
1069
1224
  else
1070
1225
  RubyLLM.chat(**opts)
1071
1226
  end
@@ -1126,33 +1281,12 @@ module Phronomy
1126
1281
  raise Phronomy::CancellationError, message if ct&.cancelled?
1127
1282
  end
1128
1283
 
1129
- # Builds the final tool class to register with the chat.
1130
- #
1131
- # When an already-instantiated tool object is passed (e.g. a
1132
- # {Phronomy::Tools::Mcp} returned by +Phronomy::Tools::Mcp.from_server+), it is
1133
- # returned as-is. RubyLLM's +with_tool+ accepts both classes and
1134
- # instances, so no wrapping is needed.
1135
- #
1136
- # For tool classes, three transformations are applied in order:
1137
- # 1. Alias override — when the Hash form of .tools maps this class to an
1138
- # explicit name, an anonymous subclass with that tool_name is returned.
1139
- # 2. Scope policy — when a scope is declared on the tool, the configured
1140
- # {Phronomy::Agent::Context::Capability::ScopePolicy} (or the default) is evaluated.
1141
- # +:reject+ wraps the tool to return a denial message without executing.
1142
- # +:approve+ behaves like requiring approval (same as step 3 when the
1143
- # tool does not already have +requires_approval+).
1144
- # 3. Approval gate — when the tool class has +requires_approval+ set AND
1145
- # an approval handler has been registered via #on_approval_required,
1146
- # the tool's #call method is wrapped: the handler is invoked with
1147
- # (tool_name, args) and, if it returns falsy, the tool returns a denial
1148
- # message instead of executing.
1284
+ # Builds the final Tool class to register with RubyLLM. Alias and Tool
1285
+ # result filters remain wrappers; authorization is handled only by
1286
+ # ToolInvocation before Tool#call begins.
1149
1287
  def prepare_tool_class(tool_class)
1150
- # When an instantiated tool object is passed (e.g. Phronomy::Tools::Mcp.from_server
1151
- # returns an instance, not a class), skip class-level processing and
1152
- # return it directly. RubyLLM#with_tool handles both forms.
1153
1288
  return tool_class unless tool_class.is_a?(Class)
1154
1289
 
1155
- # Step 1: apply alias if needed.
1156
1290
  resolved = if (alias_name = self.class.tool_aliases[tool_class])
1157
1291
  parent_description = tool_class.description
1158
1292
  Class.new(tool_class) do
@@ -1163,62 +1297,17 @@ module Phronomy
1163
1297
  tool_class
1164
1298
  end
1165
1299
 
1166
- # Step 2: evaluate scope policy.
1167
- scope = resolved.scope
1168
- if scope
1169
- policy = @scope_policy || Phronomy::Agent::Context::Capability::ScopePolicy::DEFAULT
1170
- decision = policy.call(resolved, scope, self)
1171
- case decision
1172
- when :reject
1173
- effective_name = resolved.new.name
1174
- rejected_class = Class.new(resolved) do
1175
- tool_name effective_name
1176
- define_method(:call) do |_args, **_kwargs|
1177
- "Tool execution denied: scope :#{scope} is not permitted."
1178
- end
1179
- end
1180
- return rejected_class
1181
- when :approve
1182
- # Treat as requires_approval unless the tool already has that flag.
1183
- unless resolved.requires_approval
1184
- effective_name = resolved.new.name
1185
- resolved = Class.new(resolved) do
1186
- tool_name effective_name
1187
- requires_approval true
1188
- end
1189
- end
1190
- end
1191
- end
1192
-
1193
- # Step 3: wrap with approval gate when handler is registered.
1194
- if resolved.requires_approval && @approval_handler
1195
- handler = @approval_handler
1196
- # Capture the effective tool name before building the anonymous subclass.
1197
- # Class-level instance variables (@tool_name) are not inherited through
1198
- # subclassing, so the wrapper must set it explicitly.
1199
- effective_name = resolved.new.name
1200
- resolved = Class.new(resolved) do
1201
- tool_name effective_name
1202
- define_method(:call) do |args, **kwargs|
1203
- if handler.call(name, args)
1204
- super(args, **kwargs)
1205
- else
1206
- "Tool execution denied."
1207
- end
1208
- end
1209
- end
1210
- end
1211
-
1212
- # Step 4: wrap with tool result filters when registered.
1213
1300
  result_filters = _tool_result_filters_for(tool_class)
1214
1301
  return resolved if result_filters.empty?
1215
1302
 
1216
- effective_name4 = resolved.new.name
1303
+ effective_name = resolved.new.name
1217
1304
  Class.new(resolved) do
1218
- tool_name effective_name4
1305
+ tool_name effective_name
1219
1306
  define_method(:call) do |args, **kwargs|
1220
1307
  result = super(args, **kwargs)
1221
- result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
1308
+ result_filters.inject(result) { |val, filter|
1309
+ filter.call(val, tool_name: name, args: args)
1310
+ }
1222
1311
  end
1223
1312
  end
1224
1313
  end