phronomy 0.9.0 → 0.10.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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  module Concerns
@@ -47,6 +49,23 @@ module Phronomy
47
49
  @scope_policy = policy
48
50
  end
49
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
+
50
69
  # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
51
70
  #
52
71
  # This method reconstructs the conversation state captured at suspension
@@ -59,9 +78,14 @@ module Phronomy
59
78
  # to inject a denial message and let the LLM handle it gracefully
60
79
  # @param config [Hash] same runtime options as #invoke
61
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
62
83
  # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
84
+ # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
63
85
  # @api private
64
86
  def resume(checkpoint, approved:, config: {})
87
+ # Guard against duplicate resumes using the idempotency store.
88
+ _checkpoint_store.consume!(checkpoint.checkpoint_id)
65
89
  # Build a fresh chat with all tools registered.
66
90
  chat = build_chat
67
91
 
@@ -91,8 +115,30 @@ module Phronomy
91
115
  tool_call_id: checkpoint.pending_tool_call_id
92
116
  )
93
117
 
94
- # Continue the React loop.
95
- response = chat.complete
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
96
142
 
97
143
  output = response.content
98
144
  usage = Phronomy::TokenUsage.from_tokens(response.tokens)
@@ -129,6 +175,15 @@ module Phronomy
129
175
  end
130
176
  end
131
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
132
187
  end
133
188
  end
134
189
  end
@@ -33,6 +33,18 @@ module Phronomy
33
33
  # @see Phronomy::EventLoop
34
34
  attr_accessor :event_loop
35
35
 
36
+ # When true, agent LLM calls use {Phronomy::MultiAgent::ParallelToolChat}
37
+ # for concurrent tool dispatch within a single agent turn.
38
+ # Defaults to false.
39
+ #
40
+ # Previously, this was automatically enabled when +event_loop+ was true.
41
+ # As of Phase 3, +parallel_tool_execution+ is a separate setting that must
42
+ # be explicitly enabled.
43
+ # @example
44
+ # Phronomy.configure { |c| c.parallel_tool_execution = true }
45
+ # @return [Boolean]
46
+ attr_accessor :parallel_tool_execution
47
+
36
48
  # When true, user input and LLM output are recorded in trace spans.
37
49
  # Defaults to false; set to true only in environments where PII capture is acceptable.
38
50
  # Set to false in privacy-sensitive environments to prevent PII from reaching
@@ -186,6 +198,7 @@ module Phronomy
186
198
  @tracer = Phronomy::Tracing::NullTracer.new
187
199
  @trace_pii = false
188
200
  @event_loop = false
201
+ @parallel_tool_execution = false
189
202
  @event_loop_stop_grace_seconds = 5
190
203
  @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
191
204
  @backpressure = :wait
@@ -129,7 +129,7 @@ module Phronomy
129
129
  # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
130
130
  # the popped value will be an Exception — callers are responsible for re-raising it.
131
131
  #
132
- # @param fsm_session [Phronomy::Agent::Lifecycle::FSMSession]
132
+ # @param fsm_session [Phronomy::Workflow::FSMSession]
133
133
  # @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
134
134
  # @api private
135
135
  def register(fsm_session)
@@ -150,23 +150,6 @@ module Phronomy
150
150
  completion_queue
151
151
  end
152
152
 
153
- # Enqueues an {AgentFSM} as a fire-and-forget child session.
154
- #
155
- # Unlike {#register}, this method:
156
- # - Is safe to call from the EventLoop thread (entry actions).
157
- # - Does NOT block — no completion queue is created.
158
- # - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
159
- #
160
- # @param agent_fsm [Phronomy::Agent::FSM]
161
- # @return [nil]
162
- # @api private
163
- def enqueue_child(agent_fsm)
164
- @queue.push([Event.new(type: :start, target_id: agent_fsm.id,
165
- payload: {session: agent_fsm, completion: nil}),
166
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
167
- nil
168
- end
169
-
170
153
  # Posts an event to the loop. Safe to call from any thread (including IO threads).
171
154
  # The current monotonic clock time is recorded so that the EventLoop can
172
155
  # measure the dispatch lag when it dequeues the event.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Phronomy
6
+ class Task
7
+ # Backend for Tasks created by {Task#map}.
8
+ #
9
+ # A mapped task's lifecycle is driven entirely by the +on_complete+
10
+ # callback of its source task — it never spawns a thread of its own.
11
+ # +MappedBackend+ transitions the owning task to +:running+ immediately
12
+ # on initialization so that +FSMSession+ treats it as an in-progress
13
+ # async action. Completion (or failure) is triggered externally via
14
+ # {Task#transition!} from the +on_complete+ callback registered by
15
+ # {Task#map}.
16
+ #
17
+ # +await+ and +join+ block until {#unblock} is called, which {Task#map}
18
+ # arranges by registering a second +on_complete+ callback on the *mapped*
19
+ # task itself after the transform callback has been registered.
20
+ #
21
+ # @api private
22
+ class MappedBackend < Backend
23
+ def initialize(task:, &)
24
+ super
25
+ @done_queue = Queue.new
26
+ task.transition!(:running)
27
+ end
28
+
29
+ # Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
30
+ # reaches a terminal state.
31
+ # @api private
32
+ def unblock(value, error)
33
+ @done_queue.push([value, error])
34
+ end
35
+
36
+ # Blocks until the mapped task reaches a terminal state.
37
+ # @return [Object] the mapped value
38
+ # @raise [Exception] if the source task or the map block raised an error
39
+ # @api private
40
+ def await
41
+ value, error = @done_queue.pop
42
+ raise error if error
43
+
44
+ value
45
+ end
46
+
47
+ # Returns +false+ — a mapped task has no independent thread to kill.
48
+ # @return [Boolean]
49
+ # @api private
50
+ def alive?
51
+ false
52
+ end
53
+
54
+ # No-op — mapped tasks carry no independent thread to cancel.
55
+ # @return [self]
56
+ # @api private
57
+ def cancel!
58
+ self
59
+ end
60
+
61
+ # Blocks until the mapped task completes, with an optional timeout.
62
+ # @param limit [Numeric, nil]
63
+ # @return [Object, nil] +nil+ on timeout
64
+ # @api private
65
+ def join(limit = nil)
66
+ if limit.nil?
67
+ await
68
+ else
69
+ begin
70
+ Timeout.timeout(limit) { await }
71
+ rescue Timeout::Error
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/phronomy/task.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "task/backend"
4
4
  require_relative "task/thread_backend"
5
5
  require_relative "task/immediate_backend"
6
6
  require_relative "task/fiber_backend"
7
+ require_relative "task/mapped_backend"
7
8
 
8
9
  module Phronomy
9
10
  # A single unit of concurrent work.
@@ -195,6 +196,58 @@ module Phronomy
195
196
  self
196
197
  end
197
198
 
199
+ # Returns a new {Task} whose completed value is the result of applying
200
+ # +block+ to this task's completed value.
201
+ #
202
+ # If this task fails or is cancelled, the mapped task also fails/is
203
+ # cancelled with the same error. The block is never called in error cases.
204
+ #
205
+ # The primary use-case is transforming an agent result into a
206
+ # {WorkflowContext} so that a Workflow entry action can return a Task
207
+ # whose value is picked up by {Workflow::FSMSession} via the existing
208
+ # +:action_completed+ path:
209
+ #
210
+ # @example Returning agent output into a Workflow state field
211
+ # entry :translate, ->(ctx) {
212
+ # TranslationAgent.new.invoke_async(ctx.query).map do |result|
213
+ # ctx.merge(answer: result[:output]) # returns WorkflowContext
214
+ # end
215
+ # }
216
+ #
217
+ # @yield [value] the completed value of this task
218
+ # @yieldreturn [Object] the value for the mapped task
219
+ # @return [Task] a new task that completes when this task does
220
+ # @api public
221
+ def map(&block)
222
+ # MappedBackend drives the task lifecycle entirely via on_complete;
223
+ # it never spawns a thread of its own.
224
+ mapped = self.class.spawn(
225
+ name: "#{@name}-mapped",
226
+ parent: @parent,
227
+ backend_class: MappedBackend
228
+ ) {}
229
+
230
+ on_complete do |value, error|
231
+ mapped_value = nil
232
+ mapped_error = error
233
+ unless error
234
+ begin
235
+ mapped_value = block.call(value)
236
+ rescue => e
237
+ mapped_error = e
238
+ end
239
+ end
240
+ if mapped_error
241
+ mapped.transition!(:failed, error: mapped_error)
242
+ else
243
+ mapped.transition!(:completed, value: mapped_value)
244
+ end
245
+ # Unblock mapped.await / mapped.join after the terminal transition.
246
+ mapped.backend.unblock(mapped_value, mapped_error)
247
+ end
248
+ mapped
249
+ end
250
+
198
251
  # Returns +true+ once the task has finished (success, error, or cancellation).
199
252
  # @return [Boolean]
200
253
  # @api private
@@ -3,8 +3,7 @@
3
3
  module Phronomy
4
4
  module Tools
5
5
  # Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
6
- # ReactAgent (or any agent that supports tools) can delegate sub-tasks to a
7
- # fully-capable agent.
6
+ # agent can delegate sub-tasks to a fully-capable sub-agent.
8
7
  #
9
8
  # Use Agent.from_agent to generate a concrete tool class. The generated
10
9
  # class is anonymous; assign it to a constant when you need a stable name.
@@ -16,7 +15,7 @@ module Phronomy
16
15
  # description: "Summarizes a long text and returns a brief summary"
17
16
  # )
18
17
  #
19
- # class OrchestratorAgent < Phronomy::Agent::ReactAgent
18
+ # class OrchestratorAgent < Phronomy::Agent::Base
20
19
  # model "openai/gpt-4o-mini"
21
20
  # instructions "You are an orchestrator that delegates to specialist agents."
22
21
  # tools SummarizerTool
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
@@ -0,0 +1,249 @@
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