phronomy 0.5.4 → 0.7.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +379 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +262 -48
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +281 -13
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +180 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +32 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/context.rb +0 -1
  53. data/lib/phronomy/embeddings/base.rb +5 -2
  54. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  55. data/lib/phronomy/eval/comparison.rb +2 -0
  56. data/lib/phronomy/eval/dataset.rb +4 -0
  57. data/lib/phronomy/eval/metrics.rb +6 -0
  58. data/lib/phronomy/eval/runner.rb +2 -0
  59. data/lib/phronomy/eval/scorer/base.rb +1 -0
  60. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  61. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  62. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  63. data/lib/phronomy/event.rb +14 -0
  64. data/lib/phronomy/event_loop.rb +254 -0
  65. data/lib/phronomy/fsm_session.rb +201 -0
  66. data/lib/phronomy/generator_verifier.rb +24 -22
  67. data/lib/phronomy/guardrail/base.rb +3 -0
  68. data/lib/phronomy/guardrail.rb +0 -1
  69. data/lib/phronomy/knowledge_source/base.rb +6 -2
  70. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  71. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  72. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  73. data/lib/phronomy/loader/base.rb +1 -0
  74. data/lib/phronomy/loader/csv_loader.rb +2 -0
  75. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  76. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  77. data/lib/phronomy/output_parser/base.rb +1 -0
  78. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  79. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  80. data/lib/phronomy/prompt_template.rb +5 -0
  81. data/lib/phronomy/runnable.rb +20 -3
  82. data/lib/phronomy/splitter/base.rb +2 -0
  83. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  84. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  85. data/lib/phronomy/state_store/base.rb +48 -0
  86. data/lib/phronomy/state_store/in_memory.rb +62 -0
  87. data/lib/phronomy/tool/agent_tool.rb +1 -0
  88. data/lib/phronomy/tool/base.rb +189 -27
  89. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  90. data/lib/phronomy/tracing/base.rb +3 -0
  91. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  92. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  93. data/lib/phronomy/vector_store/base.rb +33 -7
  94. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  95. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  96. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  97. data/lib/phronomy/version.rb +1 -1
  98. data/lib/phronomy/workflow.rb +175 -74
  99. data/lib/phronomy/workflow_context.rb +55 -5
  100. data/lib/phronomy/workflow_runner.rb +197 -114
  101. data/lib/phronomy.rb +74 -1
  102. data/scripts/api_snapshot.rb +91 -0
  103. data/scripts/check_api_annotations.rb +68 -0
  104. data/scripts/check_private_enforcement.rb +93 -0
  105. data/scripts/check_readme_runnable.rb +98 -0
  106. data/scripts/run_mutation.sh +46 -0
  107. metadata +50 -6
  108. data/lib/phronomy/context/builder.rb +0 -92
  109. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
  110. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
  111. data/lib/phronomy/guardrail/builtin.rb +0 -16
@@ -7,6 +7,7 @@ module Phronomy
7
7
  #
8
8
  # Included in {Phronomy::Agent::Base}. The retry loop wraps the full
9
9
  # #invoke_once call; {Phronomy::GuardrailError} is never retried.
10
+ # @api private
10
11
  module Retryable
11
12
  def self.included(base)
12
13
  base.extend(ClassMethods)
@@ -25,6 +26,7 @@ module Phronomy
25
26
  # class MyAgent < Phronomy::Agent::Base
26
27
  # retry_policy times: 2, wait: :exponential, base: 1.0
27
28
  # end
29
+ # @api private
28
30
  def retry_policy(times: 0, wait: 0, base: 1.0)
29
31
  @_retry_policy = {times: times, wait: wait, base: base}
30
32
  end
@@ -35,6 +37,7 @@ module Phronomy
35
37
 
36
38
  # Injectable sleep callable for testing (shared with Tool::Base pattern).
37
39
  # @return [#call]
40
+ # @api private
38
41
  def _sleep_proc
39
42
  @_sleep_proc || method(:sleep)
40
43
  end
@@ -48,12 +51,19 @@ module Phronomy
48
51
 
49
52
  # Retry loop for #invoke. Separated so that ReactAgent can override #invoke_once.
50
53
  def _invoke_impl(input, messages: [], thread_id: nil, config: {})
54
+ # Fail fast when the token is already cancelled before any LLM call.
55
+ if (token = config[:cancellation_token]) && token.cancelled?
56
+ raise Phronomy::CancellationError, "invocation cancelled"
57
+ end
58
+
51
59
  policy = self.class._retry_policy
52
60
  attempt = 0
53
61
  begin
54
62
  invoke_once(input, messages: messages, thread_id: thread_id, config: config)
55
63
  rescue Phronomy::GuardrailError
56
64
  raise
65
+ rescue Phronomy::CancellationError
66
+ raise # Never retry after cancellation.
57
67
  rescue
58
68
  if policy && attempt < policy[:times]
59
69
  wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
@@ -61,7 +71,7 @@ module Phronomy
61
71
  attempt += 1
62
72
  retry
63
73
  end
64
- raise
74
+ translate_and_reraise!($!)
65
75
  end
66
76
  end
67
77
 
@@ -70,6 +80,7 @@ module Phronomy
70
80
  # @param base [Float]
71
81
  # @param attempt [Integer]
72
82
  # @return [Float]
83
+ # @api private
73
84
  def compute_agent_retry_wait(strategy, base, attempt)
74
85
  case strategy
75
86
  when :exponential
@@ -8,6 +8,7 @@ module Phronomy
8
8
  # Included in {Phronomy::Agent::Base}. When a tool decorated with
9
9
  # +requires_approval true+ is called and no synchronous approval handler
10
10
  # has been registered, the invocation is suspended and a
11
+ # @api private
11
12
  # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
12
13
  module Suspendable
13
14
  # Registers a callback that is invoked before executing any tool that has
@@ -25,6 +26,7 @@ module Phronomy
25
26
  # agent = MyAgent.new
26
27
  # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
27
28
  # @return [self]
29
+ # @api private
28
30
  def on_approval_required(&block)
29
31
  @approval_handler = block
30
32
  self
@@ -43,6 +45,7 @@ module Phronomy
43
45
  # @param config [Hash] same runtime options as #invoke
44
46
  # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
45
47
  # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
48
+ # @api private
46
49
  def resume(checkpoint, approved:, config: {})
47
50
  # Build a fresh chat with all tools registered.
48
51
  chat = build_chat
@@ -95,6 +98,7 @@ module Phronomy
95
98
  # - none of the agent's tools have requires_approval set.
96
99
  #
97
100
  # @param chat [RubyLLM::Chat]
101
+ # @api private
98
102
  def _register_suspension_hook!(chat)
99
103
  return if @approval_handler
100
104
  return if self.class.tools.none? { |tc| tc.requires_approval }
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # EventLoop-registered execution unit for a single agent invocation.
8
+ #
9
+ # +AgentFSM+ implements the minimal interface expected by {Phronomy::EventLoop}
10
+ # (+#id+, +#start+, +#handle+) so it can be managed alongside
11
+ # {Phronomy::FSMSession} instances. It is *not* a traditional finite-state
12
+ # machine; the name reflects its role in the EventLoop rather than internal
13
+ # state transitions.
14
+ #
15
+ # == Execution model
16
+ #
17
+ # {#start} is called by the EventLoop on the +:start+ event. It immediately
18
+ # returns after spawning a background IO thread that runs the agent's full
19
+ # invocation pipeline (via +_invoke_impl+). The EventLoop thread is never
20
+ # blocked by agent execution.
21
+ #
22
+ # Inside the IO thread, the +:phronomy_agent_parallel_tools+ thread-local
23
+ # flag is set to +true+ so that {Agent::Base#build_chat} returns a
24
+ # {ParallelToolChat} instance, enabling concurrent tool dispatch when the LLM
25
+ # returns multiple tool calls in one response.
26
+ #
27
+ # == Completion events
28
+ #
29
+ # On *success*:
30
+ # - Posts +:finished+ to this FSM's own +#id+ so the EventLoop cleans up
31
+ # its registry entry and unblocks any +completion_queue.pop+ caller.
32
+ # - When +parent_id+ is set (child-FSM pattern), additionally posts
33
+ # +:child_completed+ to +parent_id+, carrying the result hash as the
34
+ # event payload. The parent {FSMSession} must declare an +on:+ transition
35
+ # for +:child_completed+ to advance correctly.
36
+ #
37
+ # On *error*:
38
+ # - Posts +:error+ to this FSM's own +#id+. The EventLoop propagates the
39
+ # exception through the +completion_queue+ so that the original caller of
40
+ # +Agent::Base#invoke+ (in EventLoop mode) receives and re-raises it.
41
+ #
42
+ # == Standalone usage (blocking caller)
43
+ #
44
+ # Phronomy.configure { |c| c.event_loop = true }
45
+ # result = MyAgent.new.invoke("Hello!") # => { output:, messages:, usage: }
46
+ #
47
+ # {Agent::Base#invoke} detects EventLoop mode, creates an +AgentFSM+, registers
48
+ # it via {EventLoop#register}, and blocks the *calling* thread on the returned
49
+ # +completion_queue+ until the agent finishes.
50
+ #
51
+ # == Child-FSM usage (non-blocking, inside a Workflow)
52
+ #
53
+ # state :run_agent
54
+ # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
55
+ # transition from: :run_agent, on: :child_completed, to: :process_result
56
+ #
57
+ # {Agent::Base#run_as_child} creates an +AgentFSM+ with +parent_id+ set to
58
+ # +ctx.thread_id+, registers it with the EventLoop, and returns immediately.
59
+ # The parent {FSMSession} waits for the +:child_completed+ event.
60
+ # @api private
61
+ class FSM
62
+ # @return [String] unique identifier used as the EventLoop target_id
63
+ attr_reader :id
64
+
65
+ # @return [Symbol] current internal phase (:idle, :running)
66
+ attr_reader :current_phase
67
+
68
+ # @param agent [Phronomy::Agent::Base] agent instance to run
69
+ # @param input [String, Hash] user input passed to +invoke_once+
70
+ # @param messages [Array] prior conversation history
71
+ # @param thread_id [String, nil] conversation thread id;
72
+ # auto-generated when nil
73
+ # @param config [Hash] invocation config forwarded to
74
+ # +_invoke_impl+
75
+ # @param parent_id [String, nil] EventLoop id of the parent
76
+ # FSMSession; when set, a
77
+ # +:child_completed+ event is posted
78
+ # on completion
79
+ # @param result_writer [Proc, nil] optional callable invoked with the
80
+ # result hash <b>before</b>
81
+ # +:child_completed+ is posted.
82
+ # Use this to write the agent output
83
+ # back into the parent WorkflowContext.
84
+ # Thread::Queue provides the
85
+ # happens-before guarantee.
86
+ #
87
+ # @example Writing result into context
88
+ # entry :run_agent, ->(ctx) {
89
+ # MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
90
+ # }
91
+ # @api private
92
+ def initialize(agent:, input:, messages: [], thread_id: nil, config: {}, parent_id: nil, result_writer: nil)
93
+ @agent = agent
94
+ @input = input
95
+ @messages = Array(messages).dup
96
+ @thread_id = thread_id || SecureRandom.uuid
97
+ @config = config
98
+ @parent_id = parent_id
99
+ @result_writer = result_writer
100
+ @id = @thread_id
101
+ @current_phase = :idle
102
+ end
103
+
104
+ # Called by {EventLoop} on the +:start+ event.
105
+ # Transitions to +:running+ and spawns the agent IO thread.
106
+ def start
107
+ @current_phase = :running
108
+ spawn_agent_thread
109
+ end
110
+
111
+ # Called by {EventLoop} for external events dispatched to this id.
112
+ # +AgentFSM+ is fully driven by its own IO thread and does not respond
113
+ # to external events after {#start}.
114
+ def handle(_event)
115
+ # No-op: AgentFSM is driven entirely by its IO thread.
116
+ end
117
+
118
+ private
119
+
120
+ # Spawns the background IO thread that runs the agent invocation.
121
+ # Captures all instance variables by value so the thread closure is
122
+ # safe even if the FSM object is modified (though it is not in practice).
123
+ def spawn_agent_thread
124
+ agent = @agent
125
+ input = @input
126
+ messages = @messages
127
+ thread_id = @thread_id
128
+ config = @config
129
+ fsm_id = @id
130
+ parent_id = @parent_id
131
+ result_writer = @result_writer
132
+
133
+ Thread.new do
134
+ # Enable parallel tool dispatch inside this IO thread.
135
+ Thread.current[:phronomy_agent_parallel_tools] = true
136
+ # Forward the concurrency cap to ParallelToolChat.
137
+ Thread.current[:phronomy_max_parallel_tools] =
138
+ agent.class.respond_to?(:max_parallel_tools) ? agent.class.max_parallel_tools : 10
139
+
140
+ begin
141
+ result = agent.send(:_invoke_impl,
142
+ input,
143
+ messages: messages,
144
+ thread_id: thread_id,
145
+ config: config)
146
+
147
+ if parent_id
148
+ # Let the caller write the result into the context BEFORE the
149
+ # parent FSMSession advances. Thread::Queue provides the
150
+ # happens-before guarantee — no Mutex needed.
151
+ result_writer&.call(result)
152
+
153
+ Phronomy::EventLoop.instance.post(
154
+ Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
155
+ )
156
+ end
157
+
158
+ Phronomy::EventLoop.instance.post(
159
+ Phronomy::Event.new(type: :finished, target_id: fsm_id, payload: result)
160
+ )
161
+ rescue => e
162
+ if parent_id
163
+ Phronomy::EventLoop.instance.post(
164
+ Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
165
+ )
166
+ end
167
+
168
+ Phronomy::EventLoop.instance.post(
169
+ Phronomy::Event.new(type: :error, target_id: fsm_id, payload: e)
170
+ )
171
+ ensure
172
+ # Clear the thread-local context cache for this agent so the IO
173
+ # thread's cache does not grow unboundedly across invocations.
174
+ Thread.current[:phronomy_context_version_caches]&.delete(agent.object_id)
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -22,6 +22,7 @@ module Phronomy
22
22
 
23
23
  # @param target_agent [Phronomy::Agent::Base] the agent to hand off to
24
24
  # @param description [String, nil] overrides the auto-generated tool description
25
+ # @api public
25
26
  def initialize(target_agent:, description: nil)
26
27
  @target_agent = target_agent
27
28
  klass_name = target_agent.class.name&.split("::")&.last || "Agent"
@@ -33,6 +34,7 @@ module Phronomy
33
34
 
34
35
  # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
35
36
  # @return [Class<Phronomy::Tool::Base>]
37
+ # @api public
36
38
  def to_tool_class
37
39
  sentinel_value = sentinel
38
40
  tn = tool_name
@@ -46,6 +48,7 @@ module Phronomy
46
48
 
47
49
  # The sentinel string embedded in the tool result.
48
50
  # @return [String]
51
+ # @api public
49
52
  def sentinel
50
53
  "#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
51
54
  end
@@ -55,6 +55,7 @@ module Phronomy
55
55
  # @param on_error [Symbol] +:raise+ (default) re-raises any exception
56
56
  # from the subagent; +:skip+ returns +nil+ so the LLM can decide how to
57
57
  # proceed
58
+ # @api public
58
59
  def self.subagent(name, agent_class, on_error: :raise)
59
60
  tool_class = Class.new(Phronomy::Tool::Base) do
60
61
  tool_name "dispatch_to_#{name}"
@@ -62,7 +63,14 @@ module Phronomy
62
63
  param :input, type: :string, desc: "The task or question for the subagent"
63
64
 
64
65
  define_method(:execute) do |input:|
65
- result = agent_class.new.invoke(input)
66
+ # Inherit the calling orchestrator's thread_id and config when
67
+ # available so that sub-agent spans and memory stay connected.
68
+ ctx = Thread.current[:phronomy_orchestrator_context] || {}
69
+ result = agent_class.new.invoke(
70
+ input,
71
+ thread_id: ctx[:thread_id],
72
+ config: ctx[:config] || {}
73
+ )
66
74
  result[:output]
67
75
  rescue
68
76
  raise if on_error == :raise
@@ -80,6 +88,7 @@ module Phronomy
80
88
  # Returns the subagent registry for this specific class (not inherited).
81
89
  #
82
90
  # @return [Hash{Symbol => Hash}]
91
+ # @api public
83
92
  def self.registered_subagents
84
93
  @registered_subagents ||= {}
85
94
  end
@@ -100,13 +109,28 @@ module Phronomy
100
109
  # @option task [Class] :agent agent class to invoke (required)
101
110
  # @option task [String] :input input string for the agent (required)
102
111
  # @option task [Hash] :config forwarded to +agent#invoke+ (default: +{}+)
103
- # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
112
+ # @option task [String] :thread_id forwarded to +agent#invoke+ (default: nil)
113
+ # @param max_concurrency [Integer, nil] maximum number of concurrent threads;
104
114
  # nil means no limit (all tasks run simultaneously)
105
- # @param on_error [Symbol] +:raise+ or +:skip+
115
+ # @param on_error [Symbol] +:raise+ or +:skip+
116
+ # @param timeout [Numeric, nil] maximum seconds to wait for all workers;
117
+ # nil means wait indefinitely. When the deadline is exceeded,
118
+ # {Phronomy::TimeoutError} is raised and all surviving worker threads are killed.
119
+ # @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
120
+ # token is merged into each task's config (unless the task already sets one) so
121
+ # that every worker agent checks it before making LLM calls.
122
+ # @param force_kill [Boolean] when +true+, surviving worker threads are killed with
123
+ # +Thread#kill+ after the grace period if they do not stop cooperatively. When
124
+ # +false+ (default), workers are asked to stop cooperatively but are never killed;
125
+ # the caller receives {Phronomy::TimeoutError} immediately and abandoned workers
126
+ # discard their results when they eventually finish. +false+ is safer for
127
+ # production because +Thread#kill+ can interrupt +ensure+ blocks.
106
128
  # @return [Array<Hash, nil>] agent results in the same order as +tasks+
107
129
  # @raise [ArgumentError] if +on_error+ is not +:raise+ or +:skip+
108
130
  # @raise [ArgumentError] if +max_concurrency+ is not a positive Integer or nil
109
- def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise)
131
+ # @raise [Phronomy::TimeoutError] if +timeout+ is exceeded
132
+ # @api public
133
+ def dispatch_parallel(*tasks, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
110
134
  unless [:raise, :skip].include?(on_error)
111
135
  raise ArgumentError, "unknown on_error: #{on_error.inspect}"
112
136
  end
@@ -114,7 +138,7 @@ module Phronomy
114
138
  raise ArgumentError, "max_concurrency must be a positive Integer"
115
139
  end
116
140
 
117
- bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error)
141
+ bounded_map(tasks, max_concurrency: max_concurrency, on_error: on_error, timeout: timeout, cancellation_token: cancellation_token, force_kill: force_kill)
118
142
  end
119
143
 
120
144
  # Runs the same agent against multiple inputs in parallel (fan-out pattern).
@@ -125,19 +149,52 @@ module Phronomy
125
149
  # @param agent [Class] agent class to invoke for every input
126
150
  # @param inputs [Array<String>] list of input strings
127
151
  # @param config [Hash] forwarded to every +agent#invoke+ call
152
+ # @param thread_id [String, nil] forwarded to every +agent#invoke+ call
128
153
  # @param max_concurrency [Integer, nil] forwarded to {#dispatch_parallel}
129
154
  # @param on_error [Symbol] forwarded to {#dispatch_parallel}
130
155
  # @return [Array<Hash, nil>] results in the same order as +inputs+
131
- def fan_out(agent:, inputs:, config: {}, max_concurrency: nil, on_error: :raise)
156
+ # @api public
157
+ def fan_out(agent:, inputs:, config: {}, thread_id: nil, max_concurrency: nil, on_error: :raise, timeout: nil, cancellation_token: nil, force_kill: false)
132
158
  dispatch_parallel(
133
- *inputs.map { |input| {agent: agent, input: input, config: config} },
159
+ *inputs.map { |input| {agent: agent, input: input, config: config, thread_id: thread_id} },
134
160
  max_concurrency: max_concurrency,
135
- on_error: on_error
161
+ on_error: on_error,
162
+ timeout: timeout,
163
+ cancellation_token: cancellation_token,
164
+ force_kill: force_kill
165
+ )
166
+ end
167
+
168
+ # Programmatically dispatches a single sub-agent from inside an orchestrator
169
+ # instance, inheriting the parent's +thread_id+ and +config+ by default.
170
+ #
171
+ # @param agent_class [Class] subclass of {Phronomy::Agent::Base}
172
+ # @param input [String] task or question for the sub-agent
173
+ # @param config [Hash, nil] override config (falls back to parent's)
174
+ # @param thread_id [String, nil] override thread_id (falls back to parent's)
175
+ # @return [Hash] the sub-agent's result hash (+:output+, +:messages+)
176
+ # @api public
177
+ def subagent(agent_class, input, config: nil, thread_id: nil)
178
+ ctx = Thread.current[:phronomy_orchestrator_context] || {}
179
+ agent_class.new.invoke(
180
+ input,
181
+ config: config || ctx[:config] || {},
182
+ thread_id: thread_id || ctx[:thread_id]
136
183
  )
137
184
  end
138
185
 
139
186
  private
140
187
 
188
+ # Override invoke_once to expose the current thread_id and config via a
189
+ # thread-local so that DSL-registered subagent tools can inherit them.
190
+ def invoke_once(input, messages: [], thread_id: nil, config: {})
191
+ prev = Thread.current[:phronomy_orchestrator_context]
192
+ Thread.current[:phronomy_orchestrator_context] = {thread_id: thread_id, config: config}
193
+ super
194
+ ensure
195
+ Thread.current[:phronomy_orchestrator_context] = prev
196
+ end
197
+
141
198
  # Worker-pool implementation shared by {#dispatch_parallel} and {#fan_out}.
142
199
  #
143
200
  # Uses a +Queue+ as a work-stealing mechanism: each worker thread pops a
@@ -150,12 +207,27 @@ module Phronomy
150
207
  # A +Mutex+ guards concurrent writes to +errors+ even though Array element
151
208
  # assignment at different indices is safe in MRI; this keeps the code
152
209
  # correct across alternative Ruby runtimes.
153
- def bounded_map(tasks, max_concurrency:, on_error:)
210
+ #
211
+ # When +timeout+ is given, workers are first asked to stop cooperatively
212
+ # via a cancellation flag (so they do not pick up new tasks) and then given
213
+ # +KILL_GRACE_SECONDS+ to finish any in-flight +ensure+ blocks. Only
214
+ # workers that are still alive after the grace period are force-killed, and
215
+ # a warning is logged in that case. Use a +CancellationToken+ (see #216)
216
+ # for full cooperative cancellation of long-running tasks.
217
+ #
218
+ # Deadline tracking uses +Process.clock_gettime(Process::CLOCK_MONOTONIC)+
219
+ # to avoid sensitivity to NTP adjustments and system-clock changes.
220
+ KILL_GRACE_SECONDS = 0.5
221
+ private_constant :KILL_GRACE_SECONDS
222
+
223
+ def bounded_map(tasks, max_concurrency:, on_error:, timeout: nil, cancellation_token: nil, force_kill: false)
154
224
  return [] if tasks.empty?
155
225
 
156
226
  results = Array.new(tasks.length)
157
227
  errors = Array.new(tasks.length)
158
228
  errors_mutex = Mutex.new
229
+ # Mutex-backed cooperative stop token; workers check before each task pick-up.
230
+ internal_stop_token = Phronomy::CancellationToken.new
159
231
 
160
232
  queue = Queue.new
161
233
  tasks.each_with_index { |task, i| queue << [i, task] }
@@ -165,16 +237,26 @@ module Phronomy
165
237
  workers = worker_count.times.map do
166
238
  Thread.new do
167
239
  loop do
240
+ break if internal_stop_token.cancelled?
241
+
168
242
  i, task = begin
169
243
  queue.pop(true)
170
244
  rescue ThreadError
171
245
  break # queue is empty; this worker is done
172
246
  end
173
247
 
248
+ # Merge the shared cancellation token into the task's config unless
249
+ # the task already supplies its own token.
250
+ task_config = task.fetch(:config, {})
251
+ if cancellation_token && !task_config[:cancellation_token]
252
+ task_config = task_config.merge(cancellation_token: cancellation_token)
253
+ end
254
+
174
255
  begin
175
256
  results[i] = task[:agent].new.invoke(
176
257
  task[:input],
177
- config: task.fetch(:config, {})
258
+ config: task_config,
259
+ thread_id: task[:thread_id]
178
260
  )
179
261
  rescue => e
180
262
  case on_error
@@ -188,7 +270,37 @@ module Phronomy
188
270
  end
189
271
  end
190
272
 
191
- workers.each(&:join)
273
+ workers.each(&:join) if timeout.nil?
274
+
275
+ if timeout
276
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
277
+ workers.each do |w|
278
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
+ w.join([remaining, 0].max)
280
+ end
281
+
282
+ alive = workers.select(&:alive?)
283
+ unless alive.empty?
284
+ # Signal workers cooperatively to stop picking up new tasks.
285
+ internal_stop_token.cancel!
286
+ if force_kill
287
+ # Give in-flight ensure blocks a short grace period before kill.
288
+ alive.each { |w| w.join(KILL_GRACE_SECONDS) }
289
+ still_alive = alive.select(&:alive?)
290
+ if still_alive.any?
291
+ Phronomy.configuration.logger&.warn(
292
+ "[Phronomy] dispatch_parallel: #{still_alive.length} worker(s) did not stop " \
293
+ "within grace period; force-killing. Use CancellationToken for " \
294
+ "cooperative cancellation of long-running tasks."
295
+ )
296
+ still_alive.each(&:kill)
297
+ end
298
+ end
299
+ raise Phronomy::TimeoutError,
300
+ "dispatch_parallel timed out after #{timeout}s " \
301
+ "(#{alive.length} of #{workers.length} workers still running)"
302
+ end
303
+ end
192
304
 
193
305
  first_error = errors.compact.first
194
306
  raise first_error if first_error
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # RubyLLM::Chat subclass that executes multiple tool calls concurrently.
6
+ #
7
+ # When the LLM returns more than one tool call in a single response, each
8
+ # tool is dispatched in a dedicated IO thread and all results are collected
9
+ # before being appended to the message history. This preserves a
10
+ # deterministic message order while reducing wall-clock latency when tools
11
+ # are IO-bound (HTTP calls, DB queries, etc.).
12
+ #
13
+ # Single-tool responses fall through to the standard sequential path via
14
+ # +super+, preserving all existing edge-case behaviour (Tool::Halt,
15
+ # forced_tool_choice, streaming, SuspendSignal, etc.).
16
+ #
17
+ # This class is used automatically when the agent is running inside an
18
+ # {AgentFSM} IO thread (i.e. when the +:phronomy_agent_parallel_tools+
19
+ # thread-local flag is +true+). It is not used for direct synchronous
20
+ # +invoke+ calls so that the streaming callback state remains single-threaded.
21
+ # @api private
22
+ class ParallelToolChat < RubyLLM::Chat
23
+ private
24
+
25
+ # Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
26
+ # when multiple tool calls are present in a single LLM response.
27
+ #
28
+ # The method preserves the three-phase protocol of the original:
29
+ # 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
30
+ # sequential so that the Suspendable concern's approval hook can
31
+ # raise +SuspendSignal+ before any tool is executed.
32
+ # 2. Parallel tool execution — one IO thread per tool call.
33
+ # 3. Post-execution callbacks and message recording — sequential,
34
+ # in the original tool-call order.
35
+ #
36
+ # @param response [RubyLLM::Message] the LLM response containing tool calls
37
+ # @yield streaming block forwarded to +complete+
38
+ # @api private
39
+ def handle_tool_calls(response, &block)
40
+ tool_calls = response.tool_calls.values
41
+
42
+ # Single tool: delegate to the parent implementation to preserve every
43
+ # edge case (forced_tool_choice, streaming, Halt, SuspendSignal…).
44
+ return super if tool_calls.size <= 1
45
+
46
+ # Phase 1 — pre-execution callbacks (sequential, original order).
47
+ # The SuspendSignal approval hook is registered via on_tool_call, so it
48
+ # MUST fire before execution begins.
49
+ tool_calls.each do |tool_call|
50
+ @on[:new_message]&.call
51
+ @on[:tool_call]&.call(tool_call)
52
+ end
53
+
54
+ # Phase 2 — parallel tool execution.
55
+ # Honour the per-agent concurrency cap (max_parallel_tools DSL).
56
+ # Tool calls are processed in batches of at most `max` threads;
57
+ # batches run sequentially so the total in-flight thread count never
58
+ # exceeds the limit.
59
+ #
60
+ # Check for cancellation before dispatching each batch so that
61
+ # already-cancelled tokens do not start new LLM/tool-round-trips.
62
+ ct = Thread.current[:phronomy_cancellation_token]
63
+ max = Thread.current[:phronomy_max_parallel_tools] || 10
64
+ thread_results = tool_calls.each_slice(max).flat_map do |batch|
65
+ if ct&.cancelled?
66
+ raise Phronomy::CancellationError, "invocation cancelled before tool execution"
67
+ end
68
+
69
+ threads = batch.map do |tool_call|
70
+ Thread.new { {tool_call: tool_call, result: execute_tool(tool_call)} }
71
+ end
72
+ threads.map(&:value)
73
+ end
74
+
75
+ # Phase 3 — post-execution callbacks and message recording (sequential).
76
+ halt_result = nil
77
+ thread_results.each do |item|
78
+ result = item[:result]
79
+ @on[:tool_result]&.call(result)
80
+ tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
81
+ content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
82
+ message = add_message(role: :tool, content: content, tool_call_id: item[:tool_call].id)
83
+ @on[:end_message]&.call(message)
84
+ halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
85
+ end
86
+
87
+ reset_tool_choice if forced_tool_choice?
88
+ halt_result || complete(&block)
89
+ end
90
+ end
91
+ end
92
+ end
@@ -37,9 +37,10 @@ module Phronomy
37
37
  end
38
38
  end
39
39
 
40
- # Fall back to the last message
41
- # guards against the case where the final message is a tool-call or
42
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
40
+ # Select the last assistant-produced content as the output, skipping
41
+ # raw tool result messages (role: :tool) to avoid returning tool JSON
42
+ # or status strings as the agent's answer when iterations are exhausted.
43
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
43
44
 
44
45
  # Run output guardrails before returning to the caller.
45
46
  run_output_guardrails!(output)
@@ -60,6 +61,7 @@ module Phronomy
60
61
  # @param config [Hash]
61
62
  # @yield [Phronomy::Agent::StreamEvent]
62
63
  # @return [Hash] { output:, messages:, usage: }
64
+ # @api public
63
65
  def stream(input, messages: [], thread_id: nil, config: {}, &block)
64
66
  return invoke(input, messages: messages, thread_id: thread_id, config: config) unless block
65
67
 
@@ -88,9 +90,9 @@ module Phronomy
88
90
  end
89
91
  end
90
92
 
91
- # Fall back to the last message that carries non-nil content (same as
92
- # the non-streaming path above).
93
- output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
93
+ # Select the last assistant-produced content as the output, skipping
94
+ # raw tool result messages (role: :tool) — same as the non-streaming path.
95
+ output = messages.reverse.find { |m| m.content && !m.content.empty? && m.role != :tool }&.content
94
96
  run_output_guardrails!(output)
95
97
 
96
98
  result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
@@ -30,6 +30,7 @@ module Phronomy
30
30
  # @param routes [Hash{Phronomy::Agent::Base => Array<Phronomy::Agent::Base>}]
31
31
  # declares which target agents each source agent may hand off to;
32
32
  # when omitted no handoffs are configured and the entry agent handles everything
33
+ # @api public
33
34
  def initialize(agents:, routes: {})
34
35
  @agents = Array(agents)
35
36
  raise ArgumentError, "At least one agent is required" if @agents.empty?
@@ -47,6 +48,7 @@ module Phronomy
47
48
  # @param config [Hash] forwarded to each agent's #invoke
48
49
  # @return [Hash] { output:, messages:, usage:, agent: }
49
50
  # @raise [Phronomy::HandoffError] if more than MAX_HANDOFFS handoffs occur
51
+ # @api public
50
52
  def invoke(input, config: {})
51
53
  current = @entry_agent
52
54
  handoffs_taken = 0