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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +236 -57
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +500 -411
- data/lib/phronomy/agent/context/capability/base.rb +51 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +42 -6
- data/lib/phronomy/engine/event_loop.rb +269 -112
- data/lib/phronomy/engine/fsm_session.rb +180 -142
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +2 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/tools/mcp.rb +25 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +54 -102
- data/lib/phronomy/workflow_runner.rb +238 -300
- data/lib/phronomy.rb +6 -4
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +18 -7
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -352
- 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 :
|
|
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
|
|
178
|
-
#
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|
|
326
|
-
# 5. On
|
|
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 =
|
|
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
|
-
#
|
|
7
|
+
# Compiles AgentInvocation phase topology.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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 :
|
|
52
|
-
state :
|
|
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
|
|
55
|
-
state :blocked
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
transition filtering_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
|
-
|
|
73
|
-
transition calling_llm: :
|
|
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
|
-
|
|
77
|
-
transition
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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 :
|
|
93
|
-
transition
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
attr_reader :tool_call
|
|
8
|
+
attr_reader :tool_calls
|
|
17
9
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
super("Tool call intercepted: #{
|
|
22
|
-
|
|
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
|