phronomy 0.13.0 → 0.15.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/CHANGELOG.md +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -1,346 +0,0 @@
|
|
|
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, config: ctx.config)
|
|
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
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Concurrency
|
|
5
|
-
# A counting semaphore that enforces a concurrency cap across a named
|
|
6
|
-
# resource category (e.g. agent tasks, tool tasks, LLM calls).
|
|
7
|
-
#
|
|
8
|
-
# When +max_concurrent+ is +nil+ the gate is a no-op and all callers
|
|
9
|
-
# pass through immediately without acquiring a slot.
|
|
10
|
-
#
|
|
11
|
-
# Backpressure behaviour when the gate is full is controlled by the
|
|
12
|
-
# +on_full:+ keyword:
|
|
13
|
-
# +:reject+ — raise {Phronomy::BackpressureError} immediately
|
|
14
|
-
# +:wait+ — block the calling fiber/thread until a slot is free
|
|
15
|
-
# +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
|
|
16
|
-
# after +timeout:+ seconds if no slot becomes available
|
|
17
|
-
#
|
|
18
|
-
# @example
|
|
19
|
-
# gate = Phronomy::Concurrency::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
|
|
20
|
-
# gate.acquire(on_full: :reject) do
|
|
21
|
-
# run_agent_task
|
|
22
|
-
# end
|
|
23
|
-
class ConcurrencyGate
|
|
24
|
-
# @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
|
|
25
|
-
# @param name [Symbol, String, nil] human-readable label used in error messages
|
|
26
|
-
# @api private
|
|
27
|
-
def initialize(max_concurrent:, name: nil)
|
|
28
|
-
@max = max_concurrent
|
|
29
|
-
@name = name
|
|
30
|
-
@mutex = Mutex.new
|
|
31
|
-
@cond = ConditionVariable.new
|
|
32
|
-
@count = 0
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Returns the configured cap (or nil when unlimited).
|
|
36
|
-
attr_reader :max
|
|
37
|
-
|
|
38
|
-
# Returns the name label.
|
|
39
|
-
attr_reader :name
|
|
40
|
-
|
|
41
|
-
# Returns the number of slots currently in use.
|
|
42
|
-
def current_count
|
|
43
|
-
@mutex.synchronize { @count }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Acquires a slot, executes +block+, then releases the slot.
|
|
47
|
-
# When the gate is unlimited (max is nil) the block runs directly.
|
|
48
|
-
#
|
|
49
|
-
# @param on_full [:reject, :wait, :timeout] backpressure strategy
|
|
50
|
-
# @param timeout [Numeric, nil] seconds before +:timeout+ gives up
|
|
51
|
-
# @yield
|
|
52
|
-
# @return block return value
|
|
53
|
-
# @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
|
|
54
|
-
# @api private
|
|
55
|
-
def acquire(on_full: :wait, timeout: nil, &block)
|
|
56
|
-
return block.call if @max.nil?
|
|
57
|
-
|
|
58
|
-
_acquire_slot(on_full: on_full, timeout: timeout)
|
|
59
|
-
begin
|
|
60
|
-
block.call
|
|
61
|
-
ensure
|
|
62
|
-
_release_slot
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
private
|
|
67
|
-
|
|
68
|
-
def _acquire_slot(on_full:, timeout:)
|
|
69
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
70
|
-
if scheduler
|
|
71
|
-
_acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
|
|
72
|
-
else
|
|
73
|
-
_acquire_slot_threaded(on_full: on_full, timeout: timeout)
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
def _acquire_slot_coop(scheduler, on_full:, timeout:)
|
|
78
|
-
# In cooperative mode all tasks run on the same thread, so no mutex needed.
|
|
79
|
-
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
80
|
-
@coop_signal ||= scheduler.new_signal
|
|
81
|
-
|
|
82
|
-
loop do
|
|
83
|
-
if @count < @max
|
|
84
|
-
@count += 1
|
|
85
|
-
return
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
case on_full
|
|
89
|
-
when :reject
|
|
90
|
-
raise Phronomy::BackpressureError,
|
|
91
|
-
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
92
|
-
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
93
|
-
when :timeout
|
|
94
|
-
if deadline && scheduler.virtual_time >= deadline
|
|
95
|
-
raise Phronomy::BackpressureError,
|
|
96
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
97
|
-
end
|
|
98
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
99
|
-
if deadline && scheduler.virtual_time >= deadline
|
|
100
|
-
raise Phronomy::BackpressureError,
|
|
101
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
102
|
-
end
|
|
103
|
-
else # :wait
|
|
104
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def _acquire_slot_threaded(on_full:, timeout:)
|
|
110
|
-
deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
|
|
111
|
-
|
|
112
|
-
@mutex.synchronize do
|
|
113
|
-
loop do
|
|
114
|
-
if @count < @max
|
|
115
|
-
@count += 1
|
|
116
|
-
return
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
case on_full
|
|
120
|
-
when :reject
|
|
121
|
-
raise Phronomy::BackpressureError,
|
|
122
|
-
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
123
|
-
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
124
|
-
when :timeout
|
|
125
|
-
remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
|
|
126
|
-
if remaining && remaining <= 0
|
|
127
|
-
raise Phronomy::BackpressureError,
|
|
128
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
129
|
-
end
|
|
130
|
-
@cond.wait(@mutex, remaining || nil)
|
|
131
|
-
# re-check deadline after wakeup
|
|
132
|
-
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
133
|
-
raise Phronomy::BackpressureError,
|
|
134
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
135
|
-
end
|
|
136
|
-
else # :wait
|
|
137
|
-
@cond.wait(@mutex)
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def _release_slot
|
|
144
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
145
|
-
if scheduler && @coop_signal
|
|
146
|
-
@count -= 1
|
|
147
|
-
scheduler.raise_signal(@coop_signal)
|
|
148
|
-
else
|
|
149
|
-
@mutex.synchronize do
|
|
150
|
-
@count -= 1
|
|
151
|
-
@cond.signal
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Concurrency
|
|
5
|
-
# Lazy cache of {ConcurrencyGate} instances, keyed by resource name.
|
|
6
|
-
#
|
|
7
|
-
# Gate concurrency caps are read from {Phronomy::Configuration} when a gate
|
|
8
|
-
# is first accessed; subsequent calls return the cached instance. Call
|
|
9
|
-
# {#reset} to drop the cache and force a rebuild on the next access.
|
|
10
|
-
# @api private
|
|
11
|
-
class GateRegistry
|
|
12
|
-
GATE_CONFIG_MAP = {
|
|
13
|
-
agent: :max_concurrent_agent_tasks,
|
|
14
|
-
tool: :max_concurrent_tool_tasks,
|
|
15
|
-
workflow: :max_concurrent_workflow_tasks,
|
|
16
|
-
llm: :max_concurrent_llm_calls,
|
|
17
|
-
vector: :max_concurrent_vector_searches
|
|
18
|
-
}.freeze
|
|
19
|
-
private_constant :GATE_CONFIG_MAP
|
|
20
|
-
|
|
21
|
-
def initialize
|
|
22
|
-
@mutex = Mutex.new
|
|
23
|
-
@gates = {}
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Returns (or lazily creates) the gate for +name+.
|
|
27
|
-
# @param name [Symbol]
|
|
28
|
-
# @return [ConcurrencyGate]
|
|
29
|
-
# @api private
|
|
30
|
-
def get(name)
|
|
31
|
-
@mutex.synchronize { @gates[name] ||= _build(name) }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Drops the cached gate for +name+ so the next {#get} rebuilds it.
|
|
35
|
-
# @param name [Symbol]
|
|
36
|
-
# @return [void]
|
|
37
|
-
# @api private
|
|
38
|
-
def reset(name)
|
|
39
|
-
@mutex.synchronize { @gates.delete(name) }
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
def _build(name)
|
|
45
|
-
config_key = GATE_CONFIG_MAP[name]
|
|
46
|
-
max = config_key ? Phronomy.configuration.public_send(config_key) : nil
|
|
47
|
-
ConcurrencyGate.new(max_concurrent: max, name: name)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|