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.
- checksums.yaml +4 -4
- data/README.md +17 -8
- data/benchmark/bench_agent_invoke.rb +1 -0
- data/lib/phronomy/agent/base.rb +197 -83
- data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
- data/lib/phronomy/agent/concerns/retryable.rb +6 -2
- data/lib/phronomy/agent/invocation_context.rb +171 -0
- data/lib/phronomy/agent/invocation_session.rb +346 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
- data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
- data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
- data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
- data/lib/phronomy/engine/fsm_session.rb +248 -0
- data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
- data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
- data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
- data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
- data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
- data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
- data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
- data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
- data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
- data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
- data/lib/phronomy/generator_verifier.rb +20 -11
- data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
- data/lib/phronomy/tools/mcp.rb +17 -10
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
- data/lib/phronomy/workflow.rb +6 -3
- data/lib/phronomy/workflow_context.rb +7 -5
- data/lib/phronomy/workflow_runner.rb +77 -27
- data/lib/phronomy.rb +17 -9
- metadata +35 -35
- data/lib/phronomy/agent/checkpoint.rb +0 -208
- data/lib/phronomy/agent/checkpoint_store.rb +0 -97
- data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
- data/lib/phronomy/agent/suspend_signal.rb +0 -37
- data/lib/phronomy/context.rb +0 -12
- data/lib/phronomy/tool.rb +0 -9
- data/lib/phronomy/workflow/fsm_session.rb +0 -249
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
- /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
- /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
- /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
|