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
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- module Concerns
8
- # Adds suspend/resume and tool-approval support to an agent.
9
- #
10
- # Included in {Phronomy::Agent::Base}. When a tool decorated with
11
- # +requires_approval true+ is called and no synchronous approval handler
12
- # has been registered, the invocation is suspended and a
13
- # @api private
14
- # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
15
- module Suspendable
16
- # Registers a callback that is invoked before executing any tool that has
17
- # +requires_approval true+ set. The block receives the tool name (String)
18
- # and the arguments Hash, and must return a truthy value to allow execution.
19
- # Returning a falsy value causes the tool to return a denial message instead
20
- # of executing.
21
- #
22
- # When no handler is registered and a tool with +requires_approval+ is
23
- # called, #invoke returns a suspended result hash containing a
24
- # {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
25
- # obtaining an approval decision from the user or an external system.
26
- #
27
- # @example Synchronous handler
28
- # agent = MyAgent.new
29
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
30
- # @return [self]
31
- # @api private
32
- def on_approval_required(&block)
33
- @approval_handler = block
34
- self
35
- end
36
-
37
- # Registers a scope policy callable for this agent instance.
38
- #
39
- # The callable receives +(tool_class, scope, agent)+ and must return
40
- # +:allow+, +:reject+, or +:approve+.
41
- #
42
- # @example Reject all write-scoped tools
43
- # agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
44
- #
45
- # @param policy [#call]
46
- # @return [void]
47
- # @api public
48
- def scope_policy=(policy)
49
- @scope_policy = policy
50
- end
51
-
52
- # Sets the idempotency store used to guard against duplicate resumes.
53
- #
54
- # The store must respond to:
55
- # - +consumed?(checkpoint_id)+ ⇒ Boolean
56
- # - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
57
- #
58
- # Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
59
- # Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
60
- # Custom stores are responsible for ensuring thread-safety if shared across threads.
61
- #
62
- # @param store [#consumed?, #consume!]
63
- # @return [void]
64
- # @api public
65
- def checkpoint_store=(store)
66
- @checkpoint_store = store
67
- end
68
-
69
- # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
70
- #
71
- # This method reconstructs the conversation state captured at suspension
72
- # time, injects the tool result (executed or denied), and continues the
73
- # LLM loop until it produces a final answer.
74
- #
75
- # @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
76
- # the suspended #invoke call
77
- # @param approved [Boolean] +true+ to execute the pending tool; +false+
78
- # to inject a denial message and let the LLM handle it gracefully
79
- # @param config [Hash] same runtime options as #invoke
80
- # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
81
- # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
82
- # when a second approval-required tool is encountered during continuation
83
- # @raise [Phronomy::FilterBlockError] when an output filter rejects the value
84
- # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
85
- # @api private
86
- def resume(checkpoint, approved:, config: {})
87
- # Guard against duplicate resumes using the idempotency store.
88
- _checkpoint_store.consume!(checkpoint.checkpoint_id)
89
- # Build a fresh chat with all tools registered.
90
- chat = build_chat
91
-
92
- # Re-apply system instructions and register tools so the LLM has the
93
- # same persona/context as the original invocation. build_context
94
- # includes all tool classes (static + handoff) via add_capability.
95
- context = build_context(checkpoint.original_input, messages: [])
96
- apply_instructions(chat, context[:system]) if context[:system]
97
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
98
-
99
- # Restore the full conversation (history + user + assistant with tool call).
100
- checkpoint.messages.each { |msg| chat.messages << msg }
101
-
102
- # Determine the tool result: execute it or inject a denial string.
103
- tool_result =
104
- if approved
105
- tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
106
- tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
107
- else
108
- "Tool execution denied."
109
- end
110
-
111
- # Inject the tool result so the LLM can continue.
112
- chat.add_message(
113
- role: :tool,
114
- content: tool_result.to_s,
115
- tool_call_id: checkpoint.pending_tool_call_id
116
- )
117
-
118
- # Re-register the suspension hook so that any further requires_approval
119
- # tools encountered during continuation are intercepted rather than
120
- # executed without approval (cascading / chained approval scenario).
121
- _register_suspension_hook!(chat)
122
-
123
- # Continue the LLM loop. Rescue SuspendSignal so that a second
124
- # approval-required tool produces a new checkpoint instead of running
125
- # without consent.
126
- begin
127
- response = chat.complete
128
- rescue SuspendSignal => signal
129
- new_checkpoint = Checkpoint.new(
130
- checkpoint_id: SecureRandom.uuid,
131
- agent_class: self.class.name,
132
- requested_at: Time.now.utc,
133
- thread_id: checkpoint.thread_id,
134
- original_input: checkpoint.original_input,
135
- messages: chat.messages.dup,
136
- pending_tool_name: signal.tool_name,
137
- pending_tool_args: signal.args,
138
- pending_tool_call_id: signal.tool_call_id
139
- )
140
- return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
141
- end
142
-
143
- output = response.content
144
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
145
-
146
- output = run_output_filters!(output)
147
-
148
- {output: output, suspended: false, messages: chat.messages, usage: usage}
149
- end
150
-
151
- private
152
-
153
- # Registers an on_tool_call hook on the chat object that raises SuspendSignal
154
- # when an approval-required tool is about to be executed and no synchronous
155
- # on_approval_required handler has been registered.
156
- #
157
- # Does nothing when:
158
- # - a synchronous handler is already registered (@approval_handler is set), or
159
- # - none of the agent's tools have requires_approval set.
160
- #
161
- # @param chat [RubyLLM::Chat]
162
- # @api private
163
- def _register_suspension_hook!(chat)
164
- return if @approval_handler
165
- return if self.class.tools.none? { |tc| tc.requires_approval }
166
-
167
- chat.on_tool_call do |tool_call|
168
- tool_instance = chat.tools[tool_call.name.to_sym]
169
- if tool_instance&.requires_approval
170
- raise SuspendSignal.new(
171
- tool_name: tool_call.name,
172
- args: tool_call.arguments,
173
- tool_call_id: tool_call.id
174
- )
175
- end
176
- end
177
- end
178
-
179
- # Returns the checkpoint idempotency store for this instance, lazily
180
- # initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
181
- #
182
- # @return [#consumed?, #consume!]
183
- # @api private
184
- def _checkpoint_store
185
- @checkpoint_store ||= CheckpointStore.new
186
- end
187
- end
188
- end
189
- end
190
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Raised internally inside the on_tool_call hook when an approval-required
6
- # tool is encountered and no synchronous on_approval_required handler has
7
- # been registered. Caught by Agent::Base#invoke_once to produce a
8
- # suspended result hash containing a Checkpoint.
9
- #
10
- # This class is intentionally NOT part of the public API. Callers should
11
- # @api private
12
- # inspect the +:suspended+ key in the result hash returned by #invoke.
13
- #
14
- # @api private
15
- class SuspendSignal < StandardError
16
- # @return [String] the name of the tool that triggered the suspension
17
- attr_reader :tool_name
18
-
19
- # @return [Hash] the arguments the LLM passed to the tool
20
- attr_reader :args
21
-
22
- # @return [String] the tool_call_id from the LLM response
23
- attr_reader :tool_call_id
24
-
25
- # @param tool_name [String]
26
- # @param args [Hash]
27
- # @param tool_call_id [String]
28
- # @api private
29
- def initialize(tool_name:, args:, tool_call_id:)
30
- super("Agent suspended waiting for approval of tool: #{tool_name}")
31
- @tool_name = tool_name
32
- @args = args
33
- @tool_call_id = tool_call_id
34
- end
35
- end
36
- end
37
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Context management utilities: token estimation, budget calculation, and
5
- # context assembly.
6
- #
7
- # Sub-modules are auto-loaded by Zeitwerk:
8
- # Phronomy::LlmContextWindow::TokenEstimator
9
- # Phronomy::LlmContextWindow::TokenBudget
10
- module Context
11
- end
12
- end
data/lib/phronomy/tool.rb DELETED
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This file is intentionally empty.
4
- # Tool definitions have moved to Phronomy::Agent::Context::Capability.
5
- # See lib/phronomy/agent/context/capability/.
6
- module Phronomy
7
- module Tool
8
- end
9
- end
@@ -1,249 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Workflow
5
- # Event-driven execution wrapper for a single workflow run.
6
- #
7
- # Created by WorkflowRunner and registered with EventLoop. All public methods
8
- # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
9
- # not be accessed concurrently from multiple threads.
10
- #
11
- # == Lifecycle
12
- #
13
- # register(session) → EventLoop posts :start → session.start
14
- # ↓ (auto-transition present)
15
- # EventLoop posts :state_completed → session.handle
16
- # ↓ (repeat)
17
- # session posts :finished or :halted
18
- # ↓
19
- # EventLoop pushes ctx to completion_queue → caller unblocks
20
- #
21
- # == Async IO pattern (EventLoop mode only)
22
- #
23
- # When a state has no auto-transition and is not a wait_state, but has an
24
- # external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
25
- # the FSMSession stays registered in the EventLoop and waits for that event.
26
- # The entry action is expected to spawn an IO thread that posts the event back:
27
- #
28
- # entry :fetching, ->(ctx) {
29
- # Thread.new {
30
- # ctx.result = http.get(ctx.url)
31
- # Phronomy::EventLoop.instance.post(
32
- # Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
33
- # )
34
- # }
35
- # }
36
- # transition from: :fetching, on: :fetch_done, to: :process
37
- class FSMSession
38
- FINISH = WorkflowRunner::FINISH
39
-
40
- # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
41
- attr_reader :id
42
-
43
- # @param id [String]
44
- # @param context [Object] includes Phronomy::WorkflowContext
45
- # @param entry_point [Symbol] initial state name
46
- # @param entry_actions [Hash] { state_name => [callable, ...] }
47
- # @param auto_state_set [Hash] { state_name => true }
48
- # @param declared_states [Array<Symbol>] all action state names
49
- # @param wait_state_names [Array<Symbol>]
50
- # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
51
- # @param phase_machine_class [Class] state_machines-backed phase tracker class
52
- # @param recursion_limit [Integer]
53
- # @param action_timeouts [Hash] { state_name => seconds }
54
- # @param resume_event [Symbol, nil] external event to fire when resuming
55
- # @param resume_phase [Symbol, nil] wait state name to resume from
56
- # @api private
57
- def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
58
- declared_states:, wait_state_names:, external_events:, phase_machine_class:,
59
- recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
60
- @id = id
61
- @ctx = context
62
- @entry_point = entry_point
63
- @entry_actions = entry_actions
64
- @auto_state_set = auto_state_set
65
- @declared_states = declared_states
66
- @wait_state_names = wait_state_names
67
- @external_events = external_events
68
- @phase_machine_class = phase_machine_class
69
- @recursion_limit = recursion_limit
70
- @action_timeouts = action_timeouts
71
- @resume_event = resume_event
72
- @resume_phase = resume_phase
73
- @step = 0
74
- @done = false
75
- @current_state = nil
76
- @tracker = nil
77
- end
78
-
79
- # Begins workflow execution. Called by EventLoop on :start event.
80
- def start
81
- if @resume_event
82
- # Resume from wait state: position tracker at the wait state, then fire the
83
- # external event. state_machines fires before_transition (exit) and
84
- # after_transition (entry) callbacks, so both actions execute here.
85
- @current_state = @resume_phase
86
- @tracker = build_tracker(@current_state)
87
- @tracker.context = @ctx
88
- fire_and_advance!(@resume_event)
89
- else
90
- # Fresh start: state_machines does not fire callbacks on initialization,
91
- # so we invoke the entry action for the initial state manually.
92
- @current_state = @entry_point
93
- @tracker = build_tracker(@current_state)
94
- @tracker.context = @ctx
95
- (@entry_actions[@current_state] || []).each do |c|
96
- result = c.call(@ctx)
97
- if result.is_a?(Phronomy::Task)
98
- # Awaitable action: spawn a task to await without blocking EventLoop.
99
- @tracker.async_pending = true
100
- session_id = @id
101
- current_state_name = @current_state
102
- timeout_secs = @action_timeouts[current_state_name]
103
- Phronomy::Runtime.instance.spawn(name: "fsm-await-#{session_id}") do
104
- if timeout_secs
105
- if result.join(timeout_secs).nil?
106
- result.cancel!
107
- raise Phronomy::ActionTimeoutError,
108
- "Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
109
- end
110
- end
111
- task_result = result.await
112
- if task_result.is_a?(Phronomy::WorkflowContext)
113
- event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
114
- else
115
- event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
116
- end
117
- rescue => e
118
- event_loop.post(Event.new(type: :error, target_id: session_id, payload: e))
119
- end
120
- break # Only one async action at a time per state
121
- elsif result.is_a?(Phronomy::WorkflowContext)
122
- @ctx = result
123
- end
124
- end
125
- @tracker.context = @ctx
126
- advance_or_halt unless @tracker.async_pending
127
- end
128
- rescue => e
129
- finish_with_error(e)
130
- end
131
-
132
- # Processes an event dispatched from EventLoop.
133
- # Called for :state_completed, :action_completed, and all user-defined external events.
134
- #
135
- # @param event [Phronomy::Event]
136
- # @api private
137
- def handle(event)
138
- return if @done
139
-
140
- if event.type == :action_completed
141
- # An awaitable entry action completed: update context and advance.
142
- @ctx = event.payload if event.payload.is_a?(Phronomy::WorkflowContext)
143
- @tracker.context = @ctx
144
- @tracker.async_pending = false # Reset flag set by start or fire_and_advance!
145
- advance_or_halt
146
- return
147
- end
148
-
149
- fire_and_advance!(event.type)
150
- rescue => e
151
- finish_with_error(e)
152
- end
153
-
154
- private
155
-
156
- # Fires event_name on the phase tracker, updates @current_state, then
157
- # calls advance_or_halt to decide what to do next.
158
- def fire_and_advance!(event_name)
159
- if @step >= @recursion_limit
160
- raise Phronomy::RecursionLimitError,
161
- "Recursion limit (#{@recursion_limit}) exceeded"
162
- end
163
-
164
- fire_event!(@tracker, event_name, @current_state)
165
- @ctx = @tracker.context
166
- next_phase = @tracker.phase.to_sym
167
- # When next_phase == @current_state, no transition matched → treat as terminal.
168
- @current_state = (next_phase == @current_state) ? FINISH : next_phase
169
- @step += 1
170
-
171
- # If an entry action returned a Task, the after_transition callback set
172
- # async_pending = true and spawned a thread. Skip advance_or_halt — the
173
- # background thread will post :action_completed or :state_completed.
174
- if @tracker.async_pending
175
- @tracker.async_pending = false
176
- return
177
- end
178
-
179
- advance_or_halt
180
- end
181
-
182
- # Determines the next action after the FSM has entered @current_state.
183
- def advance_or_halt
184
- return finish! if @current_state == FINISH
185
-
186
- if @wait_state_names.include?(@current_state)
187
- return halt!
188
- end
189
-
190
- if @auto_state_set.key?(@current_state)
191
- event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
192
- return
193
- end
194
-
195
- if has_external_event_from?(@current_state)
196
- # Async IO pattern: the entry action spawned an IO thread that will post
197
- # an external event back. Stay registered; do nothing here.
198
- return
199
- end
200
-
201
- # No transition declared — validate the state is known, then treat as terminal.
202
- unless @declared_states.include?(@current_state)
203
- raise ArgumentError, "State #{@current_state.inspect} is not defined"
204
- end
205
-
206
- finish!
207
- end
208
-
209
- def finish!
210
- @done = true
211
- @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
212
- event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
213
- end
214
-
215
- def halt!
216
- @done = true
217
- @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
218
- event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
219
- end
220
-
221
- def finish_with_error(err)
222
- @done = true
223
- event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
224
- end
225
-
226
- def fire_event!(tracker, event_name, from_state)
227
- return if tracker.send(event_name)
228
-
229
- raise ArgumentError,
230
- "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
231
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
232
- end
233
-
234
- def has_external_event_from?(state)
235
- @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
236
- end
237
-
238
- def build_tracker(from_state)
239
- machine = @phase_machine_class.new
240
- machine.instance_variable_set(:@phase, from_state.to_s)
241
- machine
242
- end
243
-
244
- def event_loop
245
- Phronomy::EventLoop.instance
246
- end
247
- end
248
- end
249
- end