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
@@ -8,11 +8,11 @@ module Phronomy
8
8
  #
9
9
  # Additional DSL over RubyLLM::Tool:
10
10
  # - tool_name : explicit function name exposed to the LLM (overrides auto-conversion)
11
- # - scope : access-scope metadata (:read_only, :write, etc.)
12
11
  # - on_error : error-handling policy (:raise or :return_empty)
13
12
  # - on_schema_error : behavior when LLM passes schema-violating arguments
14
13
  # :return_error (default), :raise, or :coerce
15
- # - requires_approval : require human approval before execution
14
+ # - requires_approval : Boolean/callable Tool-side approval default
15
+ # - approval_facts : callable exposing semantic facts to Agent policy
16
16
  # - param :name, enum: [...] : restrict allowed values in the JSON Schema
17
17
  #
18
18
  # @example
@@ -21,7 +21,6 @@ module Phronomy
21
21
  # description "Search the internal knowledge base"
22
22
  # param :query, type: :string, desc: "Search query"
23
23
  # param :lang, type: :string, desc: "Language", required: false, enum: %w[en ja fr]
24
- # scope :read_only
25
24
  # on_error :return_empty
26
25
  #
27
26
  # def execute(query:, lang: "en")
@@ -91,17 +90,6 @@ module Phronomy
91
90
 
92
91
  public
93
92
 
94
- # Sets the access scope for this tool (metadata; enforcement is the responsibility of
95
- # the Workflow/Filter layer).
96
- # @param value [Symbol] e.g. :read_only, :write, :admin
97
- # @api public
98
- # mutant:disable - neutral failure: unparser round-trip produces different source
99
- def scope(value = nil)
100
- return @scope if value.nil?
101
-
102
- @scope = value
103
- end
104
-
105
93
  # Sets or reads the execution mode for this tool.
106
94
  #
107
95
  # Execution mode is the concurrency contract declaration for the tool.
@@ -174,14 +162,41 @@ module Phronomy
174
162
  @on_schema_error = behavior
175
163
  end
176
164
 
177
- # Configures whether human approval is required before executing this tool.
178
- # @param value [Boolean]
165
+ # Configures the Tool-side default for approval. A callable receives
166
+ # ApprovalEvaluationRequest and must return true or false. It is
167
+ # evaluated on the Runtime authorization pool, not in Tool#call.
168
+ # @param value [Boolean, #call]
179
169
  # @api public
180
- # mutant:disable - neutral failure: unparser round-trip produces different source
181
- def requires_approval(value = nil)
182
- return @requires_approval || false if value.nil?
170
+ def requires_approval(value = :__unset__, &block)
171
+ if block
172
+ unless value == :__unset__
173
+ raise ArgumentError, "pass either a value or a block to requires_approval"
174
+ end
175
+ @requires_approval = block
176
+ elsif value == :__unset__
177
+ return @requires_approval if instance_variable_defined?(:@requires_approval)
178
+ return superclass.requires_approval if superclass.respond_to?(:requires_approval)
183
179
 
184
- @requires_approval = value
180
+ false
181
+ else
182
+ unless value == true || value == false || value.respond_to?(:call)
183
+ raise ArgumentError, "requires_approval must be true, false, or callable"
184
+ end
185
+ @requires_approval = value
186
+ end
187
+ end
188
+
189
+ # Registers a Tool-specific semantic fact extractor for authorization.
190
+ # The block receives validated immutable arguments and read-only context.
191
+ # @api public
192
+ def approval_facts(&block)
193
+ if block
194
+ @approval_facts = block
195
+ elsif instance_variable_defined?(:@approval_facts)
196
+ @approval_facts
197
+ elsif superclass.respond_to?(:approval_facts)
198
+ superclass.approval_facts
199
+ end
185
200
  end
186
201
 
187
202
  # Marks one or more parameter names as sensitive so their values are
@@ -211,49 +226,6 @@ module Phronomy
211
226
 
212
227
  @max_result_size = value
213
228
  end
214
-
215
- # Registers a retry policy for one or more exception classes.
216
- #
217
- # When the tool raises one of the listed exception classes, it will be
218
- # retried up to +times+ times with the specified wait strategy.
219
- # Multiple policies can be registered and are evaluated in order.
220
- #
221
- # FilterBlockError is never retried regardless of this configuration.
222
- #
223
- # @param exception_classes [Array<Class>] exception classes to retry on
224
- # @param times [Integer] maximum retry attempts (default: 1)
225
- # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
226
- # @param base [Float] base wait time in seconds (default: 1.0)
227
- #
228
- # @example
229
- # retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
230
- # retry_on Net::ReadTimeout, times: 2, wait: 0.5
231
- # @api public
232
- def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
233
- @retry_policies ||= []
234
- @retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
235
- end
236
-
237
- # Returns all retry policies registered on this tool class.
238
- # @return [Array<Hash>]
239
- # @api public
240
- # mutant:disable - neutral failure: unparser round-trip produces different source
241
- def retry_policies
242
- @retry_policies || []
243
- end
244
-
245
- # Injectable sleep callable for testing.
246
- # Defaults to Kernel#sleep.
247
- # @return [#call]
248
- # @api private
249
- # mutant:disable - neutral failure: unparser round-trip produces different source
250
- def _sleep_proc
251
- @_sleep_proc || method(:sleep)
252
- end
253
-
254
- # Overrides the sleep callable used between retries.
255
- # @param proc [#call]
256
- attr_writer :_sleep_proc
257
229
  end
258
230
 
259
231
  # Returns the function name exposed to the LLM.
@@ -315,15 +287,15 @@ module Phronomy
315
287
  schema
316
288
  end
317
289
 
318
- # Overrides RubyLLM::Tool#call to apply schema validation, the retry policy,
290
+ # Overrides RubyLLM::Tool#call to apply schema validation,
319
291
  # the on_error policy, and wrap errors as ToolError.
320
292
  #
321
293
  # Execution order:
322
294
  # 1. Early cancellation check (kwarg token takes precedence over thread-local).
323
295
  # 2. Schema validation (type + enum checks).
324
296
  # 3. Inject +cancellation_token:+ into args when +execute+ opts in.
325
- # 4. Call super(validated_args) inside a retry loop.
326
- # 5. On persistent failure, apply on_error policy.
297
+ # 4. Call super(validated_args) exactly once.
298
+ # 5. On failure, apply on_error policy.
327
299
  #
328
300
  # @param args [Hash]
329
301
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; takes precedence over the thread-local token
@@ -343,7 +315,7 @@ module Phronomy
343
315
  end
344
316
  end
345
317
  validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
346
- result = with_tool_retry { super(validated_args) }
318
+ result = super(validated_args)
347
319
  truncate_result_if_needed(result)
348
320
  rescue Phronomy::ToolError
349
321
  raise
@@ -397,6 +369,18 @@ module Phronomy
397
369
  self.class.requires_approval
398
370
  end
399
371
 
372
+ # Origin metadata consumed by ToolInvocation authorization policy.
373
+ # @api public
374
+ def tool_origin
375
+ :local
376
+ end
377
+
378
+ # Display-safe transport/origin metadata for approval requests.
379
+ # @api public
380
+ def approval_metadata
381
+ {}
382
+ end
383
+
400
384
  # Override this method to implement the tool's logic.
401
385
  #
402
386
  # The method receives the declared {.param} fields as keyword arguments.
@@ -460,58 +444,6 @@ module Phronomy
460
444
  end
461
445
  end
462
446
 
463
- # Executes the given block inside a retry loop driven by the class-level
464
- # retry_policies. Each policy matches by exception class; the first matching
465
- # policy governs the wait and retry count. Raises immediately when no policy
466
- # covers the exception or when all retries are exhausted.
467
- # mutant:disable - genuine equivalent mutations:
468
- # 1. `if policies.empty?; return yield; end` early-return variants (nil, false, block
469
- # removal): behavior is identical because when policies is empty, yield is still
470
- # called inside begin/rescue, any exception is re-raised (policy=nil, condition
471
- # false), and successful returns propagate the same value either way.
472
- # 2. `p[:exceptions].any?` vs `p.fetch(:exceptions).any?`: :exceptions key is always
473
- # present (set unconditionally by .retry_on), so fetch/[] are equivalent.
474
- # 3. `policy[:times]`, `policy[:wait]`, `policy[:base]` vs `.fetch(...)`: same reason
475
- # as #2 — all keys are always set by .retry_on.
476
- def with_tool_retry
477
- policies = self.class.retry_policies
478
- return yield if policies.empty?
479
-
480
- attempt = 0
481
- begin
482
- yield
483
- rescue => e
484
- policy = policies.find { |p| p[:exceptions].any? { |ex| e.is_a?(ex) } }
485
- if policy && attempt < policy[:times]
486
- wait = compute_retry_wait(policy[:wait], policy[:base], attempt)
487
- self.class._sleep_proc.call(wait) if wait > 0
488
- attempt += 1
489
- retry
490
- end
491
- raise
492
- end
493
- end
494
-
495
- # Computes the wait duration for a given strategy, base, and attempt index.
496
- #
497
- # @param strategy [Symbol, Numeric] :exponential, :linear, or a fixed Numeric
498
- # @param base [Float] base wait time in seconds
499
- # @param attempt [Integer] zero-based attempt index
500
- # @return [Float]
501
- # @api public
502
- def compute_retry_wait(strategy, base, attempt)
503
- case strategy
504
- when :exponential
505
- (2**attempt) * base
506
- when :linear
507
- (attempt + 1) * base
508
- when Numeric
509
- strategy.to_f
510
- else
511
- base.to_f
512
- end
513
- end
514
-
515
447
  # Validates args against declared parameter types and enum constraints.
516
448
  # When on_schema_error is :coerce, attempts type coercion first.
517
449
  #
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Immutable carrier for one LLM adapter operation outcome.
6
+ #
7
+ # Worker/timer threads create this value without mutating AgentInvocation.
8
+ # AgentInvocationSessionBuilder posts it as :llm_completed or :llm_failed,
9
+ # and AgentInvocation applies it on the EventLoop thread.
10
+ #
11
+ # @api private
12
+ class LLMOperationResult
13
+ attr_reader :response, :error, :streaming
14
+
15
+ def initialize(response: nil, error: nil, streaming: false)
16
+ @response = response
17
+ @error = error
18
+ @streaming = streaming
19
+ freeze
20
+ end
21
+ end
22
+ end
23
+ end
@@ -4,186 +4,124 @@ require "state_machines"
4
4
 
5
5
  module Phronomy
6
6
  module Agent
7
- # Builds the state_machines-backed PhaseTracker class for Agent invocations.
7
+ # Compiles AgentInvocation phase topology.
8
8
  #
9
- # This is the Agent counterpart to Workflow::PhaseMachineBuilder.
10
- # Unlike the Workflow version (which builds a dynamic graph from user-defined
11
- # DSL), this always generates the same fixed graph representing the
12
- # Agent invoke execution phases.
13
- #
14
- # The generated class holds a single +:phase+ state machine with:
15
- # - One automatic event (+:state_completed+) that FSMSession fires after
16
- # each entry action completes.
17
- # - Two external events (+:approve+, +:reject+) for HITL.
18
- # - after_transition callbacks for each state's entry actions.
19
- #
20
- # Guard methods (+input_passed?+, +tool_call_pending?+, etc.) are delegated
21
- # to the +InvocationContext+ stored in +attr_accessor :context+.
9
+ # Async completion is represented by explicit FSM events. This builder does
10
+ # not await Tasks or register Task callbacks.
22
11
  #
23
12
  # @api private
24
13
  class PhaseMachineBuilder
25
- # @param entry_actions [Hash{Symbol => Array<#call>}]
26
- # @param action_timeouts [Hash{Symbol => Numeric}]
27
- # @api private
28
- def initialize(entry_actions: {}, action_timeouts: {})
14
+ TOOL_EVENTS = %i[
15
+ tool_authorized
16
+ tool_approval_required
17
+ tool_completed
18
+ tool_failed
19
+ tool_rejected
20
+ tool_cancelled
21
+ ].freeze
22
+
23
+ def initialize(entry_actions: {})
29
24
  @entry_actions = entry_actions
30
- @action_timeouts = action_timeouts
31
25
  end
32
26
 
33
- # Builds and returns the PhaseTracker class.
34
- # @return [Class]
35
- # @api private
36
27
  def build
37
- entry_acts = @entry_actions
38
- act_timeouts = @action_timeouts
39
- build_cb = method(:build_entry_callback)
28
+ entry_actions = @entry_actions
29
+ callback_builder = method(:build_entry_callback)
40
30
 
41
31
  Class.new do
42
- # state_machines requires a class-level state machine definition.
32
+ attr_accessor :context, :current_event
33
+
43
34
  state_machine :phase, initial: :idle do
44
- # ----------------------------------------------------------------
45
- # State declarations
46
- # ----------------------------------------------------------------
47
35
  state :idle
48
36
  state :filtering_input
49
37
  state :building_context
50
38
  state :calling_llm
51
- state :executing_tool
52
- state :awaiting_approval # wait_state: external event required
39
+ state :starting_tools
40
+ state :evaluating_tools
41
+ state :waiting_for_tools
42
+ state :dispatching_tools
43
+ state :recording_tool_results
44
+ state :suspended
53
45
  state :output_filtering
54
- state :completed # terminal
55
- state :blocked # terminal
46
+ state :completed
47
+ state :blocked
48
+ state :failed
56
49
 
57
- # ----------------------------------------------------------------
58
- # Automatic transitions (fired by FSMSession on state_completed)
59
- # Guards are evaluated on the InvocationContext via #context.
60
- # ----------------------------------------------------------------
61
50
  event :state_completed do
62
- # idle → filtering_input (unconditional)
63
51
  transition idle: :filtering_input
64
52
 
65
- # filtering_input building_context | blocked
66
- transition filtering_input: :building_context, if: ->(m) { m.context&.input_passed? }
67
- transition filtering_input: :blocked, if: ->(m) { m.context&.input_blocked? }
53
+ transition filtering_input: :building_context,
54
+ if: ->(machine) { machine.context&.input_passed? }
55
+ transition filtering_input: :blocked,
56
+ if: ->(machine) { machine.context&.input_blocked? }
68
57
 
69
- # building_context → calling_llm (unconditional)
70
58
  transition building_context: :calling_llm
59
+ transition starting_tools: :evaluating_tools
60
+
61
+ transition evaluating_tools: :failed,
62
+ if: ->(machine) { machine.context&.tool_batch_failed? }
63
+ transition evaluating_tools: :blocked,
64
+ if: ->(machine) { machine.context&.tool_batch_rejected? }
65
+ transition evaluating_tools: :recording_tool_results,
66
+ if: ->(machine) { machine.context&.tool_batch_completed? }
67
+ transition evaluating_tools: :suspended,
68
+ if: ->(machine) { machine.context&.approval_required? }
69
+ transition evaluating_tools: :dispatching_tools,
70
+ if: ->(machine) { machine.context&.ready_to_dispatch? }
71
+ transition evaluating_tools: :waiting_for_tools
72
+
73
+ transition dispatching_tools: :evaluating_tools
74
+ transition recording_tool_results: :calling_llm
75
+
76
+ transition output_filtering: :completed,
77
+ if: ->(machine) { machine.context&.output_passed? }
78
+ transition output_filtering: :blocked,
79
+ if: ->(machine) { machine.context&.output_blocked? }
80
+ end
71
81
 
72
- # calling_llm → executing_tool | output_filtering
73
- transition calling_llm: :executing_tool, if: ->(m) { m.context&.tool_call_pending? }
82
+ event :llm_completed do
83
+ transition calling_llm: :starting_tools,
84
+ if: ->(machine) { machine.context&.tool_call_pending? }
74
85
  transition calling_llm: :output_filtering
86
+ end
75
87
 
76
- # executing_tool → awaiting_approval | calling_llm
77
- transition executing_tool: :awaiting_approval, if: ->(m) { m.context&.approval_required? }
78
- transition executing_tool: :calling_llm
79
-
80
- # output_filtering → completed | blocked
81
- transition output_filtering: :completed, if: ->(m) { m.context&.output_passed? }
82
- transition output_filtering: :blocked, if: ->(m) { m.context&.output_blocked? }
88
+ event :llm_failed do
89
+ transition calling_llm: :failed
83
90
  end
84
91
 
85
- # ----------------------------------------------------------------
86
- # External events (human-in-the-loop)
87
- # ----------------------------------------------------------------
88
- event :approve do
89
- transition awaiting_approval: :executing_tool
92
+ TOOL_EVENTS.each do |event_name|
93
+ event event_name do
94
+ transition waiting_for_tools: :evaluating_tools
95
+ end
90
96
  end
91
97
 
92
- event :reject do
93
- transition awaiting_approval: :blocked
98
+ event :resume do
99
+ transition suspended: :waiting_for_tools
94
100
  end
95
101
 
96
- # ----------------------------------------------------------------
97
- # Entry action after_transition callbacks
98
- # Each state's callables are fired after entering that state.
99
- # ----------------------------------------------------------------
100
- entry_acts.each do |state_name, callables|
102
+ entry_actions.each do |state_name, callables|
101
103
  callables.each do |callable|
102
- timeout_secs = act_timeouts[state_name]
103
- cb = build_cb.call(callable, state_name, timeout_secs)
104
- after_transition to: state_name, do: cb
104
+ after_transition(
105
+ to: state_name,
106
+ do: callback_builder.call(callable, state_name)
107
+ )
105
108
  end
106
109
  end
107
110
  end
108
-
109
- # Holds the InvocationContext so guard lambdas can access it.
110
- attr_accessor :context
111
-
112
- # async_pending flag: set when an entry action returns a Task.
113
- attr_accessor :async_pending
114
-
115
- # FSM session id — set by FSMSession so async task spawns know the
116
- # target_id for EventLoop events.
117
- attr_accessor :session_id
118
- attr_accessor :event_loop, :timer_queue_provider
119
-
120
- def initialize
121
- super
122
- @context = nil
123
- @async_pending = false
124
- @session_id = nil
125
- end
126
111
  end
127
112
  end
128
113
 
129
114
  private
130
115
 
131
- # Returns a proc suitable for use as an after_transition callback.
132
- # @api private
133
- def build_entry_callback(callable, state_name, timeout_secs)
134
- handle = method(:handle_entry_action_result)
116
+ def build_entry_callback(callable, state_name)
135
117
  ->(machine) {
136
118
  result = callable.call(machine.context)
137
- handle.call(machine, result, state_name, timeout_secs)
138
- }
139
- end
140
-
141
- # Dispatches the return value of an entry action.
142
- # - Task → async: set async_pending and spawn a background task
143
- # - context → sync: update machine.context
144
- # @api private
145
- def handle_entry_action_result(machine, result, state_name, timeout_secs)
146
- if result.is_a?(Phronomy::Task)
147
- dispatch_task(machine, result, state_name, timeout_secs)
148
- elsif result.respond_to?(:set_graph_metadata)
149
- machine.context = result
150
- end
151
- end
152
-
153
- # Marks the machine async-pending and spawns a Task to await the result.
154
- # @api private
155
- def dispatch_task(machine, result, state_name, timeout_secs)
156
- machine.async_pending = true
157
- session_id = machine.session_id
158
- if timeout_secs
159
- machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
160
- next if result.done?
161
-
162
- machine.event_loop.post(
163
- Phronomy::Event.new(
164
- type: :error,
165
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
166
- payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
167
- "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
168
- )}
169
- )
170
- )
119
+ if result.is_a?(Phronomy::Task)
120
+ raise Phronomy::InvalidAsyncEntryActionError,
121
+ "Agent entry action for #{state_name.inspect} returned Phronomy::Task"
171
122
  end
172
- end
173
- result.on_complete do |task_result, error|
174
- if error
175
- machine.event_loop.post(
176
- Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
177
- )
178
- next
179
- end
180
- ev = if task_result.respond_to?(:set_graph_metadata)
181
- Phronomy::Event.new(type: :action_completed, target_id: session_id, payload: task_result)
182
- else
183
- Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
184
- end
185
- machine.event_loop.post(ev)
186
- end
123
+ machine.context = result if result.respond_to?(:set_graph_metadata)
124
+ }
187
125
  end
188
126
  end
189
127
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Phronomy
7
+ module Agent
8
+ # Application-facing notification for one suspended ToolCall batch.
9
+ #
10
+ # The request contains display-safe values only. Internal policy input is
11
+ # represented by {ApprovalEvaluationRequest} and is deliberately separate.
12
+ #
13
+ # @api public
14
+ class ToolApprovalRequest
15
+ # One ToolInvocation included in a batch approval request.
16
+ # @api public
17
+ class Item
18
+ attr_reader :tool_invocation_id,
19
+ :tool_call_id,
20
+ :tool_name,
21
+ :arguments,
22
+ :facts,
23
+ :reason,
24
+ :origin,
25
+ :metadata
26
+
27
+ def initialize(
28
+ tool_invocation_id:,
29
+ tool_call_id:,
30
+ tool_name:,
31
+ arguments:,
32
+ facts:,
33
+ reason:,
34
+ origin:,
35
+ metadata:
36
+ )
37
+ @tool_invocation_id = tool_invocation_id.to_s.freeze
38
+ @tool_call_id = tool_call_id&.to_s&.freeze
39
+ @tool_name = tool_name.to_s.freeze
40
+ @arguments = immutable_copy(arguments)
41
+ @facts = immutable_copy(facts)
42
+ @reason = reason&.to_s&.freeze
43
+ @origin = origin.to_sym
44
+ @metadata = immutable_copy(metadata)
45
+ freeze
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ tool_invocation_id: @tool_invocation_id,
51
+ tool_call_id: @tool_call_id,
52
+ tool_name: @tool_name,
53
+ arguments: @arguments,
54
+ facts: @facts,
55
+ reason: @reason,
56
+ origin: @origin,
57
+ metadata: @metadata
58
+ }
59
+ end
60
+
61
+ private
62
+
63
+ def immutable_copy(value)
64
+ case value
65
+ when Hash
66
+ value.each_with_object({}) { |(k, v), h| h[immutable_copy(k)] = immutable_copy(v) }.freeze
67
+ when Array
68
+ value.map { |v| immutable_copy(v) }.freeze
69
+ when String
70
+ value.dup.freeze
71
+ else
72
+ value
73
+ end
74
+ end
75
+ end
76
+
77
+ attr_reader :id, :agent_invocation_id, :items, :created_at
78
+
79
+ def self.build(agent_invocation)
80
+ pending = agent_invocation.tool_invocations.select(&:awaiting_approval?)
81
+ new(
82
+ agent_invocation_id: agent_invocation.id,
83
+ items: pending.map { |invocation| build_item(invocation) }
84
+ )
85
+ end
86
+
87
+ def self.build_item(invocation)
88
+ Item.new(
89
+ tool_invocation_id: invocation.id,
90
+ tool_call_id: invocation.tool_call_id,
91
+ tool_name: invocation.tool_name,
92
+ arguments: invocation.display_arguments,
93
+ facts: invocation.display_facts,
94
+ reason: invocation.authorization_reason,
95
+ origin: invocation.origin,
96
+ metadata: invocation.metadata
97
+ )
98
+ end
99
+ private_class_method :build_item
100
+
101
+ def initialize(agent_invocation_id:, items:, id: SecureRandom.uuid, created_at: Time.now.utc)
102
+ raise ArgumentError, "ToolApprovalRequest requires at least one item" if items.empty?
103
+
104
+ @id = id.to_s.freeze
105
+ @agent_invocation_id = agent_invocation_id.to_s.freeze
106
+ @items = items.dup.freeze
107
+ @created_at = created_at
108
+ freeze
109
+ end
110
+
111
+ def to_h
112
+ {
113
+ id: @id,
114
+ agent_invocation_id: @agent_invocation_id,
115
+ items: @items.map(&:to_h),
116
+ created_at: @created_at.iso8601
117
+ }
118
+ end
119
+ end
120
+ end
121
+ end
@@ -2,24 +2,20 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Raised inside the on_tool_call hook registered by InvocationSession
6
- # to intercept every tool call before RubyLLM executes it.
7
- #
8
- # Catching this exception in calling_llm_action lets the Agent FSM
9
- # route through :executing_tool (and possibly :awaiting_approval) rather
10
- # than executing the tool inside RubyLLM's internal loop.
11
- #
12
- # This class is intentionally NOT part of the public API.
5
+ # Raised by the Agent-owned RubyLLM ToolCall interceptor before execution.
13
6
  # @api private
14
7
  class ToolCallIntercepted < StandardError
15
- # @return [Object] the RubyLLM tool_call object (responds to #name, #arguments, #id)
16
- attr_reader :tool_call
8
+ attr_reader :tool_calls
17
9
 
18
- # @param tool_call [Object] the RubyLLM tool_call object
19
- # @api private
20
- def initialize(tool_call)
21
- super("Tool call intercepted: #{tool_call.name}")
22
- @tool_call = tool_call
10
+ def initialize(tool_calls)
11
+ @tool_calls = Array(tool_calls).freeze
12
+ names = @tool_calls.map(&:name).join(", ")
13
+ super("Tool call intercepted: #{names}")
14
+ end
15
+
16
+ # Convenience accessor for callers that only support one ToolCall.
17
+ def tool_call
18
+ @tool_calls.first
23
19
  end
24
20
  end
25
21
  end