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
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "state_machines"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Builds an FSMSession for one ToolInvocation.
8
+ #
9
+ # Authorization and execution Tasks are observed by ToolInvocation-specific
10
+ # callbacks that post explicit FSM events. The generated phase machine does
11
+ # not await Tasks.
12
+ #
13
+ # @api private
14
+ class ToolInvocationSessionBuilder
15
+ AUTO_STATE_SET = {
16
+ idle: true,
17
+ validating: true,
18
+ queued: true
19
+ }.freeze
20
+
21
+ DECLARED_STATES = %i[
22
+ idle validating authorizing awaiting_approval authorized queued running
23
+ completed failed rejected cancelled
24
+ ].freeze
25
+
26
+ WAIT_STATES = %i[awaiting_approval authorized].freeze
27
+
28
+ EXTERNAL_EVENTS = {
29
+ authorization_completed: [
30
+ {from: :authorizing, to: :cancelled, guard: ->(ctx) {
31
+ ctx.cancelled?
32
+ }},
33
+ {from: :authorizing, to: :failed, guard: ->(ctx) {
34
+ ctx.failed?
35
+ }},
36
+ {from: :authorizing, to: :rejected, guard: ->(ctx) {
37
+ ctx.rejected?
38
+ }},
39
+ {from: :authorizing, to: :awaiting_approval, guard: ->(ctx) {
40
+ ctx.awaiting_approval?
41
+ }},
42
+ {from: :authorizing, to: :authorized, guard: ->(ctx) {
43
+ ctx.authorized?
44
+ }}
45
+ ],
46
+ execution_completed: [
47
+ {from: :running, to: :cancelled, guard: ->(ctx) {
48
+ ctx.cancelled?
49
+ }},
50
+ {from: :running, to: :failed, guard: ->(ctx) {
51
+ ctx.failed?
52
+ }},
53
+ {from: :running, to: :completed, guard: ->(ctx) {
54
+ ctx.execution_completed?
55
+ }}
56
+ ],
57
+ approve: [
58
+ {from: :awaiting_approval, to: :authorized, guard: nil}
59
+ ],
60
+ reject: [
61
+ {from: :awaiting_approval, to: :rejected, guard: nil}
62
+ ],
63
+ dispatch: [
64
+ {from: :authorized, to: :queued, guard: nil}
65
+ ],
66
+ cancel: [
67
+ {from: :awaiting_approval, to: :cancelled, guard: nil},
68
+ {from: :authorized, to: :cancelled, guard: nil},
69
+ {from: :queued, to: :cancelled, guard: nil},
70
+ {from: :running, to: :cancelled, guard: nil}
71
+ ]
72
+ }.freeze
73
+
74
+ def self.build(
75
+ tool_invocation:,
76
+ runtime: Phronomy::Runtime.instance
77
+ )
78
+ build_session(
79
+ tool_invocation: tool_invocation,
80
+ runtime: runtime
81
+ )
82
+ end
83
+
84
+ def self.build_for_resume(
85
+ tool_invocation:,
86
+ resume_event:,
87
+ resume_phase:,
88
+ runtime: Phronomy::Runtime.instance
89
+ )
90
+ build_session(
91
+ tool_invocation: tool_invocation,
92
+ runtime: runtime,
93
+ resume_event: resume_event,
94
+ resume_phase: resume_phase
95
+ )
96
+ end
97
+
98
+ def self.build_session(
99
+ tool_invocation:,
100
+ runtime:,
101
+ resume_event: nil,
102
+ resume_phase: nil
103
+ )
104
+ actions = build_entry_actions(runtime)
105
+ phase_machine = build_phase_machine(actions)
106
+
107
+ Phronomy::FSMSession.new(
108
+ id: tool_invocation.id,
109
+ context: tool_invocation,
110
+ entry_point: :idle,
111
+ phase_machine_class: phase_machine,
112
+ entry_actions: {},
113
+ auto_state_set: AUTO_STATE_SET,
114
+ declared_states: DECLARED_STATES,
115
+ wait_state_names: WAIT_STATES,
116
+ external_events: EXTERNAL_EVENTS,
117
+ recursion_limit: 20,
118
+ event_loop: runtime.event_loop,
119
+ resume_event: resume_event,
120
+ resume_phase: resume_phase
121
+ )
122
+ end
123
+ private_class_method :build_session
124
+
125
+ def self.build_entry_actions(runtime)
126
+ {
127
+ validating: [method(:validating_action)],
128
+ authorizing: [
129
+ method(:authorizing_action).curry.call(runtime)
130
+ ],
131
+ awaiting_approval: [
132
+ method(:awaiting_approval_action).curry.call(runtime)
133
+ ],
134
+ authorized: [
135
+ method(:authorized_action).curry.call(runtime)
136
+ ],
137
+ queued: [method(:queued_action)],
138
+ running: [
139
+ method(:running_action).curry.call(runtime)
140
+ ],
141
+ completed: [
142
+ method(:completed_action).curry.call(runtime)
143
+ ],
144
+ failed: [
145
+ method(:failed_action).curry.call(runtime)
146
+ ],
147
+ rejected: [
148
+ method(:rejected_action).curry.call(runtime)
149
+ ],
150
+ cancelled: [
151
+ method(:cancelled_action).curry.call(runtime)
152
+ ]
153
+ }
154
+ end
155
+ private_class_method :build_entry_actions
156
+
157
+ def self.build_phase_machine(entry_actions)
158
+ callbacks = entry_actions
159
+ callback_builder = method(:build_entry_callback)
160
+
161
+ Class.new do
162
+ attr_accessor :context, :current_event
163
+
164
+ state_machine :phase, initial: :idle do
165
+ state :idle
166
+ state :validating
167
+ state :authorizing
168
+ state :awaiting_approval
169
+ state :authorized
170
+ state :queued
171
+ state :running
172
+ state :completed
173
+ state :failed
174
+ state :rejected
175
+ state :cancelled
176
+
177
+ event :state_completed do
178
+ transition idle: :validating
179
+
180
+ transition validating: :failed,
181
+ if: ->(machine) { machine.context&.failed? }
182
+ transition validating: :completed,
183
+ if: ->(machine) {
184
+ machine.context&.validation_completed?
185
+ }
186
+ transition validating: :authorizing,
187
+ if: ->(machine) {
188
+ machine.context&.validation_passed?
189
+ }
190
+
191
+ transition queued: :running
192
+ end
193
+
194
+ event :authorization_completed do
195
+ transition authorizing: :cancelled,
196
+ if: ->(machine) { machine.context&.cancelled? }
197
+ transition authorizing: :failed,
198
+ if: ->(machine) { machine.context&.failed? }
199
+ transition authorizing: :rejected,
200
+ if: ->(machine) { machine.context&.rejected? }
201
+ transition authorizing: :awaiting_approval,
202
+ if: ->(machine) {
203
+ machine.context&.awaiting_approval?
204
+ }
205
+ transition authorizing: :authorized,
206
+ if: ->(machine) { machine.context&.authorized? }
207
+ end
208
+
209
+ event :execution_completed do
210
+ transition running: :cancelled,
211
+ if: ->(machine) { machine.context&.cancelled? }
212
+ transition running: :failed,
213
+ if: ->(machine) { machine.context&.failed? }
214
+ transition running: :completed,
215
+ if: ->(machine) {
216
+ machine.context&.execution_completed?
217
+ }
218
+ end
219
+
220
+ event :approve do
221
+ transition awaiting_approval: :authorized
222
+ end
223
+
224
+ event :reject do
225
+ transition awaiting_approval: :rejected
226
+ end
227
+
228
+ event :dispatch do
229
+ transition authorized: :queued
230
+ end
231
+
232
+ event :cancel do
233
+ transition awaiting_approval: :cancelled
234
+ transition authorized: :cancelled
235
+ transition queued: :cancelled
236
+ transition running: :cancelled
237
+ end
238
+
239
+ callbacks.each do |state_name, callables|
240
+ callables.each do |callable|
241
+ after_transition(
242
+ to: state_name,
243
+ do: callback_builder.call(callable, state_name)
244
+ )
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
250
+ private_class_method :build_phase_machine
251
+
252
+ def self.build_entry_callback(callable, state_name)
253
+ ->(machine) {
254
+ result = callable.call(machine.context)
255
+ if result.is_a?(Phronomy::Task)
256
+ raise Phronomy::InvalidAsyncEntryActionError,
257
+ "Tool entry action for #{state_name.inspect} returned Phronomy::Task"
258
+ end
259
+ machine.context = result if result.respond_to?(:set_graph_metadata)
260
+ }
261
+ end
262
+ private_class_method :build_entry_callback
263
+
264
+ def self.validating_action(invocation)
265
+ invocation.validate!
266
+ end
267
+ private_class_method :validating_action
268
+
269
+ def self.authorizing_action(runtime, invocation)
270
+ task = invocation.authorization_task(runtime: runtime)
271
+ observe_task(
272
+ runtime,
273
+ invocation,
274
+ task,
275
+ event_type: :authorization_completed
276
+ )
277
+ invocation
278
+ end
279
+ private_class_method :authorizing_action
280
+
281
+ def self.awaiting_approval_action(runtime, invocation)
282
+ invocation.mark_awaiting_approval!
283
+ notify_parent(runtime, invocation, :tool_approval_required)
284
+ invocation
285
+ end
286
+ private_class_method :awaiting_approval_action
287
+
288
+ def self.authorized_action(runtime, invocation)
289
+ invocation.mark_authorized!
290
+ notify_parent(runtime, invocation, :tool_authorized)
291
+ invocation
292
+ end
293
+ private_class_method :authorized_action
294
+
295
+ def self.queued_action(invocation)
296
+ invocation.mark_queued!
297
+ end
298
+ private_class_method :queued_action
299
+
300
+ def self.running_action(runtime, invocation)
301
+ # execution_task checks dispatchable? which requires :queued status;
302
+ # mark_running! is deferred until after the task is started.
303
+ task = invocation.execution_task(runtime: runtime)
304
+ invocation.mark_running!
305
+ observe_task(
306
+ runtime,
307
+ invocation,
308
+ task,
309
+ event_type: :execution_completed
310
+ )
311
+ invocation
312
+ end
313
+ private_class_method :running_action
314
+
315
+ def self.observe_task(
316
+ runtime,
317
+ invocation,
318
+ task,
319
+ event_type:
320
+ )
321
+ task.on_complete do |outcome, error|
322
+ payload = error || outcome
323
+ accepted = runtime.event_loop.post_to_session(
324
+ Phronomy::Event.new(
325
+ type: event_type,
326
+ target_id: invocation.id,
327
+ payload: payload
328
+ )
329
+ )
330
+ next if accepted
331
+
332
+ Phronomy.configuration.logger&.warn(
333
+ "[Phronomy] Dropped #{event_type.inspect} for " \
334
+ "ToolInvocation #{invocation.id}"
335
+ )
336
+ end
337
+ end
338
+ private_class_method :observe_task
339
+
340
+ def self.completed_action(runtime, invocation)
341
+ notify_parent(runtime, invocation, :tool_completed)
342
+ invocation
343
+ end
344
+ private_class_method :completed_action
345
+
346
+ def self.failed_action(runtime, invocation)
347
+ notify_parent(runtime, invocation, :tool_failed)
348
+ invocation
349
+ end
350
+ private_class_method :failed_action
351
+
352
+ def self.rejected_action(runtime, invocation)
353
+ invocation.mark_rejected!
354
+ notify_parent(runtime, invocation, :tool_rejected)
355
+ invocation
356
+ end
357
+ private_class_method :rejected_action
358
+
359
+ def self.cancelled_action(runtime, invocation)
360
+ invocation.mark_cancelled!
361
+ notify_parent(runtime, invocation, :tool_cancelled)
362
+ invocation
363
+ end
364
+ private_class_method :cancelled_action
365
+
366
+ def self.notify_parent(runtime, invocation, event_type)
367
+ runtime.event_loop.post_to_session(
368
+ Phronomy::Event.new(
369
+ type: event_type,
370
+ target_id: invocation.parent_agent_invocation_id,
371
+ payload: {tool_invocation_id: invocation.id}
372
+ )
373
+ )
374
+ end
375
+ private_class_method :notify_parent
376
+ end
377
+ end
378
+ end
@@ -2,16 +2,28 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Represents a single event emitted during a streaming agent invocation.
5
+ # Immutable event emitted by Agent async APIs.
6
6
  #
7
- # type values:
8
- # :token — a content delta from the LLM (payload: { content: String })
9
- # :tool_call — the LLM requested a tool call (payload: { tool_call: Object })
10
- # :tool_result a tool finished executing (payload: { tool_call_id: String, tool_name: String,
11
- # tool_result: Object })
12
- # :done — the agent finished (payload: { output: String, messages: Array,
13
- # usage: TokenUsage })
14
- # :error — an unrecoverable error occurred (payload: { error: Exception })
7
+ # invoke_async and stream_async share lifecycle and Tool events. Streaming
8
+ # additionally emits :token events.
9
+ #
10
+ # Common event types:
11
+ # :tool_call
12
+ # :tool_result
13
+ # :approval_required
14
+ # :done
15
+ # :error
16
+ # :timeout
17
+ # :cancelled
18
+ #
19
+ # Streaming-only event type:
20
+ # :token
15
21
  StreamEvent = Data.define(:type, :payload)
16
22
  end
17
23
  end
24
+
25
+ require_relative "agent/async_event_api"
26
+
27
+ unless Phronomy::Agent::Base < Phronomy::Agent::AsyncEventApi
28
+ Phronomy::Agent::Base.prepend(Phronomy::Agent::AsyncEventApi)
29
+ end
@@ -10,6 +10,9 @@ module Phronomy
10
10
  # config.recursion_limit = 50
11
11
  # end
12
12
  class Configuration
13
+ STREAM_CALLBACK_ERROR_POLICIES = %i[report fail_task].freeze
14
+ private_constant :STREAM_CALLBACK_ERROR_POLICIES
15
+
13
16
  # Default LLM model name (nil delegates to RubyLLM default)
14
17
  attr_accessor :default_model
15
18
 
@@ -106,11 +109,17 @@ module Phronomy
106
109
  # @return [Float, nil]
107
110
  attr_accessor :blocking_detect_threshold_ms
108
111
 
109
- # Upper bound on the number of streaming token chunks that may be buffered
110
- # in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
111
- # throttled. When nil (default), the queue is unbounded.
112
- # @return [Integer, nil]
113
- attr_accessor :stream_queue_max_size
112
+ # Determines how an unhandled Application exception from a terminal stream
113
+ # callback affects the Task returned by Agent#stream_async or
114
+ # Agent#approve_async.
115
+ #
116
+ # +:report+ logs the callback failure and preserves the Agent result.
117
+ # +:fail_task+ logs the callback failure and fails the current Task with
118
+ # {Phronomy::StreamCallbackError}. Neither policy terminates EventLoop.
119
+ #
120
+ # Default: +:report+.
121
+ # @return [:report, :fail_task]
122
+ attr_reader :stream_callback_error_policy
114
123
 
115
124
  # Number of OS worker threads in the default {BlockingAdapterPool}.
116
125
  # All LLM calls, MCP tool calls, and other blocking I/O share this pool.
@@ -126,6 +135,20 @@ module Phronomy
126
135
  # @return [Integer]
127
136
  attr_accessor :blocking_io_queue_size
128
137
 
138
+ # Worker count for Tool authorization evaluation. The named pool is owned
139
+ # by Runtime#pool(:authorization) and shares PoolRegistry lifecycle.
140
+ # @return [Integer]
141
+ attr_accessor :authorization_pool_size
142
+
143
+ # Maximum queued Tool authorization evaluations.
144
+ # @return [Integer]
145
+ attr_accessor :authorization_queue_size
146
+
147
+ # Operation-wide deadline for approval_facts, requires_approval callables,
148
+ # and Agent#tool_approval_policy. Timeout fails closed to Human approval.
149
+ # @return [Numeric]
150
+ attr_accessor :authorization_timeout
151
+
129
152
  # Scheduler starvation threshold (milliseconds).
130
153
  # When a task waits more than this many milliseconds after calling
131
154
  # +runtime.yield+ before being resumed, the wait is counted as a starvation
@@ -162,6 +185,16 @@ module Phronomy
162
185
  # @return [Boolean]
163
186
  attr_accessor :strict_runtime_guards
164
187
 
188
+ def stream_callback_error_policy=(value)
189
+ unless STREAM_CALLBACK_ERROR_POLICIES.include?(value)
190
+ allowed = STREAM_CALLBACK_ERROR_POLICIES.map(&:inspect).join(", ")
191
+ raise Phronomy::ConfigurationError,
192
+ "stream_callback_error_policy must be one of: #{allowed}"
193
+ end
194
+
195
+ @stream_callback_error_policy = value
196
+ end
197
+
165
198
  def initialize
166
199
  @recursion_limit = 25
167
200
  @tracer = Phronomy::Tracing::NullTracer.new
@@ -173,9 +206,12 @@ module Phronomy
173
206
  @event_loop_dispatch_threshold_seconds = nil
174
207
  @scheduler_debug = false
175
208
  @blocking_detect_threshold_ms = nil
176
- @stream_queue_max_size = nil
209
+ @stream_callback_error_policy = :report
177
210
  @blocking_io_pool_size = 10
178
211
  @blocking_io_queue_size = 100
212
+ @authorization_pool_size = 4
213
+ @authorization_queue_size = 100
214
+ @authorization_timeout = 5
179
215
  @starvation_threshold_ms = 50
180
216
  @runtime_backend = :thread
181
217
  @strict_runtime_guards = false