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
@@ -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]
322
+ # @api public
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]
388
338
  # @api public
389
- def approve(session_id, approved: true, config: {})
390
- new.approve(session_id, approved: approved, config: config)
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,172 +364,26 @@ 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
428
- self
429
- end
372
+ def tool_approval_policy(&block)
373
+ raise ArgumentError, "tool_approval_policy requires a block" unless block
430
374
 
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]
438
- # @api public
439
- attr_writer :scope_policy
440
-
441
- # 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.
444
- #
445
- # @param input [String, Hash] the user message; a Hash may supply
446
- # +:message+, +:query+, or +:user+ as the text key, plus any template
447
- # variables consumed by the configured instructions template.
448
- # @param messages [Array<RubyLLM::Message>] conversation history from a
449
- # previous invocation. The application owns and persists this array;
450
- # pass it on every turn to maintain multi-turn context.
451
- # @param thread_id [String, nil] conversation thread identifier, forwarded
452
- # to the compaction context when on_compact is configured.
453
- # @param config [Hash] additional runtime options:
454
- # +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
455
- # +:session_id+ (+String+, optional) — session identity forwarded to the tracer
456
- # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
457
- # object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
458
- # derived from it (existing +config:+ keys take precedence as backward-compat
459
- # aliases). The object is also stored in +config[:invocation_context]+ so that
460
- # +task_id+ / +parent_task_id+ appear in trace spans automatically.
461
- # @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
462
- # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
463
- # messages: Array }+ when the invocation was suspended awaiting tool approval.
464
- # @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
465
- # @example Normal invocation
466
- # result = MyAgent.new.invoke("What is Ruby?")
467
- # puts result[:output]
468
- # @example Multi-turn conversation
469
- # result1 = agent.invoke("Hi, I'm Alice.")
470
- # result2 = agent.invoke("What's my name?", messages: result1[:messages])
471
- # @example Suspend / resume flow
472
- # result = agent.invoke("Perform task X")
473
- # if result[:suspended]
474
- # result = agent.resume(result[:checkpoint], approved: true)
475
- # end
476
- # puts result[:output]
477
- # @example With InvocationContext (deadline-based timeout)
478
- # ctx = Phronomy::InvocationContext.new(
479
- # thread_id: "conv-123",
480
- # deadline: Phronomy::Concurrency::Deadline.in(30),
481
- # task_id: SecureRandom.uuid
482
- # )
483
- # result = MyAgent.new.invoke("Hello", invocation_context: ctx)
484
- # @api public
485
- def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
486
- if invocation_context
487
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
488
- 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
514
-
515
- result = scope.pop_queue(completion_queue) do
516
- raise Phronomy::TimeoutError,
517
- "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
518
- end
519
- raise result if result.is_a?(Exception)
520
- result
521
- end
522
-
523
- # Invokes this agent asynchronously and returns a {Phronomy::Task}.
524
- #
525
- # This is the primary async entry point. {#invoke} is a synchronous wrapper
526
- # that calls this method and blocks the caller until the task completes.
527
- # Calling {#invoke} from inside an active scheduler task raises
528
- # {Phronomy::SchedulerReentrancyError}; use +invoke_async+ directly in that
529
- # context.
530
- #
531
- # The task is registered with the Runtime task registry so {Runtime#shutdown}
532
- # drains in-flight invocations before process exit.
533
- #
534
- # @example
535
- # task = agent.invoke_async("Hello!")
536
- # result = task.wait_result # => { output: "...", messages: [...], usage: ... }
537
- #
538
- # @param input [String, Hash]
539
- # @param messages [Array]
540
- # @param thread_id [String, nil]
541
- # @param config [Hash]
542
- # @param invocation_context [Phronomy::InvocationContext, nil]
543
- # @return [Phronomy::Task]
544
- # @api public
545
- def invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
546
- if invocation_context
547
- thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
548
- end
549
- 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)
551
- result_task
375
+ _approval_configuration_mutex.synchronize { @tool_approval_policy = block }
376
+ self
552
377
  end
553
378
 
554
- # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
555
- # as they are produced by the underlying LLM.
556
- #
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
563
- #
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
568
- # @yield [Phronomy::Agent::StreamEvent]
569
- # @return [Hash] { output:, messages:, usage: } — same as #invoke
379
+ # Registers a non-blocking Application notification listener.
380
+ # @return [self]
570
381
  # @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
382
+ def on_tool_approval_required(&block)
383
+ raise ArgumentError, "on_tool_approval_required requires a block" unless block
573
384
 
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
385
+ _approval_configuration_mutex.synchronize { @tool_approval_listener = block }
386
+ self
578
387
  end
579
388
 
580
389
  # @deprecated The context version cache has been removed. Returns nil.
@@ -604,12 +413,18 @@ module Phronomy
604
413
  [effective_thread_id, effective_config]
605
414
  end
606
415
 
607
- def _check_scheduler_reentrancy
416
+ def _check_scheduler_reentrancy(sync_method, async_method)
417
+ if Phronomy::Runtime.instance.event_loop.current?
418
+ raise Phronomy::SchedulerReentrancyError,
419
+ "#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
420
+ "Use #{async_method} and return immediately."
421
+ end
422
+
608
423
  return unless Phronomy::Task.current
609
424
 
610
- msg = "#{self.class.name}#invoke called from inside a scheduler task. " \
425
+ msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
611
426
  "This blocks the scheduler until the inner invocation completes, preventing " \
612
- "other tasks from making progress. Use invoke_async + await instead."
427
+ "other tasks from making progress. Use #{async_method} + await instead."
613
428
  if Phronomy.configuration.strict_runtime_guards
614
429
  raise Phronomy::SchedulerReentrancyError, msg
615
430
  elsif Phronomy.configuration.logger
@@ -619,48 +434,6 @@ module Phronomy
619
434
  end
620
435
  end
621
436
 
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
437
  # Assembles the LLM context (system prompt + conversation messages)
665
438
  # for a single invocation. Subclasses may override this method to
666
439
  # inject custom context editing logic without having to override
@@ -828,160 +601,202 @@ module Phronomy
828
601
  end
829
602
  protected :instance_knowledge_chunks
830
603
 
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.
835
- # @api private
836
- def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
837
- 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
- check_cancellation!(effective_config, "invocation cancelled")
840
- runtime = Phronomy::Runtime.instance
841
- 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
604
+ def _complete_result_task(task, result)
605
+ task.backend.unblock(result, nil)
606
+ task.transition!(:completed, value: result)
607
+ end
608
+
609
+ def _fail_result_task(task, error)
610
+ task.backend.unblock(nil, error)
611
+ task.transition!(:failed, error: error)
612
+ end
613
+
614
+ def _translated_error(error)
615
+ translate_and_reraise!(error)
616
+ rescue => translated
617
+ translated
618
+ end
619
+
620
+ def _build_stream_terminal_event(result)
621
+ if result[:suspended]
622
+ StreamEvent.new(
623
+ type: :approval_required,
624
+ payload: {request: result[:approval_request]}
849
625
  )
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]]
626
+ else
627
+ StreamEvent.new(type: :done, payload: result)
855
628
  end
856
629
  end
857
630
 
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(
869
- agent: self,
870
- input: input,
871
- messages: messages,
872
- config: effective_config,
873
- runtime: runtime
631
+ # Returns the Application exception instead of allowing it to escape the
632
+ # shared EventLoop. A nil return means delivery succeeded or no listener
633
+ # was registered.
634
+ def _deliver_stream_event(listener, event)
635
+ return unless listener
636
+
637
+ listener.call(event)
638
+ nil
639
+ rescue => callback_error
640
+ callback_error
641
+ end
642
+
643
+ def _build_stream_callback_error(event_type:, callback_error:, result:)
644
+ wrapped = Phronomy::StreamCallbackError.new(
645
+ event_type: event_type,
646
+ original_error: callback_error,
647
+ result: result
874
648
  )
875
- source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
876
- 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
896
- )
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
- end
919
- end
649
+
650
+ begin
651
+ raise wrapped, cause: callback_error
652
+ rescue Phronomy::StreamCallbackError => error
653
+ error.set_backtrace(callback_error.backtrace)
654
+ error
655
+ end
656
+ end
657
+
658
+ def _report_stream_callback_error(callback_error, event:, invocation_id:,
659
+ callback_error_policy:)
660
+ lines = [
661
+ "[Phronomy] Stream callback failed",
662
+ "event=#{event.type.inspect}",
663
+ "agent_invocation_id=#{invocation_id || "unknown"}",
664
+ "policy=#{callback_error_policy.inspect}",
665
+ "error=#{callback_error.class}: #{callback_error.message}"
666
+ ]
667
+ Array(callback_error.backtrace).each { |line| lines << " #{line}" }
668
+ _warn_stream_callback_error(lines.join("\n"))
669
+ rescue => reporting_error
670
+ _kernel_warn_safely(
671
+ "[Phronomy] Failed to report stream callback error: " \
672
+ "#{reporting_error.class}: #{reporting_error.message}"
673
+ )
674
+ end
675
+
676
+ def _warn_stream_callback_error(message)
677
+ logger = Phronomy.configuration.logger
678
+ unless logger
679
+ _kernel_warn_safely(message)
680
+ return
920
681
  end
921
- rescue => e
922
- result_task.backend.unblock(nil, e)
923
- result_task.transition!(:failed, error: e)
682
+
683
+ logger.warn(message)
684
+ rescue => logger_error
685
+ _kernel_warn_safely(
686
+ "#{message}\n" \
687
+ "[Phronomy] Logger failed while reporting a stream callback error: " \
688
+ "#{logger_error.class}: #{logger_error.message}"
689
+ )
924
690
  end
925
691
 
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]
692
+ def _kernel_warn_safely(message)
693
+ Kernel.warn(message)
694
+ rescue
695
+ nil
696
+ end
697
+
698
+ # Continues a suspended AgentInvocation. The parent session is registered
699
+ # asynchronously; this method is only the synchronous wrapper.
933
700
  # @return [Hash]
934
701
  # @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)
947
- else
948
- _resume_fsm(ctx, :reject)
949
- end
702
+ def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
703
+ _check_scheduler_reentrancy(:approve, :approve_async)
704
+ approve_async(
705
+ agent_invocation_id,
706
+ approval_request_id: approval_request_id,
707
+ approved: approved,
708
+ config: config
709
+ ).wait_result
950
710
  end
951
711
  public :approve
952
712
 
953
- # Builds and runs a resume FSMSession for the given context and event.
954
- # @api private
955
- def _resume_fsm(ctx, event)
956
- runtime = Phronomy::Runtime.instance
957
- 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,
963
- runtime: runtime
713
+ # Continues a suspended AgentInvocation without blocking the caller.
714
+ #
715
+ # This method is safe to call from an EventLoop stream callback. The
716
+ # returned Task completes when the resumed AgentInvocation finishes,
717
+ # suspends again, or fails.
718
+ # @return [Phronomy::Task]
719
+ # @api public
720
+ def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
721
+ result_task = Phronomy::Task.deferred(
722
+ name: "agent-approval-resume:#{agent_invocation_id}"
964
723
  )
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)
724
+
725
+ begin
726
+ entry = Agent::AgentInvocationRegistry.consume_approval(
727
+ agent_invocation_id, approval_request_id
728
+ )
729
+ unless entry
730
+ raise ArgumentError,
731
+ "No pending approval found for AgentInvocation #{agent_invocation_id}"
732
+ end
733
+
734
+ _start_approval_resume(
735
+ result_task,
736
+ entry.invocation,
737
+ approved: approved,
738
+ config: config
739
+ )
740
+ rescue => e
741
+ _fail_result_task(result_task, e)
742
+ end
743
+
744
+ result_task
745
+ end
746
+ public :approve_async
747
+
748
+ def _extract_invoke_result(invocation)
749
+ if invocation.phase == :suspended
750
+ request = invocation.approval_request
751
+ Agent::AgentInvocationRegistry.store_suspended(invocation, request)
752
+ _dispatch_tool_approval_notification(invocation, request)
753
+ {
754
+ suspended: true,
755
+ agent_invocation_id: invocation.id,
756
+ approval_request: request,
757
+ messages: invocation.messages
758
+ }
759
+ elsif invocation.input_blocked? || invocation.output_blocked?
760
+ raise invocation.block_error
761
+ elsif invocation.error
762
+ raise invocation.error
763
+ elsif invocation.rejected
764
+ {rejected: true, messages: invocation.messages}
765
+ else
766
+ {output: invocation.output, messages: invocation.messages, usage: invocation.usage}
767
+ end
969
768
  end
970
769
 
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}
770
+ def _dispatch_tool_approval_notification(invocation, request)
771
+ listener = invocation.approval_listener
772
+ return unless listener
773
+
774
+ Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
775
+ listener.call(request)
776
+ end
777
+ rescue => e
778
+ message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
779
+ if Phronomy.configuration.logger
780
+ Phronomy.configuration.logger.warn(message)
983
781
  else
984
- {output: ctx.output, messages: ctx.messages, usage: ctx.usage}
782
+ Kernel.warn(message)
783
+ end
784
+ end
785
+
786
+ def _approval_configuration_mutex
787
+ return @approval_configuration_mutex if @approval_configuration_mutex
788
+
789
+ APPROVAL_CONFIGURATION_INIT_MUTEX.synchronize do
790
+ @approval_configuration_mutex ||= Mutex.new
791
+ end
792
+ end
793
+
794
+ def _approval_configuration_snapshot(invocation_listener = nil)
795
+ _approval_configuration_mutex.synchronize do
796
+ {
797
+ policy: @tool_approval_policy,
798
+ listener: invocation_listener || @tool_approval_listener
799
+ }.freeze
985
800
  end
986
801
  end
987
802
 
@@ -1002,22 +817,6 @@ module Phronomy
1002
817
  context[:messages].each { |msg| chat.messages << msg }
1003
818
  end
1004
819
 
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
820
  # Builds a TokenBudget for this agent's model if possible.
1022
821
  # When context_window is set at the class level, that value is used directly
1023
822
  # (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
@@ -1065,7 +864,7 @@ module Phronomy
1065
864
  t = self.class.temperature
1066
865
  parallel_class = build_chat_class
1067
866
  chat = if parallel_class
1068
- parallel_class.new(max_parallel_tools: self.class.max_parallel_tools, **opts)
867
+ parallel_class.new(**opts)
1069
868
  else
1070
869
  RubyLLM.chat(**opts)
1071
870
  end
@@ -1126,33 +925,12 @@ module Phronomy
1126
925
  raise Phronomy::CancellationError, message if ct&.cancelled?
1127
926
  end
1128
927
 
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.
928
+ # Builds the final Tool class to register with RubyLLM. Alias and Tool
929
+ # result filters remain wrappers; authorization is handled only by
930
+ # ToolInvocation before Tool#call begins.
1149
931
  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
932
  return tool_class unless tool_class.is_a?(Class)
1154
933
 
1155
- # Step 1: apply alias if needed.
1156
934
  resolved = if (alias_name = self.class.tool_aliases[tool_class])
1157
935
  parent_description = tool_class.description
1158
936
  Class.new(tool_class) do
@@ -1163,62 +941,17 @@ module Phronomy
1163
941
  tool_class
1164
942
  end
1165
943
 
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
944
  result_filters = _tool_result_filters_for(tool_class)
1214
945
  return resolved if result_filters.empty?
1215
946
 
1216
- effective_name4 = resolved.new.name
947
+ effective_name = resolved.new.name
1217
948
  Class.new(resolved) do
1218
- tool_name effective_name4
949
+ tool_name effective_name
1219
950
  define_method(:call) do |args, **kwargs|
1220
951
  result = super(args, **kwargs)
1221
- result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
952
+ result_filters.inject(result) { |val, filter|
953
+ filter.call(val, tool_name: name, args: args)
954
+ }
1222
955
  end
1223
956
  end
1224
957
  end