phronomy 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  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/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -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,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,45 +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
- bp = Phronomy.configuration.backpressure
550
- on_full = (bp == :raise) ? :reject : (bp || :wait)
551
- bp_timeout = Phronomy.configuration.backpressure_timeout
552
477
  result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
553
- gate = Phronomy::Runtime.instance.gate(:agent)
554
- gate.acquire(on_full: on_full, timeout: bp_timeout) do
555
- _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
556
- return result_task
557
- end
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
+ )
484
+ result_task
558
485
  end
559
486
 
560
- # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
561
- # as they are produced by the underlying LLM.
487
+ # Invokes this agent asynchronously and delivers stream events from the
488
+ # Runtime-owned EventLoop thread.
562
489
  #
563
- # Events emitted (in order):
564
- # :token — each content delta from the LLM
565
- # :tool_call — when the LLM requests a tool
566
- # :tool_result — after a tool completes
567
- # :done — final event carrying output, messages, and usage
568
- # :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.
569
492
  #
570
- # @param input [String, Hash] same as #invoke
571
- # @param messages [Array<RubyLLM::Message>] same as #invoke
572
- # @param thread_id [String, nil] same as #invoke
573
- # @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.
574
524
  # @yield [Phronomy::Agent::StreamEvent]
575
- # @return [Hash] { output:, messages:, usage: } — same as #invoke
525
+ # @return [Hash] same result shape as #invoke
576
526
  # @api public
577
- def stream(input, messages: [], thread_id: nil, config: {}, &block)
578
- 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
579
530
 
580
- _stream_impl(input, messages: messages, thread_id: thread_id, config: config, &block)
581
- rescue => e
582
- block&.call(StreamEvent.new(type: :error, payload: {error: e}))
583
- 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
584
547
  end
585
548
 
586
549
  # @deprecated The context version cache has been removed. Returns nil.
@@ -610,12 +573,18 @@ module Phronomy
610
573
  [effective_thread_id, effective_config]
611
574
  end
612
575
 
613
- 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
+
614
583
  return unless Phronomy::Task.current
615
584
 
616
- msg = "#{self.class.name}#invoke called from inside a scheduler task. " \
585
+ msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
617
586
  "This blocks the scheduler until the inner invocation completes, preventing " \
618
- "other tasks from making progress. Use invoke_async + await instead."
587
+ "other tasks from making progress. Use #{async_method} + await instead."
619
588
  if Phronomy.configuration.strict_runtime_guards
620
589
  raise Phronomy::SchedulerReentrancyError, msg
621
590
  elsif Phronomy.configuration.logger
@@ -625,48 +594,6 @@ module Phronomy
625
594
  end
626
595
  end
627
596
 
628
- # Streaming implementation for #stream.
629
- def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
630
- trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
631
- input = run_input_filters!(input)
632
-
633
- chat = build_chat
634
- user_message = extract_message(input)
635
- context = build_context(
636
- input,
637
- messages: messages,
638
- thread_id: thread_id,
639
- config: config,
640
- budget: build_token_budget,
641
- instruction: build_instructions(input),
642
- tools: self.class.tools + _handoff_tools
643
- )
644
- _apply_context_to_chat(chat, context)
645
-
646
- current_tool_call = nil
647
- chat.on_tool_call do |tool_call|
648
- current_tool_call = tool_call
649
- block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
650
- end
651
- chat.on_tool_result do |tool_result|
652
- block.call(StreamEvent.new(type: :tool_result, payload: {
653
- tool_call_id: current_tool_call&.id,
654
- tool_name: current_tool_call&.name,
655
- tool_result: tool_result
656
- }))
657
- end
658
-
659
- run_before_completion_hooks!(chat, config)
660
-
661
- output, usage = _drain_stream(chat, user_message, config, &block)
662
- output = run_output_filters!(output)
663
-
664
- result = {output: output, messages: chat.messages, usage: usage}
665
- block.call(StreamEvent.new(type: :done, payload: result))
666
- [result, usage]
667
- end
668
- end
669
-
670
597
  # Assembles the LLM context (system prompt + conversation messages)
671
598
  # for a single invocation. Subclasses may override this method to
672
599
  # inject custom context editing logic without having to override
@@ -834,154 +761,398 @@ module Phronomy
834
761
  end
835
762
  protected :instance_knowledge_chunks
836
763
 
837
- # Runs the agent invocation through the FSM-based execution engine.
838
- # Called by Retryable#_invoke_impl (which wraps it in a retry loop).
839
- # Returns the result hash: { output:, messages:, usage: } on success,
840
- # 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.
841
767
  # @api private
842
- 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)
843
770
  effective_config = thread_id ? config.merge(thread_id: thread_id) : config
844
- # Fail fast when the token is already cancelled before any LLM call.
845
771
  check_cancellation!(effective_config, "invocation cancelled")
846
- # Ensure EventLoop is running. start is idempotent when already alive.
847
- Phronomy::EventLoop.instance.start
848
- trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
849
- session = Agent::InvocationSession.build(
850
- agent: self,
851
- input: input,
852
- messages: messages,
853
- config: effective_config
854
- )
855
- completion_queue = Phronomy::EventLoop.instance.register(session)
856
- ctx = completion_queue.pop
857
- raise ctx if ctx.is_a?(Exception)
858
- result = _extract_invoke_result(ctx, session.id)
859
- [result, result[:usage]]
860
- end
861
- end
862
-
863
- # Starts a single invocation attempt and wires retry/translation onto result_task.
864
- # Non-blocking: registers with EventLoop and returns immediately.
865
- # On error, retries via timer_queue when policy allows; otherwise translates
866
- # and resolves result_task as failed.
867
- # @api private
868
- def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
869
- effective_config = thread_id ? config.merge(thread_id: thread_id) : config
870
- check_cancellation!(effective_config, "invocation cancelled")
871
- Phronomy::EventLoop.instance.start
872
- session = Agent::InvocationSession.build(
772
+ runtime = Phronomy::Runtime.instance
773
+ event_loop = runtime.event_loop
774
+ session = Agent::AgentInvocationSessionBuilder.build(
873
775
  agent: self,
874
776
  input: input,
875
777
  messages: messages,
876
- config: effective_config
778
+ config: effective_config,
779
+ approval_policy: approval_snapshot[:policy],
780
+ approval_listener: approval_snapshot[:listener],
781
+ mode: mode,
782
+ on_event: on_event,
783
+ runtime: runtime
877
784
  )
878
- source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
879
- Phronomy::EventLoop.instance.register(session, completion: source_task)
880
- session_id = session.id
881
- policy = self.class._retry_policy
882
-
883
- source_task.on_complete do |ctx, error|
884
- retriable = error &&
885
- !error.is_a?(Phronomy::FilterBlockError) &&
886
- !error.is_a?(Phronomy::CancellationError) &&
887
- policy && attempt < policy[:times]
888
-
889
- if retriable
890
- wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
891
- # Call _sleep_proc for instrumentation (test spy records the duration;
892
- # in production this is a no-op since timer_queue handles the actual delay).
893
- self.class._sleep_proc.call(wait) if wait > 0
894
- do_retry = -> {
895
- _start_invoke_attempt(
896
- result_task, input,
897
- messages: messages, thread_id: thread_id, config: config,
898
- attempt: attempt + 1
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.
803
+ event_loop.register(session, completion: source_task)
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
899
860
  )
900
- }
901
- if wait > 0
902
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
903
- else
904
- do_retry.call
905
- end
906
- elsif error
907
- begin
908
- translate_and_reraise!(error)
909
- rescue => translated
910
- result_task.backend.unblock(nil, translated)
911
- result_task.transition!(:failed, error: translated)
912
- end
913
- else
914
- begin
915
- result = _extract_invoke_result(ctx, session_id)
916
- result_task.backend.unblock(result, nil)
917
- result_task.transition!(:completed, value: result)
918
- rescue => e
919
- result_task.backend.unblock(nil, e)
920
- result_task.transition!(:failed, error: e)
921
861
  end
922
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)
923
898
  end
924
- rescue => e
925
- result_task.backend.unblock(nil, e)
926
- result_task.transition!(:failed, error: e)
927
899
  end
928
900
 
929
- # Continues a suspended invocation identified by +session_id+.
930
- # When +approved: true+, executes the pending tool and continues.
931
- # When +approved: false+, rejects the tool call and ends the invocation.
932
- #
933
- # @param session_id [String]
934
- # @param approved [Boolean]
935
- # @param config [Hash]
936
- # @return [Hash]
937
- # @api public
938
- def approve(session_id, approved: true, config: {})
939
- ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
940
- raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
941
-
942
- # Reset approval_required so executing_tool_action proceeds instead of
943
- # re-suspending when called after the :approve FSM transition.
944
- ctx.approval_required = false
945
- ctx.approved = true if approved # signals executing_tool to run the tool
946
- ctx.rejected = !approved # signals _extract_invoke_result for rejection
947
-
948
- if approved
949
- _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
+ )
950
907
  else
951
- _resume_fsm(ctx, :reject)
908
+ StreamEvent.new(type: :done, payload: result)
952
909
  end
953
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
954
992
  public :approve
955
993
 
956
- # Builds and runs a resume FSMSession for the given context and event.
957
- # @api private
958
- def _resume_fsm(ctx, event)
959
- session = Agent::InvocationSession.build_for_resume(
960
- agent: self,
961
- context: ctx,
962
- resume_event: event,
963
- resume_phase: :awaiting_approval
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}"
964
1004
  )
965
- completion_queue = Phronomy::EventLoop.instance.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)
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
969
1026
  end
1027
+ public :approve_async
970
1028
 
971
- # Interprets the InvocationContext after FSM completion/halt and returns
972
- # the appropriate result hash or raises the block error.
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.
973
1032
  # @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}
1033
+ def _start_approval_resume(result_task, invocation, approved:, config:)
1034
+ invocation.merge_config!(config)
1035
+ invocation.begin_approval_resume!(approved: approved)
1036
+ runtime = Phronomy::Runtime.instance
1037
+ event_loop = runtime.event_loop
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,
1045
+ runtime: runtime
1046
+ )
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
1085
+ end
1086
+
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}
983
1104
  else
984
- {output: ctx.output, messages: ctx.messages, usage: ctx.usage}
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)
1137
+ else
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