phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module Lifecycle
8
+ # Builds the anonymous state-machine Class used by {WorkflowRunner} to track
9
+ # workflow phase transitions.
10
+ #
11
+ # Extracted from {WorkflowRunner#build_phase_machine_class} to reduce the
12
+ # span of WorkflowRunner's initializer and to give the FSM construction
13
+ # logic an explicit, testable home.
14
+ #
15
+ # Call {#build} to obtain the generated +Class+. The returned class responds
16
+ # to +#context+ / +#context=+ and +#async_pending+ / +#async_pending=+, and
17
+ # has a +state_machine :phase+ definition with all registered transitions and
18
+ # callbacks.
19
+ #
20
+ # @api private
21
+ class PhaseMachineBuilder
22
+ # @param entry_point [Symbol] initial state for the phase machine
23
+ # @param declared_states [Array<Symbol>] all states declared in the workflow
24
+ # @param wait_state_names [Array<Symbol>] states that wait for external events
25
+ # @param external_events [Hash{Symbol => Array<Hash>}]
26
+ # +{ event_name => [{from:, to:, guard:}, ...] }+
27
+ # @param entry_actions [Hash{Symbol => Array<#call>}]
28
+ # +{ state_name => [callable, ...] }+
29
+ # @param action_timeouts [Hash{Symbol => Numeric}]
30
+ # +{ state_name => seconds }+
31
+ # @param auto_transitions [Array<Hash>]
32
+ # +[{ from:, to:, guard: }, ...]+ — all auto-fire transitions
33
+ # @param exit_actions [Hash{Symbol => Array<#call>}]
34
+ # +{ state_name => [callable, ...] }+
35
+ # @api private
36
+ def initialize(
37
+ entry_point:,
38
+ declared_states:,
39
+ wait_state_names:,
40
+ external_events:,
41
+ entry_actions:,
42
+ action_timeouts:,
43
+ auto_transitions:,
44
+ exit_actions:
45
+ )
46
+ @entry_point = entry_point
47
+ @declared_states = declared_states
48
+ @wait_state_names = wait_state_names
49
+ @external_events = external_events
50
+ @entry_actions = entry_actions
51
+ @action_timeouts = action_timeouts
52
+ @auto_transitions = auto_transitions
53
+ @exit_actions = exit_actions
54
+ end
55
+
56
+ # Constructs and returns the anonymous phase-machine Class.
57
+ #
58
+ # @return [Class] an anonymous class with a +state_machine :phase+ definition
59
+ # @raise [ArgumentError] if state_machines raises during class construction
60
+ # @api private
61
+ def build
62
+ entry = @entry_point
63
+ all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
64
+ auto_trans = @auto_transitions
65
+ ext_events = @external_events
66
+ entry_acts = @entry_actions
67
+ exit_acts = @exit_actions
68
+ act_timeouts = @action_timeouts
69
+ build_cb = method(:build_entry_callback)
70
+
71
+ Class.new do
72
+ # Holds the current WorkflowContext so guards and callbacks can read it.
73
+ attr_accessor :context
74
+
75
+ # Set to true by an entry action that returned an awaitable Task.
76
+ # When true, FSMSession skips the automatic advance_or_halt step and
77
+ # waits for the async worker thread to post a state_completed event back.
78
+ attr_accessor :async_pending
79
+
80
+ state_machine :phase, initial: entry do
81
+ all_states.each { |s| state s }
82
+
83
+ # Auto-fire transitions: all auto transitions unified under :state_completed.
84
+ # Includes unguarded (unconditional) and guarded (conditional) transitions.
85
+ # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
86
+ event :state_completed do
87
+ auto_trans.each do |t|
88
+ if t[:guard]
89
+ guard_proc = t[:guard]
90
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
91
+ else
92
+ transition t[:from] => t[:to]
93
+ end
94
+ end
95
+ end
96
+
97
+ # External events: human-in-the-loop triggers from wait states.
98
+ ext_events.each do |ev_name, transitions|
99
+ event ev_name do
100
+ transitions.each do |t|
101
+ if t[:guard]
102
+ guard_proc = t[:guard]
103
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
104
+ else
105
+ transition t[:from] => t[:to]
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ # Entry callbacks: fire after_transition into each state.
112
+ # Each callable is registered as a separate callback; state_machines
113
+ # accumulates them and fires in declaration order.
114
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
115
+ # the returned context replaces the current one on the tracker.
116
+ entry_acts.each do |state_name, callables|
117
+ callables.each do |callable|
118
+ cb = build_cb.call(callable, state_name, act_timeouts[state_name])
119
+ after_transition to: state_name, &cb
120
+ end
121
+ end
122
+
123
+ # Exit callbacks: fire before_transition out of each state.
124
+ # Each callable is registered as a separate callback; state_machines
125
+ # accumulates them and fires in declaration order.
126
+ exit_acts.each do |state_name, callables|
127
+ callables.each do |callable|
128
+ before_transition from: state_name do |machine|
129
+ callable.call(machine.context)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ rescue => e
136
+ raise ArgumentError, "Failed to build phase machine: #{e.message}"
137
+ end
138
+
139
+ private
140
+
141
+ # Returns a proc suitable for use as an +after_transition+ callback.
142
+ #
143
+ # The returned proc accepts a single argument (the phase machine instance),
144
+ # invokes the entry action callable with the current context, then delegates
145
+ # the result to {#handle_entry_action_result}. Capturing this in the
146
+ # builder's scope lets the anonymous +Class.new+ block stay slim.
147
+ #
148
+ # @param callable [#call] the entry action
149
+ # @param state_name [Symbol] name of the target state (for error messages)
150
+ # @param timeout_secs [Numeric, nil] seconds before ActionTimeoutError
151
+ # @return [Proc]
152
+ # @api private
153
+ def build_entry_callback(callable, state_name, timeout_secs)
154
+ handle = method(:handle_entry_action_result)
155
+ ->(machine) {
156
+ result = callable.call(machine.context)
157
+ handle.call(machine, result, state_name, timeout_secs)
158
+ }
159
+ end
160
+
161
+ # Dispatches the return value of an entry action callable.
162
+ #
163
+ # - +Phronomy::Task+ → async or blocking task handling
164
+ # - +Phronomy::WorkflowContext+ → replaces the machine's context directly
165
+ # - anything else → ignored
166
+ #
167
+ # @param machine [Object] phase machine instance
168
+ # @param result [Object] return value of the entry callable
169
+ # @param state_name [Symbol] name of the entered state
170
+ # @param timeout_secs [Numeric, nil] optional timeout in seconds
171
+ # @api private
172
+ def handle_entry_action_result(machine, result, state_name, timeout_secs)
173
+ if result.is_a?(Phronomy::Task)
174
+ if Phronomy.configuration.event_loop
175
+ dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
176
+ else
177
+ await_task_blocking(machine, result, state_name, timeout_secs)
178
+ end
179
+ elsif result.is_a?(Phronomy::WorkflowContext)
180
+ machine.context = result
181
+ end
182
+ end
183
+
184
+ # Handles a +Phronomy::Task+ return value in EventLoop mode.
185
+ #
186
+ # Marks the machine as async-pending and spawns a cooperative background
187
+ # task that awaits the result, then posts the appropriate event back to
188
+ # the EventLoop. +FSMSession+ will skip the automatic +advance_or_halt+
189
+ # step while +async_pending+ is true.
190
+ #
191
+ # @param machine [Object] phase machine instance
192
+ # @param result [Phronomy::Task]
193
+ # @param state_name [Symbol]
194
+ # @param timeout_secs [Numeric, nil]
195
+ # @api private
196
+ def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
197
+ machine.async_pending = true
198
+ thread_id = machine.context.thread_id
199
+ Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
200
+ enforce_timeout!(result, state_name, timeout_secs)
201
+ task_result = result.await
202
+ ev = if task_result.is_a?(Phronomy::WorkflowContext)
203
+ Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
204
+ else
205
+ Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
206
+ end
207
+ Phronomy::EventLoop.instance.post(ev)
208
+ rescue => e
209
+ Phronomy::EventLoop.instance.post(
210
+ Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
211
+ )
212
+ end
213
+ end
214
+
215
+ # Handles a +Phronomy::Task+ return value in non-EventLoop mode.
216
+ #
217
+ # Blocks the current execution context until the task completes or the
218
+ # optional timeout elapses.
219
+ #
220
+ # @param machine [Object] phase machine instance
221
+ # @param result [Phronomy::Task]
222
+ # @param state_name [Symbol]
223
+ # @param timeout_secs [Numeric, nil]
224
+ # @api private
225
+ def await_task_blocking(machine, result, state_name, timeout_secs)
226
+ enforce_timeout!(result, state_name, timeout_secs)
227
+ task_result = result.await
228
+ machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
229
+ end
230
+
231
+ # Raises +ActionTimeoutError+ if the task does not complete within
232
+ # +timeout_secs+. No-op when +timeout_secs+ is +nil+.
233
+ #
234
+ # @param result [Phronomy::Task]
235
+ # @param state_name [Symbol]
236
+ # @param timeout_secs [Numeric, nil]
237
+ # @api private
238
+ def enforce_timeout!(result, state_name, timeout_secs)
239
+ return unless timeout_secs
240
+ return unless result.join(timeout_secs).nil?
241
+
242
+ result.cancel!
243
+ raise Phronomy::ActionTimeoutError,
244
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -13,6 +13,10 @@ module Phronomy
13
13
  caller_meta = {}
14
14
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
15
15
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
16
+ if (ic = config[:invocation_context])
17
+ caller_meta[:task_id] = ic.task_id if ic.task_id
18
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
19
+ end
16
20
 
17
21
  trace("agent.invoke", input: input, **caller_meta) do |_span|
18
22
  # Run input guardrails before any LLM interaction.
@@ -68,6 +72,10 @@ module Phronomy
68
72
  caller_meta = {}
69
73
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
70
74
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
75
+ if (ic = config[:invocation_context])
76
+ caller_meta[:task_id] = ic.task_id if ic.task_id
77
+ caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
78
+ end
71
79
 
72
80
  trace("agent.invoke", input: input, **caller_meta) do |_span|
73
81
  run_input_guardrails!(input)
@@ -127,14 +135,14 @@ module Phronomy
127
135
  # Run before_completion hooks before each LLM call in the ReAct loop.
128
136
  run_before_completion_hooks!(chat, config)
129
137
 
130
- response = if user_asked
131
- # Subsequent loop iteration history already contains the user message;
132
- # just ask the LLM to continue (e.g. after a tool result).
133
- chat.complete
134
- else
135
- # First iteration — add the new user question and call the LLM.
136
- chat.ask(extract_message(initial_input))
137
- end
138
+ # Route the LLM call through the configured LLMAdapter so that the
139
+ # blocking HTTP request runs inside BlockingAdapterPool and the
140
+ # adapter can be swapped without changing agent code.
141
+ # Passing nil as message signals the adapter to call chat.complete
142
+ # (no new user turn) for continuation iterations.
143
+ adapter = Phronomy.configuration.llm_adapter
144
+ message = user_asked ? nil : extract_message(initial_input)
145
+ response = adapter.complete_async(chat, message, config: config).await
138
146
 
139
147
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
140
148
  tool_calls = chat.messages.last&.tool_calls
@@ -173,13 +181,18 @@ module Phronomy
173
181
  # Run before_completion hooks before each LLM call in the streaming loop.
174
182
  run_before_completion_hooks!(chat, config)
175
183
 
184
+ # Route the streaming LLM call through the configured LLMAdapter so that
185
+ # the blocking HTTP request runs inside BlockingAdapterPool.
186
+ # Passing nil as message signals the adapter to call chat.complete
187
+ # (no new user turn) for continuation iterations.
188
+ # Streaming chunks and tool event callbacks are delivered directly via the
189
+ # block on the pool worker thread; pending.await yields cooperatively until
190
+ # streaming is complete.
191
+ adapter = Phronomy.configuration.llm_adapter
192
+ message = user_asked ? nil : extract_message(initial_input)
176
193
  streaming_block = proc { |chunk| block.call(StreamEvent.new(type: :token, payload: {content: chunk.content})) }
177
-
178
- response = if user_asked
179
- chat.complete(&streaming_block)
180
- else
181
- chat.ask(extract_message(initial_input), &streaming_block)
182
- end
194
+ pending = adapter.stream_async(chat, message, config: config, &streaming_block)
195
+ response = pending.await
183
196
 
184
197
  usage = Phronomy::TokenUsage.from_tokens(response&.tokens)
185
198
  tool_calls = chat.messages.last&.tool_calls
@@ -74,7 +74,7 @@ module Phronomy
74
74
  def build_handoffs(routes)
75
75
  routes.each do |source_agent, target_agents|
76
76
  Array(target_agents).each do |target_agent|
77
- handoff = Handoff.new(target_agent: target_agent)
77
+ handoff = Phronomy::MultiAgent::Handoff.new(target_agent: target_agent)
78
78
  @sentinel_map[handoff.sentinel] = target_agent
79
79
  source_agent._add_handoff_tool(handoff.to_tool_class)
80
80
  end
@@ -86,7 +86,7 @@ module Phronomy
86
86
  next unless msg.role.to_sym == :tool
87
87
 
88
88
  content = msg.content.to_s
89
- next unless content.start_with?(Handoff::SENTINEL_PREFIX)
89
+ next unless content.start_with?(Phronomy::MultiAgent::Handoff::SENTINEL_PREFIX)
90
90
 
91
91
  return @sentinel_map[content]
92
92
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Centralises tool execution routing based on {Tool::Base.execution_mode}.
6
+ #
7
+ # This is the single place in the framework that decides *how* a tool call is
8
+ # dispatched:
9
+ #
10
+ # - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
11
+ # scheduler. Under the +:fiber+ backend this avoids an
12
+ # extra OS thread; under the +:thread+ backend it is
13
+ # backed by +ThreadScheduler+ (one thread per task).
14
+ # - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
15
+ # provides a pool; falls back to +Runtime#spawn+ otherwise.
16
+ # - +:cpu_bound+ — emits a deprecation-style warning then falls back to
17
+ # +:blocking_io+ routing (no process pool available yet).
18
+ # - +:external_process+ — falls back to +:blocking_io+ routing (no process
19
+ # manager available yet).
20
+ #
21
+ # All paths return an object that responds to +#await+ (+Phronomy::Task+ or
22
+ # +BlockingAdapterPool::PendingOperation+), so callers can collect results
23
+ # uniformly.
24
+ #
25
+ # @note Non-goals
26
+ # ToolExecutor deliberately does NOT provide:
27
+ # - A CPU-bound process pool. CPU-intensive tool work must be handled at the
28
+ # application layer (e.g., fork, Sidekiq, separate OS processes). The
29
+ # framework will not add a +ProcessPoolExecutor+ equivalent.
30
+ # - An external process manager. Spawning or supervising subprocesses is
31
+ # out of scope for this module.
32
+ # - Additional core execution routes beyond scheduler-backed cooperative
33
+ # execution and BlockingAdapterPool-backed blocking I/O isolation.
34
+ # The +:cpu_bound+ and +:external_process+ modes are accepted for
35
+ # compatibility but both fall back to +:blocking_io+ routing with a
36
+ # one-time warning. If a genuinely new core execution route is needed,
37
+ # a new ADR is required.
38
+ # These non-goals follow from the cooperative-first, non-preemptive
39
+ # concurrency model (ADR-010): framework components must not assume the
40
+ # caller's concurrency model, and CPU/process management belongs to the
41
+ # application layer.
42
+ #
43
+ # @api private
44
+ module ToolExecutor
45
+ # Tracks tool classes that have already emitted an execution_mode warning so
46
+ # that the same warning is only logged once per process lifetime.
47
+ WARNED_MODES = Set.new
48
+ WARNED_MODES_MUTEX = Mutex.new
49
+ private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
50
+
51
+ # Dispatches a single tool call asynchronously according to its
52
+ # +execution_mode+ and returns an awaitable.
53
+ #
54
+ # @param tool [Phronomy::Tool::Base] the tool instance to invoke
55
+ # @param args [Hash] argument hash to pass to {Tool::Base#call}
56
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
57
+ # @param runtime [Phronomy::Runtime] runtime to use for spawning
58
+ # (defaults to {Runtime.instance}; injectable for tests)
59
+ # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
60
+ # @api private
61
+ def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
62
+ ct = cancellation_token
63
+ mode = tool.class.execution_mode
64
+
65
+ # Warn and normalise unsupported modes to :blocking_io.
66
+ # Each (tool class, mode) pair emits the warning at most once per process
67
+ # lifetime to avoid log flooding in high-throughput scenarios.
68
+ if mode == :cpu_bound || mode == :external_process
69
+ warn_key = [tool.class.name, mode]
70
+ newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
71
+ if newly_warned
72
+ msg = if mode == :cpu_bound
73
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
74
+ "which has no dedicated executor. " \
75
+ "Falling back to blocking_io (BlockingAdapterPool). " \
76
+ "Use :blocking_io explicitly to suppress this warning."
77
+ else
78
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
79
+ "which has no dedicated process manager. " \
80
+ "Falling back to blocking_io (BlockingAdapterPool)."
81
+ end
82
+ if Phronomy.configuration.logger
83
+ Phronomy.configuration.logger.warn(msg)
84
+ else
85
+ warn msg
86
+ end
87
+ end
88
+ mode = :blocking_io
89
+ end
90
+
91
+ pool = begin
92
+ runtime&.blocking_io
93
+ rescue
94
+ nil
95
+ end
96
+
97
+ if mode == :cooperative || pool.nil?
98
+ runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
99
+ tool.call(args, cancellation_token: ct)
100
+ end
101
+ else
102
+ # Submit directly to pool — no wrapping Task thread required.
103
+ pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # A thread-safe FIFO queue for passing values between concurrent tasks.
6
+ #
7
+ # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
8
+ # standard-library type directly. A future implementation may replace the
9
+ # backing primitive without changing call sites.
10
+ #
11
+ # @example Producer / consumer
12
+ # queue = Phronomy::Concurrency::AsyncQueue.new
13
+ # Runtime.instance.spawn { queue.push(expensive_io()) }
14
+ # value = queue.pop # blocks until the producer pushes
15
+ # @api private
16
+ class AsyncQueue
17
+ # @param max_size [Integer, nil] optional upper bound on queue depth.
18
+ # When set, {#push} blocks the caller until a slot is available.
19
+ # @api private
20
+ def initialize(max_size: nil)
21
+ @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
+ @max_size = max_size
23
+ end
24
+
25
+ # Enqueues +item+.
26
+ # In a cooperative scheduler context with a bounded queue (max_size:), suspends
27
+ # the current Fiber via a scheduler signal when the queue is full rather than
28
+ # blocking the OS thread. Without a scheduler, falls back to the standard
29
+ # SizedQueue blocking behaviour.
30
+ # @param item [Object] value to enqueue
31
+ # @return [self]
32
+ # @api private
33
+ def push(item)
34
+ scheduler = Phronomy::Runtime::Scheduler.current
35
+ if scheduler && @max_size
36
+ _push_cooperative(scheduler, item)
37
+ else
38
+ @queue.push(item)
39
+ scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
40
+ end
41
+ self
42
+ end
43
+
44
+ # Dequeues and returns the next item.
45
+ # In a cooperative scheduler context, suspends the current Fiber (yielding
46
+ # control back to the scheduler) rather than blocking the OS thread.
47
+ #
48
+ # When +timeout+ is given the semantics depend on the active backend:
49
+ #
50
+ # * **Thread backend** (`:thread`) — uses real wall-clock time via
51
+ # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
52
+ # Returns +nil+ if no item arrives within the specified number of real seconds.
53
+ # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
54
+ # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
55
+ # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
56
+ # In tests this means the timeout is fully deterministic and does not depend on
57
+ # actual elapsed wall time. However, in production `:fiber` mode the timeout
58
+ # may never expire unless the scheduler explicitly advances virtual time.
59
+ #
60
+ # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
61
+ # in production workloads is not guaranteed and may differ from wall-clock
62
+ # expectations.
63
+ # @note **Cooperative timeout limitation**: on the cooperative path, the
64
+ # deadline is re-checked *after* a wake-up signal arrives. If virtual time
65
+ # has already passed the deadline when the consumer is woken by a producer
66
+ # push, the consumer returns +nil+ rather than the pushed item. Without any
67
+ # wake-up signal the waiting Fiber remains suspended even after
68
+ # +scheduler.advance+ — the timeout does not self-fire.
69
+ # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
70
+ # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
71
+ # @return [Object, nil] the next item, or +nil+ when timeout expires
72
+ # @api private
73
+ def pop(timeout: nil)
74
+ scheduler = Phronomy::Runtime::Scheduler.current
75
+ if scheduler
76
+ _pop_cooperative(scheduler, timeout: timeout)
77
+ elsif timeout
78
+ @queue.pop(timeout: timeout)
79
+ else
80
+ @queue.pop
81
+ end
82
+ end
83
+
84
+ # Returns the current number of items in the queue.
85
+ # @return [Integer]
86
+ # @api private
87
+ def size
88
+ @queue.size
89
+ end
90
+
91
+ # Returns +true+ when the queue contains no items.
92
+ # @return [Boolean]
93
+ # @api private
94
+ def empty?
95
+ @queue.empty?
96
+ end
97
+
98
+ # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
99
+ # @return [self]
100
+ # @api private
101
+ def close
102
+ @queue.close
103
+ self
104
+ end
105
+
106
+ private
107
+
108
+ # Cooperative pop for DeterministicScheduler context.
109
+ # Suspends the current Fiber via the scheduler's signal mechanism rather than
110
+ # blocking the OS thread. Because cooperative mode is single-threaded, the
111
+ # empty?/pop pair is race-free (no other Fiber can run between the two calls).
112
+ # After dequeuing, notifies any push-waiter so that a backpressure-suspended
113
+ # producer can be unblocked.
114
+ # @api private
115
+ # @param scheduler [Runtime::Scheduler]
116
+ # @param timeout [Numeric, nil]
117
+ # @return [Object, nil]
118
+ def _pop_cooperative(scheduler, timeout:)
119
+ @coop_signal ||= scheduler.new_signal
120
+ deadline = timeout ? (scheduler.virtual_time + timeout) : nil
121
+
122
+ loop do
123
+ unless @queue.empty?
124
+ item = @queue.pop(timeout: 0)
125
+ # Notify a push-waiter (bounded queue) that a slot opened up.
126
+ scheduler.raise_signal(@push_signal) if @push_signal
127
+ return item
128
+ end
129
+ return nil if deadline && scheduler.virtual_time >= deadline
130
+ scheduler.wait_for_signal(@coop_signal)
131
+ return nil if deadline && scheduler.virtual_time >= deadline
132
+ end
133
+ end
134
+
135
+ # Cooperative push for DeterministicScheduler context with a bounded queue.
136
+ # Suspends the current Fiber via a scheduler signal when the queue is full,
137
+ # rather than blocking the OS thread.
138
+ # @api private
139
+ # @param scheduler [Runtime::Scheduler]
140
+ # @param item [Object]
141
+ # @return [void]
142
+ def _push_cooperative(scheduler, item)
143
+ @push_signal ||= scheduler.new_signal
144
+
145
+ loop do
146
+ unless @queue.size >= @max_size
147
+ @queue.push(item)
148
+ # Notify any pop-waiter that an item is now available.
149
+ scheduler.raise_signal(@coop_signal) if @coop_signal
150
+ return
151
+ end
152
+ scheduler.wait_for_signal(@push_signal)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end