phronomy 0.14.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +236 -57
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +471 -0
  14. data/lib/phronomy/agent/base.rb +500 -411
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -7
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
@@ -1,352 +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::Runtime.instance.event_loop.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
- runtime: Phronomy::Runtime.instance)
61
- ctx = Agent::InvocationContext.new(
62
- agent: agent,
63
- input: input,
64
- messages: messages,
65
- config: config
66
- )
67
-
68
- actions = (mode == :stream && on_event) ?
69
- build_stream_entry_actions(agent, on_event) :
70
- build_entry_actions(agent)
71
-
72
- # Calculate recursion_limit for the FSM:
73
- # Base states: idle→filtering_input→building_context→calling_llm→
74
- # output_filtering→completed = 6 transitions
75
- # Each tool call loop: calling_llm→executing_tool→calling_llm = 2 transitions
76
- # Safety margin: +4
77
- iterations = agent.class.max_iterations || 10
78
- fsm_recursion_limit = 6 + (iterations * 2) + 4
79
-
80
- # Entry actions are registered as after_transition callbacks in the
81
- # phase machine class. Pass empty hash to FSMSession (it uses @entry_actions
82
- # only for the entry_point state, which has no action for :idle).
83
- phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
84
- session_id = config[:thread_id] || SecureRandom.uuid
85
-
86
- Phronomy::FSMSession.new(
87
- id: session_id,
88
- context: ctx,
89
- entry_point: :idle,
90
- phase_machine_class: phase_machine,
91
- entry_actions: {},
92
- auto_state_set: AUTO_STATE_SET,
93
- declared_states: DECLARED_STATES,
94
- wait_state_names: %i[awaiting_approval],
95
- external_events: {
96
- approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
97
- reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
98
- },
99
- recursion_limit: fsm_recursion_limit,
100
- event_loop: runtime.event_loop,
101
- timer_queue_provider: -> { runtime.timer_queue }
102
- )
103
- end
104
-
105
- # Builds a FSMSession that resumes an existing InvocationContext
106
- # from a wait state (e.g. :awaiting_approval) using an external event.
107
- #
108
- # @param agent [Phronomy::Agent::Base]
109
- # @param context [Phronomy::Agent::InvocationContext] suspended context
110
- # @param resume_event [Symbol] e.g. :approve or :reject
111
- # @param resume_phase [Symbol] the wait state to resume from
112
- # @return [Phronomy::FSMSession]
113
- # @api private
114
- def self.build_for_resume(agent:, context:, resume_event:, resume_phase:,
115
- runtime: Phronomy::Runtime.instance)
116
- actions = build_entry_actions(agent)
117
- phase_machine = Agent::PhaseMachineBuilder.new(entry_actions: actions).build
118
-
119
- iterations = agent.class.max_iterations || 10
120
- fsm_recursion_limit = 6 + (iterations * 2) + 4
121
-
122
- Phronomy::FSMSession.new(
123
- id: context.session_id || SecureRandom.uuid,
124
- context: context,
125
- entry_point: :idle,
126
- phase_machine_class: phase_machine,
127
- entry_actions: {},
128
- auto_state_set: AUTO_STATE_SET,
129
- declared_states: DECLARED_STATES,
130
- wait_state_names: %i[awaiting_approval],
131
- external_events: {
132
- approve: [{from: :awaiting_approval, to: :executing_tool, guard: nil}],
133
- reject: [{from: :awaiting_approval, to: :blocked, guard: nil}]
134
- },
135
- recursion_limit: fsm_recursion_limit,
136
- event_loop: runtime.event_loop,
137
- timer_queue_provider: -> { runtime.timer_queue },
138
- resume_event: resume_event,
139
- resume_phase: resume_phase
140
- )
141
- end
142
-
143
- # ---------------------------------------------------------------------------
144
- # Entry action builders
145
- # ---------------------------------------------------------------------------
146
-
147
- # @api private
148
- def self.build_entry_actions(agent)
149
- {
150
- # :idle has no action — FSMSession auto-transitions to :filtering_input
151
- filtering_input: [method(:filtering_input_action).curry.call(agent)],
152
- building_context: [method(:building_context_action).curry.call(agent)],
153
- calling_llm: [method(:calling_llm_action).curry.call(agent)],
154
- executing_tool: [method(:executing_tool_action).curry.call(agent)],
155
- output_filtering: [method(:output_filtering_action).curry.call(agent)]
156
- }
157
- end
158
- private_class_method :build_entry_actions
159
-
160
- # @api private
161
- def self.build_stream_entry_actions(agent, on_event)
162
- build_entry_actions(agent).merge(
163
- calling_llm: [method(:calling_llm_stream_action).curry.call(agent, on_event)]
164
- )
165
- end
166
- private_class_method :build_stream_entry_actions
167
-
168
- # ----------------------------------------------------------------
169
- # Individual entry action implementations
170
- # ----------------------------------------------------------------
171
-
172
- def self.filtering_input_action(agent, ctx)
173
- begin
174
- ctx.input = agent.send(:run_input_filters!, ctx.input)
175
- rescue Phronomy::FilterBlockError => e
176
- ctx.input_blocked = true
177
- ctx.block_error = e
178
- end
179
- ctx
180
- end
181
- private_class_method :filtering_input_action
182
-
183
- def self.building_context_action(agent, ctx)
184
- ctx.chat = agent.send(:build_chat)
185
- context = agent.send(
186
- :build_context,
187
- ctx.input,
188
- messages: ctx.messages,
189
- thread_id: ctx.thread_id,
190
- config: ctx.config,
191
- budget: agent.send(:build_token_budget),
192
- instruction: agent.send(:build_instructions, ctx.input),
193
- tools: agent.class.tools + agent.send(:_handoff_tools)
194
- )
195
- agent.send(:_apply_context_to_chat, ctx.chat, context)
196
- # Run before-completion hooks (e.g. memory injection) once per invocation.
197
- agent.send(:run_before_completion_hooks!, ctx.chat, ctx.config)
198
- # Register the tool-call interceptor so every tool call routes through
199
- # :executing_tool in the FSM instead of executing inside RubyLLM's loop.
200
- ctx.chat.on_tool_call do |tool_call|
201
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
202
- end
203
- ctx
204
- end
205
- private_class_method :building_context_action
206
-
207
- def self.calling_llm_action(agent, ctx)
208
- # Returns a Task.deferred — no extra OS thread is created.
209
- # The BlockingAdapterPool worker thread completes the LLM call and
210
- # resolves result_task via on_complete, which then triggers the
211
- # FSMSession's dispatch_task_in_event_loop on_complete callback to
212
- # post :action_completed back to the EventLoop.
213
- user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
214
- agent.send(:check_cancellation!, ctx.config, "invocation cancelled before LLM call")
215
- adapter = Phronomy.configuration.llm_adapter
216
- op = adapter.complete_async(ctx.chat, user_message, config: ctx.config)
217
- result_task = Phronomy::Task.deferred(name: "agent-llm:#{ctx.thread_id}")
218
- op.on_complete do |response, error|
219
- if error.is_a?(Phronomy::Agent::ToolCallIntercepted)
220
- ctx.user_message_sent = true
221
- ctx.pending_tool_call = error.tool_call
222
- ctx.tool_call_pending = true
223
- ctx.messages = ctx.chat.messages
224
- result_task.backend.unblock(ctx, nil)
225
- result_task.transition!(:completed, value: ctx)
226
- elsif error
227
- result_task.backend.unblock(nil, error)
228
- result_task.transition!(:failed, error: error)
229
- else
230
- ctx.user_message_sent = true
231
- ctx.output = response.content
232
- ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
233
- ctx.messages = ctx.chat.messages
234
- ctx.tool_call_pending = false
235
- result_task.backend.unblock(ctx, nil)
236
- result_task.transition!(:completed, value: ctx)
237
- end
238
- end
239
- result_task
240
- end
241
- private_class_method :calling_llm_action
242
-
243
- def self.calling_llm_stream_action(agent, on_event, ctx)
244
- user_message = ctx.user_message_sent ? nil : agent.send(:extract_message, ctx.input)
245
- # Streaming requires a background thread because chunk_queue.pop is a
246
- # blocking drain loop that must not run on the EventLoop thread.
247
- # The on_complete pattern used in calling_llm_action cannot be applied
248
- # here because tokens must be delivered incrementally via on_event
249
- # before the final response arrives. This spawn is therefore
250
- # intentional and classified under ADR-010 Rule 2 (blocking loop).
251
- Phronomy::Runtime.instance.spawn(name: "agent-llm-stream:#{ctx.thread_id}") do
252
- adapter = Phronomy.configuration.llm_adapter
253
- chunk_queue = Phronomy::Concurrency::AsyncQueue.new(
254
- max_size: Phronomy.configuration.stream_queue_max_size
255
- )
256
- pending = adapter.stream_async(
257
- ctx.chat, user_message,
258
- config: ctx.config,
259
- enqueue_to: chunk_queue
260
- )
261
- loop do
262
- chunk = chunk_queue.pop
263
- break if chunk.nil?
264
- on_event.call(Phronomy::Agent::StreamEvent.new(
265
- type: :token, payload: {content: chunk.content}
266
- ))
267
- end
268
- response = pending.blocking_wait
269
- ctx.user_message_sent = true
270
- ctx.output = response.content
271
- ctx.usage = Phronomy::TokenUsage.from_tokens(response.tokens)
272
- ctx.messages = ctx.chat.messages
273
- ctx.tool_call_pending = false
274
- ctx
275
- end
276
- end
277
- private_class_method :calling_llm_stream_action
278
-
279
- def self.executing_tool_action(agent, ctx)
280
- tc = ctx.pending_tool_call
281
- tool_instance = ctx.chat.tools[tc.name.to_sym]
282
-
283
- unless tool_instance
284
- # Tool not found — inject an error result and continue the LLM loop.
285
- ctx.chat.add_message(
286
- role: :tool,
287
- content: "Tool not found.",
288
- tool_call_id: tc.id
289
- )
290
- ctx.pending_tool_call = nil
291
- ctx.tool_call_pending = false
292
- ctx.approval_required = false
293
- return ctx
294
- end
295
-
296
- if tool_instance.requires_approval && !ctx.sync_approval_handler
297
- if ctx.approved
298
- # Human approved via Agent.approve — execute the tool and continue.
299
- ctx.approved = false # consume the approval flag
300
- else
301
- # No sync handler and not yet approved — suspend for HITL.
302
- ctx.approval_required = true
303
- return ctx
304
- end
305
- end
306
-
307
- # Dispatch the tool off the EventLoop thread via ToolExecutor, which
308
- # routes based on the tool's execution_mode class attribute:
309
- # :blocking_io (default) → BlockingAdapterPool (bounded thread pool)
310
- # :cooperative → Runtime.instance.spawn (scheduler task)
311
- # Wrap the awaitable in Task.deferred so FSMSession recognises it as
312
- # an async action and sets async_pending = true.
313
- tc_id = tc.id
314
- tc_args = tc.arguments
315
- tc_name = tc.name
316
- ct = ctx.config[:cancellation_token]
317
- awaitable = tool_instance.call_async(tc_args, cancellation_token: ct, config: ctx.config)
318
- result_task = Phronomy::Task.deferred(name: "tool-exec:#{tc_name}")
319
- awaitable.on_complete do |result, error|
320
- if error
321
- result_task.backend.unblock(nil, error)
322
- result_task.transition!(:failed, error: error)
323
- else
324
- ctx.chat.add_message(
325
- role: :tool,
326
- content: result.to_s,
327
- tool_call_id: tc_id
328
- )
329
- ctx.pending_tool_call = nil
330
- ctx.tool_call_pending = false
331
- ctx.approval_required = false
332
- result_task.backend.unblock(ctx, nil)
333
- result_task.transition!(:completed, value: ctx)
334
- end
335
- end
336
- result_task
337
- end
338
- private_class_method :executing_tool_action
339
-
340
- def self.output_filtering_action(agent, ctx)
341
- begin
342
- ctx.output = agent.send(:run_output_filters!, ctx.output)
343
- rescue Phronomy::FilterBlockError => e
344
- ctx.output_blocked = true
345
- ctx.block_error = e
346
- end
347
- ctx
348
- end
349
- private_class_method :output_filtering_action
350
- end
351
- end
352
- 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