phronomy 0.11.0 → 0.12.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 (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 +197 -83
  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 +346 -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 +6 -6
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +17 -10
  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
@@ -0,0 +1,346 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Factory that builds a Phronomy::FSMSession configured for a single
8
+ # Agent#invoke execution.
9
+ #
10
+ # This is the Agent counterpart to WorkflowRunner — it assembles the
11
+ # FSMSession with the correct phase machine class, entry actions, and
12
+ # context, then hands it to EventLoop for execution.
13
+ #
14
+ # == Usage
15
+ #
16
+ # session = Agent::InvocationSession.build(
17
+ # agent: my_agent,
18
+ # input: "What is Ruby?",
19
+ # messages: [],
20
+ # config: { thread_id: "t-1" }
21
+ # )
22
+ # completion_queue = Phronomy::EventLoop.instance.register(session)
23
+ # ctx = completion_queue.pop
24
+ #
25
+ # == Streaming mode
26
+ #
27
+ # Pass +mode: :stream+ and an +on_event:+ block to receive token/tool events.
28
+ # The state graph is identical; only the +:calling_llm+ entry action differs.
29
+ #
30
+ # @api private
31
+ class InvocationSession
32
+ # States that have an automatic transition after their action completes.
33
+ AUTO_STATE_SET = {
34
+ idle: true,
35
+ filtering_input: true,
36
+ building_context: true,
37
+ calling_llm: true,
38
+ executing_tool: true,
39
+ output_filtering: true
40
+ }.freeze
41
+
42
+ # All declared action states (terminals excluded).
43
+ DECLARED_STATES = %i[
44
+ idle filtering_input building_context calling_llm
45
+ executing_tool awaiting_approval output_filtering
46
+ completed blocked
47
+ ].freeze
48
+
49
+ # Builds a Phronomy::FSMSession for the given agent invocation.
50
+ #
51
+ # @param agent [Phronomy::Agent::Base]
52
+ # @param input [String, Hash]
53
+ # @param messages [Array]
54
+ # @param config [Hash]
55
+ # @param mode [:invoke, :stream]
56
+ # @param on_event [Proc, nil] stream event callback (stream mode only)
57
+ # @return [Phronomy::FSMSession]
58
+ # @api private
59
+ def self.build(agent:, input:, messages:, config:, mode: :invoke, on_event: nil)
60
+ ctx = Agent::InvocationContext.new(
61
+ agent: agent,
62
+ input: input,
63
+ messages: messages,
64
+ config: config
65
+ )
66
+
67
+ actions = (mode == :stream && on_event) ?
68
+ build_stream_entry_actions(agent, on_event) :
69
+ build_entry_actions(agent)
70
+
71
+ # Calculate recursion_limit for the FSM:
72
+ # Base states: idle→filtering_input→building_context→calling_llm→
73
+ # output_filtering→completed = 6 transitions
74
+ # Each tool call loop: calling_llm→executing_tool→calling_llm = 2 transitions
75
+ # Safety margin: +4
76
+ iterations = agent.class.max_iterations || 10
77
+ fsm_recursion_limit = 6 + (iterations * 2) + 4
78
+
79
+ # Entry actions are registered as after_transition callbacks in the
80
+ # phase machine class. Pass empty hash to FSMSession (it uses @entry_actions
81
+ # only for the entry_point state, which has no action for :idle).
82
+ phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
83
+ session_id = config[:thread_id] || SecureRandom.uuid
84
+
85
+ Phronomy::FSMSession.new(
86
+ id: session_id,
87
+ context: ctx,
88
+ entry_point: :idle,
89
+ phase_machine_class: phase_machine,
90
+ entry_actions: {},
91
+ auto_state_set: AUTO_STATE_SET,
92
+ declared_states: DECLARED_STATES,
93
+ wait_state_names: %i[awaiting_approval],
94
+ external_events: {
95
+ approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
96
+ reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
97
+ },
98
+ recursion_limit: fsm_recursion_limit
99
+ )
100
+ end
101
+
102
+ # Builds a FSMSession that resumes an existing InvocationContext
103
+ # from a wait state (e.g. :awaiting_approval) using an external event.
104
+ #
105
+ # @param agent [Phronomy::Agent::Base]
106
+ # @param context [Phronomy::Agent::InvocationContext] suspended context
107
+ # @param resume_event [Symbol] e.g. :approve or :reject
108
+ # @param resume_phase [Symbol] the wait state to resume from
109
+ # @return [Phronomy::FSMSession]
110
+ # @api private
111
+ def self.build_for_resume(agent:, context:, resume_event:, resume_phase:)
112
+ actions = build_entry_actions(agent)
113
+ phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
114
+
115
+ iterations = agent.class.max_iterations || 10
116
+ fsm_recursion_limit = 6 + (iterations * 2) + 4
117
+
118
+ Phronomy::FSMSession.new(
119
+ id: context.session_id || SecureRandom.uuid,
120
+ context: context,
121
+ entry_point: :idle,
122
+ phase_machine_class: phase_machine,
123
+ entry_actions: {},
124
+ auto_state_set: AUTO_STATE_SET,
125
+ declared_states: DECLARED_STATES,
126
+ wait_state_names: %i[awaiting_approval],
127
+ external_events: {
128
+ approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
129
+ reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
130
+ },
131
+ recursion_limit: fsm_recursion_limit,
132
+ resume_event: resume_event,
133
+ resume_phase: resume_phase
134
+ )
135
+ end
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Entry action builders
139
+ # ---------------------------------------------------------------------------
140
+
141
+ # @api private
142
+ def self.build_entry_actions(agent)
143
+ {
144
+ # :idle has no action — FSMSession auto-transitions to :filtering_input
145
+ filtering_input: [method(:filtering_input_action).curry.call(agent)],
146
+ building_context: [method(:building_context_action).curry.call(agent)],
147
+ calling_llm: [method(:calling_llm_action).curry.call(agent)],
148
+ executing_tool: [method(:executing_tool_action).curry.call(agent)],
149
+ output_filtering: [method(:output_filtering_action).curry.call(agent)]
150
+ }
151
+ end
152
+ private_class_method :build_entry_actions
153
+
154
+ # @api private
155
+ def self.build_stream_entry_actions(agent, on_event)
156
+ build_entry_actions(agent).merge(
157
+ calling_llm: [method(:calling_llm_stream_action).curry.call(agent, on_event)]
158
+ )
159
+ end
160
+ private_class_method :build_stream_entry_actions
161
+
162
+ # ----------------------------------------------------------------
163
+ # Individual entry action implementations
164
+ # ----------------------------------------------------------------
165
+
166
+ def self.filtering_input_action(agent, ctx)
167
+ begin
168
+ ctx.input = agent.send(:run_input_filters!, ctx.input)
169
+ rescue Phronomy::FilterBlockError => e
170
+ ctx.input_blocked = true
171
+ ctx.block_error = e
172
+ end
173
+ ctx
174
+ end
175
+ private_class_method :filtering_input_action
176
+
177
+ def self.building_context_action(agent, ctx)
178
+ ctx.chat = agent.send(:build_chat)
179
+ context = agent.send(
180
+ :build_context,
181
+ ctx.input,
182
+ messages: ctx.messages,
183
+ thread_id: ctx.thread_id,
184
+ config: ctx.config,
185
+ budget: agent.send(:build_token_budget),
186
+ instruction: agent.send(:build_instructions, ctx.input),
187
+ tools: agent.class.tools + agent.send(:_handoff_tools)
188
+ )
189
+ agent.send(:_apply_context_to_chat, ctx.chat, context)
190
+ # Run before-completion hooks (e.g. memory injection) once per invocation.
191
+ agent.send(:run_before_completion_hooks!, ctx.chat, ctx.config)
192
+ # Register the tool-call interceptor so every tool call routes through
193
+ # :executing_tool in the FSM instead of executing inside RubyLLM's loop.
194
+ ctx.chat.on_tool_call do |tool_call|
195
+ raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
196
+ end
197
+ ctx
198
+ end
199
+ private_class_method :building_context_action
200
+
201
+ def self.calling_llm_action(agent, ctx)
202
+ # Returns a Task.deferred — no extra OS thread is created.
203
+ # The BlockingAdapterPool worker thread completes the LLM call and
204
+ # resolves result_task via on_complete, which then triggers the
205
+ # FSMSession's dispatch_task_in_event_loop on_complete callback to
206
+ # post :action_completed back to the EventLoop.
207
+ user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
208
+ agent.send(:check_cancellation!, ctx.config, "invocation cancelled before LLM call")
209
+ adapter = Phronomy.configuration.llm_adapter
210
+ op = adapter.complete_async(ctx.chat, user_message, config: ctx.config)
211
+ result_task = Phronomy::Task.deferred(name: "agent-llm:#{ctx.thread_id}")
212
+ op.on_complete do |response, error|
213
+ if error.is_a?(Phronomy::Agent::ToolCallIntercepted)
214
+ ctx.user_message_sent = true
215
+ ctx.pending_tool_call = error.tool_call
216
+ ctx.tool_call_pending = true
217
+ ctx.messages = ctx.chat.messages
218
+ result_task.backend.unblock(ctx, nil)
219
+ result_task.transition!(:completed, value: ctx)
220
+ elsif error
221
+ result_task.backend.unblock(nil, error)
222
+ result_task.transition!(:failed, error: error)
223
+ else
224
+ ctx.user_message_sent = true
225
+ ctx.output = response.content
226
+ ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
227
+ ctx.messages = ctx.chat.messages
228
+ ctx.tool_call_pending = false
229
+ result_task.backend.unblock(ctx, nil)
230
+ result_task.transition!(:completed, value: ctx)
231
+ end
232
+ end
233
+ result_task
234
+ end
235
+ private_class_method :calling_llm_action
236
+
237
+ def self.calling_llm_stream_action(agent, on_event, ctx)
238
+ user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
239
+ # Streaming requires a background thread because chunk_queue.pop is a
240
+ # blocking drain loop that must not run on the EventLoop thread.
241
+ # The on_complete pattern used in calling_llm_action cannot be applied
242
+ # here because tokens must be delivered incrementally via on_event
243
+ # before the final response arrives. This spawn is therefore
244
+ # intentional and classified under ADR-010 Rule 2 (blocking loop).
245
+ Phronomy::Runtime.instance.spawn(name: "agent-llm-stream:#{ctx.thread_id}") do
246
+ adapter = Phronomy.configuration.llm_adapter
247
+ chunk_queue = Phronomy::Concurrency::AsyncQueue.new(
248
+ max_size: Phronomy.configuration.stream_queue_max_size
249
+ )
250
+ pending = adapter.stream_async(
251
+ ctx.chat, user_message,
252
+ config: ctx.config,
253
+ enqueue_to: chunk_queue
254
+ )
255
+ loop do
256
+ chunk = chunk_queue.pop
257
+ break if chunk.nil?
258
+ on_event.call(Phronomy::Agent::StreamEvent.new(
259
+ type: :token, payload: {content: chunk.content}
260
+ ))
261
+ end
262
+ response = pending.blocking_wait
263
+ ctx.user_message_sent = true
264
+ ctx.output = response.content
265
+ ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
266
+ ctx.messages = ctx.chat.messages
267
+ ctx.tool_call_pending = false
268
+ ctx
269
+ end
270
+ end
271
+ private_class_method :calling_llm_stream_action
272
+
273
+ def self.executing_tool_action(agent, ctx)
274
+ tc = ctx.pending_tool_call
275
+ tool_instance = ctx.chat.tools[tc.name.to_sym]
276
+
277
+ unless tool_instance
278
+ # Tool not found — inject an error result and continue the LLM loop.
279
+ ctx.chat.add_message(
280
+ role: :tool,
281
+ content: "Tool not found.",
282
+ tool_call_id: tc.id
283
+ )
284
+ ctx.pending_tool_call = nil
285
+ ctx.tool_call_pending = false
286
+ ctx.approval_required = false
287
+ return ctx
288
+ end
289
+
290
+ if tool_instance.requires_approval && !ctx.sync_approval_handler
291
+ if ctx.approved
292
+ # Human approved via Agent.approve — execute the tool and continue.
293
+ ctx.approved = false # consume the approval flag
294
+ else
295
+ # No sync handler and not yet approved — suspend for HITL.
296
+ ctx.approval_required = true
297
+ return ctx
298
+ end
299
+ end
300
+
301
+ # Dispatch the tool off the EventLoop thread via ToolExecutor, which
302
+ # routes based on the tool's execution_mode class attribute:
303
+ # :blocking_io (default) → BlockingAdapterPool (bounded thread pool)
304
+ # :cooperative → Runtime.instance.spawn (scheduler task)
305
+ # Wrap the awaitable in Task.deferred so FSMSession recognises it as
306
+ # an async action and sets async_pending = true.
307
+ tc_id = tc.id
308
+ tc_args = tc.arguments
309
+ tc_name = tc.name
310
+ ct = ctx.config[:cancellation_token]
311
+ awaitable = tool_instance.call_async(tc_args, cancellation_token: ct)
312
+ result_task = Phronomy::Task.deferred(name: "tool-exec:#{tc_name}")
313
+ awaitable.on_complete do |result, error|
314
+ if error
315
+ result_task.backend.unblock(nil, error)
316
+ result_task.transition!(:failed, error: error)
317
+ else
318
+ ctx.chat.add_message(
319
+ role: :tool,
320
+ content: result.to_s,
321
+ tool_call_id: tc_id
322
+ )
323
+ ctx.pending_tool_call = nil
324
+ ctx.tool_call_pending = false
325
+ ctx.approval_required = false
326
+ result_task.backend.unblock(ctx, nil)
327
+ result_task.transition!(:completed, value: ctx)
328
+ end
329
+ end
330
+ result_task
331
+ end
332
+ private_class_method :executing_tool_action
333
+
334
+ def self.output_filtering_action(agent, ctx)
335
+ begin
336
+ ctx.output = agent.send(:run_output_filters!, ctx.output)
337
+ rescue Phronomy::FilterBlockError => e
338
+ ctx.output_blocked = true
339
+ ctx.block_error = e
340
+ end
341
+ ctx
342
+ end
343
+ private_class_method :output_filtering_action
344
+ end
345
+ end
346
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Builds the state_machines-backed PhaseTracker class for Agent invocations.
8
+ #
9
+ # This is the Agent counterpart to Workflow::PhaseMachineBuilder.
10
+ # Unlike the Workflow version (which builds a dynamic graph from user-defined
11
+ # DSL), this always generates the same fixed graph representing the
12
+ # Agent invoke execution phases.
13
+ #
14
+ # The generated class holds a single +:phase+ state machine with:
15
+ # - One automatic event (+:state_completed+) that FSMSession fires after
16
+ # each entry action completes.
17
+ # - Two external events (+:approve+, +:reject+) for HITL.
18
+ # - after_transition callbacks for each state's entry actions.
19
+ #
20
+ # Guard methods (+input_passed?+, +tool_call_pending?+, etc.) are delegated
21
+ # to the +InvocationContext+ stored in +attr_accessor :context+.
22
+ #
23
+ # @api private
24
+ class PhaseMachineBuilder
25
+ # @param entry_actions [Hash{Symbol => Array<#call>}]
26
+ # @param action_timeouts [Hash{Symbol => Numeric}]
27
+ # @api private
28
+ def initialize(entry_actions: {}, action_timeouts: {})
29
+ @entry_actions = entry_actions
30
+ @action_timeouts = action_timeouts
31
+ end
32
+
33
+ # Builds and returns the PhaseTracker class.
34
+ # @return [Class]
35
+ # @api private
36
+ def build
37
+ entry_acts = @entry_actions
38
+ act_timeouts = @action_timeouts
39
+ build_cb = method(:build_entry_callback)
40
+
41
+ Class.new do
42
+ # state_machines requires a class-level state machine definition.
43
+ state_machine :phase, initial: :idle do
44
+ # ----------------------------------------------------------------
45
+ # State declarations
46
+ # ----------------------------------------------------------------
47
+ state :idle
48
+ state :filtering_input
49
+ state :building_context
50
+ state :calling_llm
51
+ state :executing_tool
52
+ state :awaiting_approval # wait_state: external event required
53
+ state :output_filtering
54
+ state :completed # terminal
55
+ state :blocked # terminal
56
+
57
+ # ----------------------------------------------------------------
58
+ # Automatic transitions (fired by FSMSession on state_completed)
59
+ # Guards are evaluated on the InvocationContext via #context.
60
+ # ----------------------------------------------------------------
61
+ event :state_completed do
62
+ # idle → filtering_input (unconditional)
63
+ transition idle: :filtering_input
64
+
65
+ # filtering_input → building_context | blocked
66
+ transition filtering_input: :building_context, if: ->(m) { m.context&.input_passed? }
67
+ transition filtering_input: :blocked, if: ->(m) { m.context&.input_blocked? }
68
+
69
+ # building_context → calling_llm (unconditional)
70
+ transition building_context: :calling_llm
71
+
72
+ # calling_llm → executing_tool | output_filtering
73
+ transition calling_llm: :executing_tool, if: ->(m) { m.context&.tool_call_pending? }
74
+ transition calling_llm: :output_filtering
75
+
76
+ # executing_tool → awaiting_approval | calling_llm
77
+ transition executing_tool: :awaiting_approval, if: ->(m) { m.context&.approval_required? }
78
+ transition executing_tool: :calling_llm
79
+
80
+ # output_filtering → completed | blocked
81
+ transition output_filtering: :completed, if: ->(m) { m.context&.output_passed? }
82
+ transition output_filtering: :blocked, if: ->(m) { m.context&.output_blocked? }
83
+ end
84
+
85
+ # ----------------------------------------------------------------
86
+ # External events (human-in-the-loop)
87
+ # ----------------------------------------------------------------
88
+ event :approve do
89
+ transition awaiting_approval: :executing_tool
90
+ end
91
+
92
+ event :reject do
93
+ transition awaiting_approval: :blocked
94
+ end
95
+
96
+ # ----------------------------------------------------------------
97
+ # Entry action after_transition callbacks
98
+ # Each state's callables are fired after entering that state.
99
+ # ----------------------------------------------------------------
100
+ entry_acts.each do |state_name, callables|
101
+ callables.each do |callable|
102
+ timeout_secs = act_timeouts[state_name]
103
+ cb = build_cb.call(callable, state_name, timeout_secs)
104
+ after_transition to: state_name, do: cb
105
+ end
106
+ end
107
+ end
108
+
109
+ # Holds the InvocationContext so guard lambdas can access it.
110
+ attr_accessor :context
111
+
112
+ # async_pending flag: set when an entry action returns a Task.
113
+ attr_accessor :async_pending
114
+
115
+ # FSM session id — set by FSMSession so async task spawns know the
116
+ # target_id for EventLoop events.
117
+ attr_accessor :session_id
118
+
119
+ def initialize
120
+ super
121
+ @context = nil
122
+ @async_pending = false
123
+ @session_id = nil
124
+ end
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # Returns a proc suitable for use as an after_transition callback.
131
+ # @api private
132
+ def build_entry_callback(callable, state_name, timeout_secs)
133
+ handle = method(:handle_entry_action_result)
134
+ ->(machine) {
135
+ result = callable.call(machine.context)
136
+ handle.call(machine, result, state_name, timeout_secs)
137
+ }
138
+ end
139
+
140
+ # Dispatches the return value of an entry action.
141
+ # - Task → async: set async_pending and spawn a background task
142
+ # - context → sync: update machine.context
143
+ # @api private
144
+ def handle_entry_action_result(machine, result, state_name, timeout_secs)
145
+ if result.is_a?(Phronomy::Task)
146
+ dispatch_task(machine, result, state_name, timeout_secs)
147
+ elsif result.respond_to?(:set_graph_metadata)
148
+ machine.context = result
149
+ end
150
+ end
151
+
152
+ # Marks the machine async-pending and spawns a Task to await the result.
153
+ # @api private
154
+ def dispatch_task(machine, result, state_name, timeout_secs)
155
+ machine.async_pending = true
156
+ session_id = machine.session_id
157
+ if timeout_secs
158
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
159
+ next if result.done?
160
+
161
+ Phronomy::EventLoop.instance.post(
162
+ Phronomy::Event.new(
163
+ type: :error,
164
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
165
+ payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
166
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
167
+ )}
168
+ )
169
+ )
170
+ end
171
+ end
172
+ result.on_complete do |task_result, error|
173
+ if error
174
+ Phronomy::EventLoop.instance.post(
175
+ Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
176
+ )
177
+ next
178
+ end
179
+ ev = if task_result.respond_to?(:set_graph_metadata)
180
+ Phronomy::Event.new(type: :action_completed, target_id: session_id, payload: task_result)
181
+ else
182
+ Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
183
+ end
184
+ Phronomy::EventLoop.instance.post(ev)
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # In-process registry for Agent invocations suspended at :awaiting_approval.
6
+ #
7
+ # When an agent invocation halts waiting for human approval, the
8
+ # InvocationContext is stored here keyed by session_id.
9
+ # Agent::Base.approve / Agent::Base.reject look up and remove the context
10
+ # to build a resume session.
11
+ #
12
+ # Thread-safe. Each process has one shared instance via module methods.
13
+ # Cross-process persistence is out of scope (future SessionStore feature).
14
+ #
15
+ # @api private
16
+ module SuspendedSessionRegistry
17
+ @sessions = {}
18
+ @mutex = Mutex.new
19
+
20
+ # Stores a suspended context under the given session_id.
21
+ # @param session_id [String]
22
+ # @param ctx [Phronomy::Agent::InvocationContext]
23
+ # @return [void]
24
+ # @api private
25
+ def self.store(session_id, ctx)
26
+ @mutex.synchronize { @sessions[session_id] = ctx }
27
+ end
28
+
29
+ # Retrieves and removes the suspended context for session_id.
30
+ # Returns nil when no matching session exists.
31
+ # @param session_id [String]
32
+ # @return [Phronomy::Agent::InvocationContext, nil]
33
+ # @api private
34
+ def self.fetch(session_id)
35
+ @mutex.synchronize { @sessions.delete(session_id) }
36
+ end
37
+
38
+ # Returns true when a session is suspended under the given id.
39
+ # @param session_id [String]
40
+ # @return [Boolean]
41
+ # @api private
42
+ def self.exists?(session_id)
43
+ @mutex.synchronize { @sessions.key?(session_id) }
44
+ end
45
+
46
+ # Clears all suspended sessions. Intended for test teardown only.
47
+ # @return [void]
48
+ # @api private
49
+ def self.clear!
50
+ @mutex.synchronize { @sessions.clear }
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Raised inside the on_tool_call hook registered by InvocationSession
6
+ # to intercept every tool call before RubyLLM executes it.
7
+ #
8
+ # Catching this exception in calling_llm_action lets the Agent FSM
9
+ # route through :executing_tool (and possibly :awaiting_approval) rather
10
+ # than executing the tool inside RubyLLM's internal loop.
11
+ #
12
+ # This class is intentionally NOT part of the public API.
13
+ # @api private
14
+ class ToolCallIntercepted < StandardError
15
+ # @return [Object] the RubyLLM tool_call object (responds to #name, #arguments, #id)
16
+ attr_reader :tool_call
17
+
18
+ # @param tool_call [Object] the RubyLLM tool_call object
19
+ # @api private
20
+ def initialize(tool_call)
21
+ super("Tool call intercepted: #{tool_call.name}")
22
+ @tool_call = tool_call
23
+ end
24
+ end
25
+ end
26
+ end
@@ -28,11 +28,6 @@ module Phronomy
28
28
  # Recursion limit for graph execution (default: 25)
29
29
  attr_accessor :recursion_limit
30
30
 
31
- # When true, workflow execution is driven by EventLoop instead of a
32
- # synchronous loop in the calling thread. Defaults to false (sync mode).
33
- # @see Phronomy::EventLoop
34
- attr_accessor :event_loop
35
-
36
31
  # When true, agent LLM calls use {Phronomy::MultiAgent::ParallelToolChat}
37
32
  # for concurrent tool dispatch within a single agent turn.
38
33
  # Defaults to false.
@@ -197,7 +192,6 @@ module Phronomy
197
192
  @recursion_limit = 25
198
193
  @tracer = Phronomy::Tracing::NullTracer.new
199
194
  @trace_pii = false
200
- @event_loop = false
201
195
  @parallel_tool_execution = false
202
196
  @event_loop_stop_grace_seconds = 5
203
197
  @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new