phronomy 0.11.0 → 0.11.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +192 -78
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +337 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +1 -1
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e73d1b49c4638232021afad5cc807466c4bb7b2cfcfe60446930288e868a3db9
4
- data.tar.gz: 624e783c532fc2ea009db99a7bda4a1308f06019bc0e776a78d206b70031b352
3
+ metadata.gz: 2a6262c12bc063819c0e1656699f47666b06b182747d6ce85843d2a74988c358
4
+ data.tar.gz: 6529858071913e61a6a840fe03e36620322bfddd50e415b054314124f9b7fcd1
5
5
  SHA512:
6
- metadata.gz: 0ce003a8f0b0c85cd29a744b86ee89ad7414c511ea46c1ad446227824100174b6bfae76de8a4caa32428a50f77606af8006c17ae307eb514692a92829331570c
7
- data.tar.gz: 10a3e965b58c730d5ba21eefc3c7151725a26e6accc24dbde04dd91546aa6c65bea801faae0fe5ef1908995a78c259159712c0f8dffe3f19938fa64b14988c16
6
+ metadata.gz: 6b1441aab8f7197c23bd84fea750388280ee00036e729f47de13b0113249261f72fb10f01b5e9a78fb2464fcb0ebac5fd07d91eef7d4b5e83fb973b463c0630e
7
+ data.tar.gz: d9c0401177c9d59900543c7ff5bbae698401fa0dc5ca7e33272458f41eddff2fa61caa501a5735b273f59b44de96e1fc70b0c5a648bb159066f7facd18b5aa82
data/README.md CHANGED
@@ -603,14 +603,16 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
603
603
  | Context | Recommended API |
604
604
  |---------|----------------|
605
605
  | Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
606
- | Inside a `Runtime#spawn` block, `TaskGroup`, Workflow action, Tool `execute` | `agent.invoke_async(input).await` — non-blocking within the scheduler |
606
+ | Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
607
+ | Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
607
608
 
608
609
  ### Why this matters
609
610
 
610
611
  `invoke` is a synchronous wrapper that calls `invoke_async` and then _blocks_ the calling
611
- thread until the task completes. When called from **inside** an active scheduler task, the
612
- calling task blocks the scheduler thread, preventing other tasks from making progress — a
613
- hidden deadlock when all scheduler threads are occupied.
612
+ thread until the task completes. It is intended for top-level application threads such as
613
+ Rails controller actions, CLI scripts, or background jobs. Inside EventLoop-driven
614
+ workflow actions, return a Task and let `Task#map` / `Task#on_complete` drive the next
615
+ state transition instead of waiting inside the EventLoop thread.
614
616
 
615
617
  ### Runtime guard
616
618
 
@@ -630,14 +632,21 @@ You can also query the current context directly:
630
632
  Phronomy::Runtime.in_scheduler_context? # => true if called from inside a task
631
633
  ```
632
634
 
633
- ### Migration: invokeinvoke_async
635
+ ### Migration: blocking wait Task mapping
634
636
 
635
637
  ```ruby
636
- # Before (blocks scheduler if called from inside a task)
638
+ # Top-level synchronous use
637
639
  result = my_agent.invoke("Hello")
638
640
 
639
- # After (safe inside tasks and TaskGroups)
640
- result = my_agent.invoke_async("Hello").await
641
+ # Explicit async from top-level code
642
+ result = my_agent.invoke_async("Hello").wait_result
643
+
644
+ # Workflow action / EventLoop-safe use
645
+ NODE = ->(ctx) {
646
+ my_agent.invoke_async("Hello").map { |result|
647
+ ctx.merge(answer: result[:output])
648
+ }
649
+ }
641
650
  ```
642
651
 
643
652
  ### :immediate backend (synchronous / test mode)
@@ -39,6 +39,7 @@ class BenchStubChat
39
39
  def with_temperature(_) = self
40
40
  def with_cache_instructions(_) = self
41
41
  def with_output_schema(_) = self
42
+ def on_tool_call(&) = self
42
43
  def last_message = @response
43
44
 
44
45
  def ask(_)
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require_relative "checkpoint_store"
5
4
  require_relative "concerns/retryable"
6
5
  require_relative "concerns/filterable"
7
6
  require_relative "concerns/before_completion"
8
- require_relative "concerns/suspendable"
9
7
  require_relative "concerns/error_translation"
10
8
 
11
9
  module Phronomy
@@ -36,7 +34,6 @@ module Phronomy
36
34
  include Concerns::Retryable
37
35
  include Concerns::Filterable
38
36
  include Concerns::BeforeCompletion
39
- include Concerns::Suspendable
40
37
  include Concerns::ErrorTranslation
41
38
 
42
39
  class << self
@@ -376,25 +373,21 @@ module Phronomy
376
373
  end
377
374
  end
378
375
 
379
- # Resumes a suspended invocation identified by +checkpoint+ without
380
- # requiring the original agent instance to be kept in memory.
376
+ # Continues a suspended invocation identified by +session_id+.
381
377
  #
382
- # Validates that the checkpoint was created by this agent class, then
383
- # instantiates a fresh agent and delegates to {Suspendable#resume}.
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.
384
381
  #
385
- # @param checkpoint [Phronomy::Agent::Checkpoint]
382
+ # @param session_id [String] the session_id from the suspended result hash
386
383
  # @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
387
384
  # @param config [Hash] same runtime options as {#invoke}
388
385
  # @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
389
386
  # another approval-required tool is encountered during continuation
390
- # @raise [ArgumentError] when +checkpoint.agent_class+ does not match this class
387
+ # @raise [ArgumentError] when no suspended session matches +session_id+
391
388
  # @api public
392
- def resume(checkpoint, approved:, config: {})
393
- if checkpoint.agent_class && checkpoint.agent_class != name
394
- raise ArgumentError,
395
- "checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
396
- end
397
- new.resume(checkpoint, approved: approved, config: config)
389
+ def approve(session_id, approved: true, config: {})
390
+ new.approve(session_id, approved: approved, config: config)
398
391
  end
399
392
  end
400
393
 
@@ -416,6 +409,35 @@ module Phronomy
416
409
  @_handoff_tools || []
417
410
  end
418
411
 
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) }
424
+ # @return [self]
425
+ # @api public
426
+ def on_approval_required(&block)
427
+ @approval_handler = block
428
+ self
429
+ end
430
+
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
+
419
441
  # Invokes the agent with the given input and returns a result Hash.
420
442
  # Applies the retry policy configured via {.retry_policy} when transient
421
443
  # errors occur. {Phronomy::FilterBlockError} is never retried.
@@ -468,7 +490,10 @@ module Phronomy
468
490
 
469
491
  timeout_sec = self.class.invoke_timeout
470
492
  unless timeout_sec
471
- return invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
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
472
497
  end
473
498
 
474
499
  # invoke_timeout: create a CancellationScope with deadline, pass its token
@@ -482,7 +507,7 @@ module Phronomy
482
507
  # Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
483
508
  completion_queue = Phronomy::Concurrency::AsyncQueue.new
484
509
  Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
485
- completion_queue.push(task.await)
510
+ completion_queue.push(task.wait_result)
486
511
  rescue => e
487
512
  completion_queue.push(e)
488
513
  end
@@ -508,7 +533,7 @@ module Phronomy
508
533
  #
509
534
  # @example
510
535
  # task = agent.invoke_async("Hello!")
511
- # result = task.await # => { output: "...", messages: [...], usage: ... }
536
+ # result = task.wait_result # => { output: "...", messages: [...], usage: ... }
512
537
  #
513
538
  # @param input [String, Hash]
514
539
  # @param messages [Array]
@@ -524,11 +549,11 @@ module Phronomy
524
549
  bp = Phronomy.configuration.backpressure
525
550
  on_full = (bp == :raise) ? :reject : (bp || :wait)
526
551
  bp_timeout = Phronomy.configuration.backpressure_timeout
552
+ result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
527
553
  gate = Phronomy::Runtime.instance.gate(:agent)
528
- Phronomy::Runtime.instance.spawn(name: "agent-#{(self.class.name || "anonymous").downcase}-async") do
529
- gate.acquire(on_full: on_full, timeout: bp_timeout) do
530
- _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
531
- end
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
532
557
  end
533
558
  end
534
559
 
@@ -809,35 +834,154 @@ module Phronomy
809
834
  end
810
835
  protected :instance_knowledge_chunks
811
836
 
812
- # Performs a single (non-retried) invocation. Extracted so that #invoke can
813
- # wrap it in a retry loop without duplicating the LLM interaction logic.
814
- def invoke_once(input, messages: [], thread_id: nil, config: {})
815
- trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
816
- input = run_input_filters!(input)
817
-
818
- user_message = extract_message(input)
819
- chat = build_chat
820
- context = build_context(
821
- input,
822
- messages: messages, thread_id: thread_id, config: config,
823
- budget: build_token_budget, instruction: build_instructions(input),
824
- tools: self.class.tools + _handoff_tools
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.
841
+ # @api private
842
+ def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
843
+ 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
+ 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
825
854
  )
826
- _apply_context_to_chat(chat, context)
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
827
862
 
828
- run_before_completion_hooks!(chat, config)
829
- _register_suspension_hook!(chat)
830
- check_cancellation!(config, "invocation cancelled before LLM call")
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(
873
+ agent: self,
874
+ input: input,
875
+ messages: messages,
876
+ config: effective_config
877
+ )
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
899
+ )
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
+ end
922
+ end
923
+ end
924
+ rescue => e
925
+ result_task.backend.unblock(nil, e)
926
+ result_task.transition!(:failed, error: e)
927
+ end
831
928
 
832
- result, usage = _complete_with_suspension_guard(
833
- chat, user_message, config,
834
- thread_id: thread_id, original_input: input
835
- )
836
- next [result, usage] if result[:suspended]
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)
950
+ else
951
+ _resume_fsm(ctx, :reject)
952
+ end
953
+ end
954
+ public :approve
837
955
 
838
- filtered_output = run_output_filters!(result[:output])
839
- result = result.merge(output: filtered_output) unless filtered_output.equal?(result[:output])
840
- [result, usage]
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
964
+ )
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)
969
+ end
970
+
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}
983
+ else
984
+ {output: ctx.output, messages: ctx.messages, usage: ctx.usage}
841
985
  end
842
986
  end
843
987
 
@@ -858,36 +1002,6 @@ module Phronomy
858
1002
  context[:messages].each { |msg| chat.messages << msg }
859
1003
  end
860
1004
 
861
- # Submits the LLM call via LLMAdapter and handles SuspendSignal.
862
- # Sets/clears the chat cancellation token around the call so that
863
- # ParallelToolChat can observe cancellation without Thread.current.
864
- # Returns [result_hash, usage_or_nil].
865
- def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
866
- chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
867
- begin
868
- adapter = Phronomy.configuration.llm_adapter
869
- response = adapter.complete_async(chat, user_message, config: config).await
870
- rescue SuspendSignal => signal
871
- checkpoint = Checkpoint.new(
872
- checkpoint_id: SecureRandom.uuid,
873
- agent_class: self.class.name,
874
- requested_at: Time.now.utc,
875
- thread_id: thread_id,
876
- original_input: original_input,
877
- messages: chat.messages.dup,
878
- pending_tool_name: signal.tool_name,
879
- pending_tool_args: signal.args,
880
- pending_tool_call_id: signal.tool_call_id
881
- )
882
- return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
883
- ensure
884
- chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
885
- end
886
- output = response.content
887
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
888
- [{output: output, messages: chat.messages, usage: usage}, usage]
889
- end
890
-
891
1005
  def _drain_stream(chat, user_message, config, &block)
892
1006
  adapter = Phronomy.configuration.llm_adapter
893
1007
  chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
@@ -900,7 +1014,7 @@ module Phronomy
900
1014
  check_cancellation!(config, "invocation cancelled during streaming")
901
1015
  end
902
1016
 
903
- response = pending.await
1017
+ response = pending.blocking_wait
904
1018
  [response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
905
1019
  end
906
1020
 
@@ -36,7 +36,7 @@ module Phronomy
36
36
  when RubyLLM::Error
37
37
  raise Phronomy::TransportError, error.message
38
38
  else
39
- raise # bare re-raise preserves $! and its backtrace unchanged
39
+ raise error # preserve original class, message, and backtrace
40
40
  end
41
41
  end
42
42
  end
@@ -35,7 +35,11 @@ module Phronomy
35
35
  # @return [Hash, nil]
36
36
  attr_reader :_retry_policy
37
37
 
38
- # Injectable sleep callable for testing (shared with Tool::Base pattern).
38
+ # Injectable sleep callable for testing.
39
+ # In the EventLoop-driven invoke path, actual delay is handled by
40
+ # timer_queue (non-blocking); _sleep_proc is called only as an
41
+ # instrumentation hook. The default is a no-op; tests override it
42
+ # with a spy lambda to record sleep durations.
39
43
  # @return [#call]
40
44
  # @api private
41
45
  def _sleep_proc
@@ -59,7 +63,7 @@ module Phronomy
59
63
  policy = self.class._retry_policy
60
64
  attempt = 0
61
65
  begin
62
- invoke_once(input, messages: messages, thread_id: thread_id, config: config)
66
+ _invoke_via_fsm(input, messages: messages, thread_id: thread_id, config: config)
63
67
  rescue Phronomy::FilterBlockError
64
68
  raise
65
69
  rescue Phronomy::CancellationError
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Holds the mutable state for a single Agent#invoke execution.
6
+ #
7
+ # An InvocationContext is created at the start of each invoke call and
8
+ # passed through every FSM state as the context object. It plays the same
9
+ # role as WorkflowContext does for Workflow executions.
10
+ #
11
+ # Fields:
12
+ # input — the (possibly filter-transformed) user input
13
+ # messages — conversation history (Array of RubyLLM::Message)
14
+ # chat — the RubyLLM::Chat object built during :building_context
15
+ # output — the final LLM text response
16
+ # usage — Phronomy::TokenUsage after completion
17
+ # tool_call_pending — whether the last LLM response contained a tool call
18
+ # approval_required — whether the pending tool requires human approval
19
+ # input_blocked — whether an input filter called block!
20
+ # output_blocked — whether an output filter called block!
21
+ # block_error — the FilterBlockError raised (for :blocked terminal)
22
+ # pending_tool_call — the intercepted tool_call object (name/arguments/id)
23
+ # user_message_sent — true after the first LLM call (continuation uses nil msg)
24
+ # sync_approval_handler — true when agent has an on_approval_required block
25
+ # session_id — FSM session id (set by set_graph_metadata)
26
+ # phase — terminal FSM phase (set by set_graph_metadata)
27
+ #
28
+ # @api private
29
+ class InvocationContext
30
+ # @return [String, Hash] user input (may be transformed by input filters)
31
+ attr_accessor :input
32
+
33
+ # @return [Array] conversation history
34
+ attr_accessor :messages
35
+
36
+ # @return [Object, nil] RubyLLM::Chat instance
37
+ attr_accessor :chat
38
+
39
+ # @return [String, nil] final LLM output text
40
+ attr_accessor :output
41
+
42
+ # @return [Phronomy::TokenUsage, nil]
43
+ attr_accessor :usage
44
+
45
+ # @return [Boolean] true when calling_llm returned a tool call
46
+ attr_accessor :tool_call_pending
47
+
48
+ # @return [Boolean] true when the pending tool requires human approval
49
+ attr_accessor :approval_required
50
+
51
+ # @return [Boolean] true when an input filter called block!
52
+ attr_accessor :input_blocked
53
+
54
+ # @return [Boolean] true when an output filter called block!
55
+ attr_accessor :output_blocked
56
+
57
+ # @return [Phronomy::FilterBlockError, nil]
58
+ attr_accessor :block_error
59
+
60
+ # @return [Object, nil] intercepted RubyLLM tool_call (name/arguments/id)
61
+ attr_accessor :pending_tool_call
62
+
63
+ # @return [Boolean] true after the first LLM call (continuation passes nil msg)
64
+ attr_accessor :user_message_sent
65
+
66
+ # @return [Boolean] true when Agent.approve was called (signals executing_tool
67
+ # to execute the pending tool rather than re-suspending)
68
+ attr_accessor :approved
69
+
70
+ # @return [Boolean] true when the pending tool call was rejected via
71
+ # Agent.approve(session_id, approved: false)
72
+ attr_accessor :rejected
73
+
74
+ # @return [String, nil] FSM session id (set by set_graph_metadata)
75
+ attr_reader :session_id
76
+
77
+ # @return [Symbol, nil] terminal FSM phase (set by set_graph_metadata)
78
+ attr_reader :phase
79
+
80
+ # @return [Boolean] true when agent has a synchronous on_approval_required handler
81
+ attr_reader :sync_approval_handler
82
+
83
+ # @return [Phronomy::Agent::Base] the agent instance driving this invocation
84
+ attr_reader :agent
85
+
86
+ # @return [Hash] the config hash passed to invoke
87
+ attr_reader :config
88
+
89
+ # @return [String, nil] thread_id from config
90
+ attr_reader :thread_id
91
+
92
+ # @param agent [Phronomy::Agent::Base]
93
+ # @param input [String, Hash]
94
+ # @param messages [Array]
95
+ # @param config [Hash]
96
+ # @api private
97
+ def initialize(agent:, input:, messages:, config:)
98
+ @agent = agent
99
+ @input = input
100
+ @messages = Array(messages)
101
+ @config = config
102
+ @thread_id = config[:thread_id]
103
+ @chat = nil
104
+ @output = nil
105
+ @usage = nil
106
+ @tool_call_pending = false
107
+ @approval_required = false
108
+ @input_blocked = false
109
+ @output_blocked = false
110
+ @block_error = nil
111
+ @pending_tool_call = nil
112
+ @user_message_sent = false
113
+ @approved = false
114
+ @rejected = false
115
+ @sync_approval_handler = !agent.instance_variable_get(:@approval_handler).nil?
116
+ @session_id = nil
117
+ @phase = nil
118
+ end
119
+
120
+ # Called by FSMSession#finish! and FSMSession#halt! to record the
121
+ # terminal/halted phase and the session id.
122
+ # Mirrors the WorkflowContext interface so FSMSession works with both.
123
+ # @param thread_id [String, nil]
124
+ # @param phase [Symbol, nil]
125
+ # @return [void]
126
+ # @api private
127
+ def set_graph_metadata(thread_id: nil, phase: nil)
128
+ @session_id = thread_id if thread_id
129
+ @phase = phase
130
+ end
131
+
132
+ # Guard helpers used by PhaseMachineBuilder transitions.
133
+
134
+ # @return [Boolean]
135
+ # @api private
136
+ def input_passed?
137
+ !@input_blocked
138
+ end
139
+
140
+ # @return [Boolean]
141
+ # @api private
142
+ def input_blocked?
143
+ @input_blocked
144
+ end
145
+
146
+ # @return [Boolean]
147
+ # @api private
148
+ def output_passed?
149
+ !@output_blocked
150
+ end
151
+
152
+ # @return [Boolean]
153
+ # @api private
154
+ def output_blocked?
155
+ @output_blocked
156
+ end
157
+
158
+ # @return [Boolean]
159
+ # @api private
160
+ def tool_call_pending?
161
+ @tool_call_pending
162
+ end
163
+
164
+ # @return [Boolean]
165
+ # @api private
166
+ def approval_required?
167
+ @approval_required
168
+ end
169
+ end
170
+ end
171
+ end