phronomy 0.13.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 +155 -0
- data/README.md +266 -38
- 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/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -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 +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- 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 +58 -53
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- 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 +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- 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 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
-
require_relative "concerns/retryable"
|
|
5
4
|
require_relative "concerns/filterable"
|
|
6
5
|
require_relative "concerns/before_completion"
|
|
7
6
|
require_relative "concerns/error_translation"
|
|
@@ -12,7 +11,7 @@ module Phronomy
|
|
|
12
11
|
#
|
|
13
12
|
# Subclass this to create a conversational agent powered by an LLM.
|
|
14
13
|
# DSL class methods configure the model, instructions, tools, memory,
|
|
15
|
-
# and
|
|
14
|
+
# and execution hooks. Instance methods handle invocation.
|
|
16
15
|
#
|
|
17
16
|
# @example Minimal agent
|
|
18
17
|
# class GreetingAgent < Phronomy::Agent::Base
|
|
@@ -31,11 +30,13 @@ module Phronomy
|
|
|
31
30
|
# end
|
|
32
31
|
class Base
|
|
33
32
|
include Phronomy::Runnable
|
|
34
|
-
include Concerns::Retryable
|
|
35
33
|
include Concerns::Filterable
|
|
36
34
|
include Concerns::BeforeCompletion
|
|
37
35
|
include Concerns::ErrorTranslation
|
|
38
36
|
|
|
37
|
+
APPROVAL_CONFIGURATION_INIT_MUTEX = Mutex.new
|
|
38
|
+
private_constant :APPROVAL_CONFIGURATION_INIT_MUTEX
|
|
39
|
+
|
|
39
40
|
class << self
|
|
40
41
|
# Sets or reads the LLM model identifier for this agent.
|
|
41
42
|
# When called without an argument, returns the stored model or the
|
|
@@ -187,66 +188,6 @@ module Phronomy
|
|
|
187
188
|
end
|
|
188
189
|
end
|
|
189
190
|
|
|
190
|
-
# Sets or reads the maximum number of tool calls executed concurrently
|
|
191
|
-
# when the LLM returns multiple tool calls in a single response
|
|
192
|
-
# (ParallelToolChat mode, active inside an AgentFSM IO thread).
|
|
193
|
-
#
|
|
194
|
-
# Defaults to 10. Set to 1 to force sequential execution.
|
|
195
|
-
# Inherited by subclasses; the most-specific definition wins.
|
|
196
|
-
#
|
|
197
|
-
# @param val [Integer, nil]
|
|
198
|
-
# @return [Integer]
|
|
199
|
-
# @example
|
|
200
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
201
|
-
# max_parallel_tools 4
|
|
202
|
-
# end
|
|
203
|
-
# @api public
|
|
204
|
-
def max_parallel_tools(val = nil)
|
|
205
|
-
if val.nil?
|
|
206
|
-
@max_parallel_tools ||
|
|
207
|
-
(superclass.respond_to?(:max_parallel_tools) ? superclass.max_parallel_tools : 10)
|
|
208
|
-
else
|
|
209
|
-
unless val.is_a?(Integer) && val >= 1
|
|
210
|
-
raise ArgumentError,
|
|
211
|
-
"max_parallel_tools must be a positive Integer (>= 1), got #{val.inspect}"
|
|
212
|
-
end
|
|
213
|
-
@max_parallel_tools = val
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# Sets or reads the per-invocation timeout (in seconds) for EventLoop-mode
|
|
218
|
-
# agent calls. When set, +invoke+ raises {Phronomy::TimeoutError} if the
|
|
219
|
-
# agent does not finish within the given number of seconds.
|
|
220
|
-
#
|
|
221
|
-
# Has no effect when EventLoop mode is disabled (direct invoke path).
|
|
222
|
-
# Defaults to +nil+ (no timeout).
|
|
223
|
-
# Inherited by subclasses; the most-specific definition wins.
|
|
224
|
-
#
|
|
225
|
-
# When the timeout fires, a {Phronomy::Concurrency::CancellationScope} is cancelled
|
|
226
|
-
# and its token is propagated to the FSM config so that in-flight LLM,
|
|
227
|
-
# tool, and RAG calls observe cancellation via their +cancellation_token:+
|
|
228
|
-
# keyword argument. +Phronomy::TimeoutError+ is raised to the caller.
|
|
229
|
-
#
|
|
230
|
-
# @param val [Numeric, nil]
|
|
231
|
-
# @return [Numeric, nil]
|
|
232
|
-
# @example
|
|
233
|
-
# class MyAgent < Phronomy::Agent::Base
|
|
234
|
-
# invoke_timeout 30
|
|
235
|
-
# end
|
|
236
|
-
# @api public
|
|
237
|
-
def invoke_timeout(val = nil)
|
|
238
|
-
if val.nil?
|
|
239
|
-
return @invoke_timeout if defined?(@invoke_timeout)
|
|
240
|
-
superclass.respond_to?(:invoke_timeout) ? superclass.invoke_timeout : nil
|
|
241
|
-
else
|
|
242
|
-
unless val.is_a?(Numeric) && val > 0
|
|
243
|
-
raise ArgumentError,
|
|
244
|
-
"invoke_timeout must be a positive number, got #{val.inspect}"
|
|
245
|
-
end
|
|
246
|
-
@invoke_timeout = val
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
191
|
# Registers one or more static knowledge sources on the agent class.
|
|
251
192
|
# Static source content is fetched and memoized at the **class** level
|
|
252
193
|
# the first time +invoke+ is called. The cache persists for the lifetime
|
|
@@ -373,21 +314,35 @@ module Phronomy
|
|
|
373
314
|
end
|
|
374
315
|
end
|
|
375
316
|
|
|
376
|
-
# Continues a suspended
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
317
|
+
# Continues a suspended AgentInvocation.
|
|
318
|
+
# @param agent_invocation_id [String]
|
|
319
|
+
# @param approval_request_id [String]
|
|
320
|
+
# @param approved [Boolean]
|
|
321
|
+
# @param config [Hash]
|
|
322
|
+
# @api public
|
|
323
|
+
def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
324
|
+
new.approve(
|
|
325
|
+
agent_invocation_id,
|
|
326
|
+
approval_request_id: approval_request_id,
|
|
327
|
+
approved: approved,
|
|
328
|
+
config: config
|
|
329
|
+
)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Continues a suspended AgentInvocation without blocking the caller.
|
|
333
|
+
# @param agent_invocation_id [String]
|
|
334
|
+
# @param approval_request_id [String]
|
|
335
|
+
# @param approved [Boolean]
|
|
336
|
+
# @param config [Hash]
|
|
337
|
+
# @return [Phronomy::Task]
|
|
388
338
|
# @api public
|
|
389
|
-
def
|
|
390
|
-
new.
|
|
339
|
+
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
340
|
+
new.approve_async(
|
|
341
|
+
agent_invocation_id,
|
|
342
|
+
approval_request_id: approval_request_id,
|
|
343
|
+
approved: approved,
|
|
344
|
+
config: config
|
|
345
|
+
)
|
|
391
346
|
end
|
|
392
347
|
end
|
|
393
348
|
|
|
@@ -409,38 +364,31 @@ module Phronomy
|
|
|
409
364
|
@_handoff_tools || []
|
|
410
365
|
end
|
|
411
366
|
|
|
412
|
-
# Registers
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
# must return a truthy value to allow execution.
|
|
416
|
-
# Returning a falsy value causes the tool to return a denial message.
|
|
417
|
-
#
|
|
418
|
-
# When no handler is registered and a tool with +requires_approval+ is
|
|
419
|
-
# called, #invoke returns a suspended result hash containing a
|
|
420
|
-
# +session_id+. Call #approve to continue execution.
|
|
421
|
-
#
|
|
422
|
-
# @example
|
|
423
|
-
# agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
|
|
367
|
+
# Registers the final Agent/Application authorization policy.
|
|
368
|
+
# The block runs on the Runtime authorization pool and must return
|
|
369
|
+
# :allow, :require_approval, or :reject.
|
|
424
370
|
# @return [self]
|
|
425
371
|
# @api public
|
|
426
|
-
def
|
|
427
|
-
|
|
372
|
+
def tool_approval_policy(&block)
|
|
373
|
+
raise ArgumentError, "tool_approval_policy requires a block" unless block
|
|
374
|
+
|
|
375
|
+
_approval_configuration_mutex.synchronize { @tool_approval_policy = block }
|
|
428
376
|
self
|
|
429
377
|
end
|
|
430
378
|
|
|
431
|
-
# Registers a
|
|
432
|
-
#
|
|
433
|
-
# The callable receives +(tool_class, scope, agent)+ and must return
|
|
434
|
-
# +:allow+, +:reject+, or +:approve+.
|
|
435
|
-
#
|
|
436
|
-
# @param policy [#call]
|
|
437
|
-
# @return [void]
|
|
379
|
+
# Registers a non-blocking Application notification listener.
|
|
380
|
+
# @return [self]
|
|
438
381
|
# @api public
|
|
439
|
-
|
|
382
|
+
def on_tool_approval_required(&block)
|
|
383
|
+
raise ArgumentError, "on_tool_approval_required requires a block" unless block
|
|
384
|
+
|
|
385
|
+
_approval_configuration_mutex.synchronize { @tool_approval_listener = block }
|
|
386
|
+
self
|
|
387
|
+
end
|
|
440
388
|
|
|
441
389
|
# Invokes the agent with the given input and returns a result Hash.
|
|
442
|
-
#
|
|
443
|
-
#
|
|
390
|
+
# Provider errors are translated after the configured LLM adapter returns
|
|
391
|
+
# its final result. Phronomy does not replay the Agent invocation.
|
|
444
392
|
#
|
|
445
393
|
# @param input [String, Hash] the user message; a Hash may supply
|
|
446
394
|
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
@@ -486,38 +434,17 @@ module Phronomy
|
|
|
486
434
|
if invocation_context
|
|
487
435
|
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
488
436
|
end
|
|
489
|
-
_check_scheduler_reentrancy
|
|
490
|
-
|
|
491
|
-
timeout_sec = self.class.invoke_timeout
|
|
492
|
-
unless timeout_sec
|
|
493
|
-
return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
494
|
-
result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
|
|
495
|
-
[result, result[:usage]]
|
|
496
|
-
end
|
|
497
|
-
end
|
|
498
|
-
|
|
499
|
-
# invoke_timeout: create a CancellationScope with deadline, pass its token
|
|
500
|
-
# to the async invocation, and use scope.pop_queue so the calling thread
|
|
501
|
-
# unblocks as soon as either the result arrives or the deadline fires.
|
|
502
|
-
scope = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
|
|
503
|
-
scope.deadline_in(timeout_sec)
|
|
504
|
-
effective_config = config.merge(cancellation_token: scope.token)
|
|
505
|
-
task = invoke_async(input, messages: messages, thread_id: thread_id, config: effective_config)
|
|
506
|
-
|
|
507
|
-
# Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
|
|
508
|
-
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
509
|
-
Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
|
|
510
|
-
completion_queue.push(task.wait_result)
|
|
511
|
-
rescue => e
|
|
512
|
-
completion_queue.push(e)
|
|
513
|
-
end
|
|
437
|
+
_check_scheduler_reentrancy(:invoke, :invoke_async)
|
|
514
438
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
439
|
+
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
440
|
+
result = invoke_async(
|
|
441
|
+
input,
|
|
442
|
+
messages: messages,
|
|
443
|
+
thread_id: thread_id,
|
|
444
|
+
config: config
|
|
445
|
+
).wait_result
|
|
446
|
+
[result, result[:usage]]
|
|
518
447
|
end
|
|
519
|
-
raise result if result.is_a?(Exception)
|
|
520
|
-
result
|
|
521
448
|
end
|
|
522
449
|
|
|
523
450
|
# Invokes this agent asynchronously and returns a {Phronomy::Task}.
|
|
@@ -542,45 +469,81 @@ module Phronomy
|
|
|
542
469
|
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
543
470
|
# @return [Phronomy::Task]
|
|
544
471
|
# @api public
|
|
545
|
-
def invoke_async(input, messages: [], thread_id: nil, config: {},
|
|
472
|
+
def invoke_async(input, messages: [], thread_id: nil, config: {},
|
|
473
|
+
invocation_context: nil, on_tool_approval_required: nil)
|
|
546
474
|
if invocation_context
|
|
547
475
|
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
548
476
|
end
|
|
549
|
-
bp = Phronomy.configuration.backpressure
|
|
550
|
-
on_full = (bp == :raise) ? :reject : (bp || :wait)
|
|
551
|
-
bp_timeout = Phronomy.configuration.backpressure_timeout
|
|
552
477
|
result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
478
|
+
approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
|
|
479
|
+
_start_invocation(
|
|
480
|
+
result_task, input,
|
|
481
|
+
messages: messages, thread_id: thread_id, config: config,
|
|
482
|
+
approval_snapshot: approval_snapshot
|
|
483
|
+
)
|
|
484
|
+
result_task
|
|
558
485
|
end
|
|
559
486
|
|
|
560
|
-
#
|
|
561
|
-
#
|
|
487
|
+
# Invokes this agent asynchronously and delivers stream events from the
|
|
488
|
+
# Runtime-owned EventLoop thread.
|
|
562
489
|
#
|
|
563
|
-
#
|
|
564
|
-
#
|
|
565
|
-
# :tool_call — when the LLM requests a tool
|
|
566
|
-
# :tool_result — after a tool completes
|
|
567
|
-
# :done — final event carrying output, messages, and usage
|
|
568
|
-
# :error — if an unrecoverable error occurs
|
|
490
|
+
# The callback must return quickly. Blocking I/O, synchronous Agent calls,
|
|
491
|
+
# sleep, and heavy CPU work must be delegated by the Application.
|
|
569
492
|
#
|
|
570
|
-
# @
|
|
571
|
-
# @
|
|
572
|
-
|
|
573
|
-
|
|
493
|
+
# @return [Phronomy::Task] final invocation result
|
|
494
|
+
# @api public
|
|
495
|
+
def stream_async(input, messages: [], thread_id: nil, config: {},
|
|
496
|
+
invocation_context: nil, on_tool_approval_required: nil, &block)
|
|
497
|
+
raise ArgumentError, "stream_async requires a block" unless block
|
|
498
|
+
|
|
499
|
+
if invocation_context
|
|
500
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
result_task = Phronomy::Task.deferred(
|
|
504
|
+
name: "agent-#{(self.class.name || "anonymous").downcase}-stream-async"
|
|
505
|
+
)
|
|
506
|
+
approval_snapshot = _approval_configuration_snapshot(on_tool_approval_required)
|
|
507
|
+
_start_invocation(
|
|
508
|
+
result_task,
|
|
509
|
+
input,
|
|
510
|
+
messages: messages,
|
|
511
|
+
thread_id: thread_id,
|
|
512
|
+
config: config,
|
|
513
|
+
approval_snapshot: approval_snapshot,
|
|
514
|
+
mode: :stream,
|
|
515
|
+
on_event: block
|
|
516
|
+
)
|
|
517
|
+
result_task
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Synchronous wrapper around {#stream_async}.
|
|
521
|
+
#
|
|
522
|
+
# Stream callbacks execute on the EventLoop thread, not on the thread that
|
|
523
|
+
# calls this method. This method only blocks while waiting for the final Task.
|
|
574
524
|
# @yield [Phronomy::Agent::StreamEvent]
|
|
575
|
-
# @return [Hash]
|
|
525
|
+
# @return [Hash] same result shape as #invoke
|
|
576
526
|
# @api public
|
|
577
|
-
def stream(input, messages: [], thread_id: nil, config: {},
|
|
578
|
-
|
|
527
|
+
def stream(input, messages: [], thread_id: nil, config: {},
|
|
528
|
+
invocation_context: nil, on_tool_approval_required: nil, &block)
|
|
529
|
+
raise ArgumentError, "stream requires a block" unless block
|
|
579
530
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
531
|
+
if invocation_context
|
|
532
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
533
|
+
end
|
|
534
|
+
_check_scheduler_reentrancy(:stream, :stream_async)
|
|
535
|
+
|
|
536
|
+
trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
|
|
537
|
+
result = stream_async(
|
|
538
|
+
input,
|
|
539
|
+
messages: messages,
|
|
540
|
+
thread_id: thread_id,
|
|
541
|
+
config: config,
|
|
542
|
+
on_tool_approval_required: on_tool_approval_required,
|
|
543
|
+
&block
|
|
544
|
+
).wait_result
|
|
545
|
+
[result, result[:usage]]
|
|
546
|
+
end
|
|
584
547
|
end
|
|
585
548
|
|
|
586
549
|
# @deprecated The context version cache has been removed. Returns nil.
|
|
@@ -610,12 +573,18 @@ module Phronomy
|
|
|
610
573
|
[effective_thread_id, effective_config]
|
|
611
574
|
end
|
|
612
575
|
|
|
613
|
-
def _check_scheduler_reentrancy
|
|
576
|
+
def _check_scheduler_reentrancy(sync_method, async_method)
|
|
577
|
+
if Phronomy::Runtime.instance.event_loop.current?
|
|
578
|
+
raise Phronomy::SchedulerReentrancyError,
|
|
579
|
+
"#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
|
|
580
|
+
"Use #{async_method} and return immediately."
|
|
581
|
+
end
|
|
582
|
+
|
|
614
583
|
return unless Phronomy::Task.current
|
|
615
584
|
|
|
616
|
-
msg = "#{self.class.name}
|
|
585
|
+
msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
|
|
617
586
|
"This blocks the scheduler until the inner invocation completes, preventing " \
|
|
618
|
-
"other tasks from making progress. Use
|
|
587
|
+
"other tasks from making progress. Use #{async_method} + await instead."
|
|
619
588
|
if Phronomy.configuration.strict_runtime_guards
|
|
620
589
|
raise Phronomy::SchedulerReentrancyError, msg
|
|
621
590
|
elsif Phronomy.configuration.logger
|
|
@@ -625,48 +594,6 @@ module Phronomy
|
|
|
625
594
|
end
|
|
626
595
|
end
|
|
627
596
|
|
|
628
|
-
# Streaming implementation for #stream.
|
|
629
|
-
def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
|
|
630
|
-
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
631
|
-
input = run_input_filters!(input)
|
|
632
|
-
|
|
633
|
-
chat = build_chat
|
|
634
|
-
user_message = extract_message(input)
|
|
635
|
-
context = build_context(
|
|
636
|
-
input,
|
|
637
|
-
messages: messages,
|
|
638
|
-
thread_id: thread_id,
|
|
639
|
-
config: config,
|
|
640
|
-
budget: build_token_budget,
|
|
641
|
-
instruction: build_instructions(input),
|
|
642
|
-
tools: self.class.tools + _handoff_tools
|
|
643
|
-
)
|
|
644
|
-
_apply_context_to_chat(chat, context)
|
|
645
|
-
|
|
646
|
-
current_tool_call = nil
|
|
647
|
-
chat.on_tool_call do |tool_call|
|
|
648
|
-
current_tool_call = tool_call
|
|
649
|
-
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
|
|
650
|
-
end
|
|
651
|
-
chat.on_tool_result do |tool_result|
|
|
652
|
-
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
653
|
-
tool_call_id: current_tool_call&.id,
|
|
654
|
-
tool_name: current_tool_call&.name,
|
|
655
|
-
tool_result: tool_result
|
|
656
|
-
}))
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
run_before_completion_hooks!(chat, config)
|
|
660
|
-
|
|
661
|
-
output, usage = _drain_stream(chat, user_message, config, &block)
|
|
662
|
-
output = run_output_filters!(output)
|
|
663
|
-
|
|
664
|
-
result = {output: output, messages: chat.messages, usage: usage}
|
|
665
|
-
block.call(StreamEvent.new(type: :done, payload: result))
|
|
666
|
-
[result, usage]
|
|
667
|
-
end
|
|
668
|
-
end
|
|
669
|
-
|
|
670
597
|
# Assembles the LLM context (system prompt + conversation messages)
|
|
671
598
|
# for a single invocation. Subclasses may override this method to
|
|
672
599
|
# inject custom context editing logic without having to override
|
|
@@ -834,154 +761,398 @@ module Phronomy
|
|
|
834
761
|
end
|
|
835
762
|
protected :instance_knowledge_chunks
|
|
836
763
|
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
#
|
|
840
|
-
# or { suspended: true, session_id:, messages: } when awaiting approval.
|
|
764
|
+
# Starts one AgentInvocation and resolves +result_task+ from that session.
|
|
765
|
+
# Phronomy translates the adapter's final error but never starts another
|
|
766
|
+
# AgentInvocation automatically.
|
|
841
767
|
# @api private
|
|
842
|
-
def
|
|
768
|
+
def _start_invocation(result_task, input, messages:, thread_id:, config:,
|
|
769
|
+
approval_snapshot:, mode: :invoke, on_event: nil)
|
|
843
770
|
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
844
|
-
# Fail fast when the token is already cancelled before any LLM call.
|
|
845
771
|
check_cancellation!(effective_config, "invocation cancelled")
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
session = Agent::InvocationSession.build(
|
|
850
|
-
agent: self,
|
|
851
|
-
input: input,
|
|
852
|
-
messages: messages,
|
|
853
|
-
config: effective_config
|
|
854
|
-
)
|
|
855
|
-
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
856
|
-
ctx = completion_queue.pop
|
|
857
|
-
raise ctx if ctx.is_a?(Exception)
|
|
858
|
-
result = _extract_invoke_result(ctx, session.id)
|
|
859
|
-
[result, result[:usage]]
|
|
860
|
-
end
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
# Starts a single invocation attempt and wires retry/translation onto result_task.
|
|
864
|
-
# Non-blocking: registers with EventLoop and returns immediately.
|
|
865
|
-
# On error, retries via timer_queue when policy allows; otherwise translates
|
|
866
|
-
# and resolves result_task as failed.
|
|
867
|
-
# @api private
|
|
868
|
-
def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
|
|
869
|
-
effective_config = thread_id ? config.merge(thread_id: thread_id) : config
|
|
870
|
-
check_cancellation!(effective_config, "invocation cancelled")
|
|
871
|
-
Phronomy::EventLoop.instance.start
|
|
872
|
-
session = Agent::InvocationSession.build(
|
|
772
|
+
runtime = Phronomy::Runtime.instance
|
|
773
|
+
event_loop = runtime.event_loop
|
|
774
|
+
session = Agent::AgentInvocationSessionBuilder.build(
|
|
873
775
|
agent: self,
|
|
874
776
|
input: input,
|
|
875
777
|
messages: messages,
|
|
876
|
-
config: effective_config
|
|
778
|
+
config: effective_config,
|
|
779
|
+
approval_policy: approval_snapshot[:policy],
|
|
780
|
+
approval_listener: approval_snapshot[:listener],
|
|
781
|
+
mode: mode,
|
|
782
|
+
on_event: on_event,
|
|
783
|
+
runtime: runtime
|
|
877
784
|
)
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
785
|
+
callback_error_policy =
|
|
786
|
+
Phronomy.configuration.stream_callback_error_policy
|
|
787
|
+
source_task = Phronomy::Task.deferred(name: "#{result_task.name}-source")
|
|
788
|
+
source_task.on_complete do |invocation, error|
|
|
789
|
+
_handle_agent_completion(
|
|
790
|
+
result_task: result_task,
|
|
791
|
+
invocation: invocation,
|
|
792
|
+
error: error,
|
|
793
|
+
mode: mode,
|
|
794
|
+
listener: on_event,
|
|
795
|
+
event_loop: event_loop,
|
|
796
|
+
callback_error_policy: callback_error_policy
|
|
797
|
+
)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
# Register completion handling before EventLoop admission. Otherwise an
|
|
801
|
+
# immediately finishing session can complete source_task before the
|
|
802
|
+
# callback is installed, causing Task#on_complete to run on this thread.
|
|
803
|
+
event_loop.register(session, completion: source_task)
|
|
804
|
+
rescue => e
|
|
805
|
+
_fail_result_task(result_task, e)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def _complete_result_task(task, result)
|
|
809
|
+
task.backend.unblock(result, nil)
|
|
810
|
+
task.transition!(:completed, value: result)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def _fail_result_task(task, error)
|
|
814
|
+
task.backend.unblock(nil, error)
|
|
815
|
+
task.transition!(:failed, error: error)
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def _translated_error(error)
|
|
819
|
+
translate_and_reraise!(error)
|
|
820
|
+
rescue => translated
|
|
821
|
+
translated
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Completes one Agent execution interval. Execution failures and
|
|
825
|
+
# Application callback failures are deliberately handled in separate
|
|
826
|
+
# exception domains.
|
|
827
|
+
def _handle_agent_completion(result_task:, invocation:, error:, mode:, listener:,
|
|
828
|
+
event_loop:, callback_error_policy:)
|
|
829
|
+
if mode == :stream && !event_loop.current?
|
|
830
|
+
completion_error = error || Phronomy::Error.new(
|
|
831
|
+
"Stream completion occurred outside the EventLoop"
|
|
832
|
+
)
|
|
833
|
+
_fail_result_task(result_task, _translated_error(completion_error))
|
|
834
|
+
return
|
|
835
|
+
end
|
|
836
|
+
|
|
837
|
+
result = nil
|
|
838
|
+
execution_error = nil
|
|
839
|
+
begin
|
|
840
|
+
raise error if error
|
|
841
|
+
|
|
842
|
+
result = _extract_invoke_result(invocation)
|
|
843
|
+
rescue => e
|
|
844
|
+
execution_error = _translated_error(e)
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
if execution_error
|
|
848
|
+
if mode == :stream
|
|
849
|
+
event = StreamEvent.new(
|
|
850
|
+
type: :error,
|
|
851
|
+
payload: {error: execution_error}
|
|
852
|
+
)
|
|
853
|
+
callback_error = _deliver_stream_event(listener, event)
|
|
854
|
+
if callback_error
|
|
855
|
+
_report_stream_callback_error(
|
|
856
|
+
callback_error,
|
|
857
|
+
event: event,
|
|
858
|
+
invocation_id: invocation&.id,
|
|
859
|
+
callback_error_policy: callback_error_policy
|
|
899
860
|
)
|
|
900
|
-
}
|
|
901
|
-
if wait > 0
|
|
902
|
-
Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
|
|
903
|
-
else
|
|
904
|
-
do_retry.call
|
|
905
|
-
end
|
|
906
|
-
elsif error
|
|
907
|
-
begin
|
|
908
|
-
translate_and_reraise!(error)
|
|
909
|
-
rescue => translated
|
|
910
|
-
result_task.backend.unblock(nil, translated)
|
|
911
|
-
result_task.transition!(:failed, error: translated)
|
|
912
|
-
end
|
|
913
|
-
else
|
|
914
|
-
begin
|
|
915
|
-
result = _extract_invoke_result(ctx, session_id)
|
|
916
|
-
result_task.backend.unblock(result, nil)
|
|
917
|
-
result_task.transition!(:completed, value: result)
|
|
918
|
-
rescue => e
|
|
919
|
-
result_task.backend.unblock(nil, e)
|
|
920
|
-
result_task.transition!(:failed, error: e)
|
|
921
861
|
end
|
|
922
862
|
end
|
|
863
|
+
|
|
864
|
+
# An Application failure while consuming :error never replaces the
|
|
865
|
+
# original Agent/LLM/Tool/Runtime failure.
|
|
866
|
+
_fail_result_task(result_task, execution_error)
|
|
867
|
+
return
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
unless mode == :stream
|
|
871
|
+
_complete_result_task(result_task, result)
|
|
872
|
+
return
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
event = _build_stream_terminal_event(result)
|
|
876
|
+
callback_error = _deliver_stream_event(listener, event)
|
|
877
|
+
unless callback_error
|
|
878
|
+
_complete_result_task(result_task, result)
|
|
879
|
+
return
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
_report_stream_callback_error(
|
|
883
|
+
callback_error,
|
|
884
|
+
event: event,
|
|
885
|
+
invocation_id: invocation&.id,
|
|
886
|
+
callback_error_policy: callback_error_policy
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
if callback_error_policy == :fail_task
|
|
890
|
+
wrapped = _build_stream_callback_error(
|
|
891
|
+
event_type: event.type,
|
|
892
|
+
callback_error: callback_error,
|
|
893
|
+
result: result
|
|
894
|
+
)
|
|
895
|
+
_fail_result_task(result_task, wrapped)
|
|
896
|
+
else
|
|
897
|
+
_complete_result_task(result_task, result)
|
|
923
898
|
end
|
|
924
|
-
rescue => e
|
|
925
|
-
result_task.backend.unblock(nil, e)
|
|
926
|
-
result_task.transition!(:failed, error: e)
|
|
927
899
|
end
|
|
928
900
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
# @param config [Hash]
|
|
936
|
-
# @return [Hash]
|
|
937
|
-
# @api public
|
|
938
|
-
def approve(session_id, approved: true, config: {})
|
|
939
|
-
ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
|
|
940
|
-
raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
|
|
941
|
-
|
|
942
|
-
# Reset approval_required so executing_tool_action proceeds instead of
|
|
943
|
-
# re-suspending when called after the :approve FSM transition.
|
|
944
|
-
ctx.approval_required = false
|
|
945
|
-
ctx.approved = true if approved # signals executing_tool to run the tool
|
|
946
|
-
ctx.rejected = !approved # signals _extract_invoke_result for rejection
|
|
947
|
-
|
|
948
|
-
if approved
|
|
949
|
-
_resume_fsm(ctx, :approve)
|
|
901
|
+
def _build_stream_terminal_event(result)
|
|
902
|
+
if result[:suspended]
|
|
903
|
+
StreamEvent.new(
|
|
904
|
+
type: :approval_required,
|
|
905
|
+
payload: {request: result[:approval_request]}
|
|
906
|
+
)
|
|
950
907
|
else
|
|
951
|
-
|
|
908
|
+
StreamEvent.new(type: :done, payload: result)
|
|
952
909
|
end
|
|
953
910
|
end
|
|
911
|
+
|
|
912
|
+
# Returns the Application exception instead of allowing it to escape the
|
|
913
|
+
# shared EventLoop. A nil return means delivery succeeded or no listener
|
|
914
|
+
# was registered.
|
|
915
|
+
def _deliver_stream_event(listener, event)
|
|
916
|
+
return unless listener
|
|
917
|
+
|
|
918
|
+
listener.call(event)
|
|
919
|
+
nil
|
|
920
|
+
rescue => callback_error
|
|
921
|
+
callback_error
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
def _build_stream_callback_error(event_type:, callback_error:, result:)
|
|
925
|
+
wrapped = Phronomy::StreamCallbackError.new(
|
|
926
|
+
event_type: event_type,
|
|
927
|
+
original_error: callback_error,
|
|
928
|
+
result: result
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
begin
|
|
932
|
+
raise wrapped, cause: callback_error
|
|
933
|
+
rescue Phronomy::StreamCallbackError => error
|
|
934
|
+
error.set_backtrace(callback_error.backtrace)
|
|
935
|
+
error
|
|
936
|
+
end
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def _report_stream_callback_error(callback_error, event:, invocation_id:,
|
|
940
|
+
callback_error_policy:)
|
|
941
|
+
lines = [
|
|
942
|
+
"[Phronomy] Stream callback failed",
|
|
943
|
+
"event=#{event.type.inspect}",
|
|
944
|
+
"agent_invocation_id=#{invocation_id || "unknown"}",
|
|
945
|
+
"policy=#{callback_error_policy.inspect}",
|
|
946
|
+
"error=#{callback_error.class}: #{callback_error.message}"
|
|
947
|
+
]
|
|
948
|
+
Array(callback_error.backtrace).each { |line| lines << " #{line}" }
|
|
949
|
+
_warn_stream_callback_error(lines.join("\n"))
|
|
950
|
+
rescue => reporting_error
|
|
951
|
+
_kernel_warn_safely(
|
|
952
|
+
"[Phronomy] Failed to report stream callback error: " \
|
|
953
|
+
"#{reporting_error.class}: #{reporting_error.message}"
|
|
954
|
+
)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
def _warn_stream_callback_error(message)
|
|
958
|
+
logger = Phronomy.configuration.logger
|
|
959
|
+
unless logger
|
|
960
|
+
_kernel_warn_safely(message)
|
|
961
|
+
return
|
|
962
|
+
end
|
|
963
|
+
|
|
964
|
+
logger.warn(message)
|
|
965
|
+
rescue => logger_error
|
|
966
|
+
_kernel_warn_safely(
|
|
967
|
+
"#{message}\n" \
|
|
968
|
+
"[Phronomy] Logger failed while reporting a stream callback error: " \
|
|
969
|
+
"#{logger_error.class}: #{logger_error.message}"
|
|
970
|
+
)
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
def _kernel_warn_safely(message)
|
|
974
|
+
Kernel.warn(message)
|
|
975
|
+
rescue
|
|
976
|
+
nil
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
# Continues a suspended AgentInvocation. The parent session is registered
|
|
980
|
+
# asynchronously; this method is only the synchronous wrapper.
|
|
981
|
+
# @return [Hash]
|
|
982
|
+
# @api public
|
|
983
|
+
def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
984
|
+
_check_scheduler_reentrancy(:approve, :approve_async)
|
|
985
|
+
approve_async(
|
|
986
|
+
agent_invocation_id,
|
|
987
|
+
approval_request_id: approval_request_id,
|
|
988
|
+
approved: approved,
|
|
989
|
+
config: config
|
|
990
|
+
).wait_result
|
|
991
|
+
end
|
|
954
992
|
public :approve
|
|
955
993
|
|
|
956
|
-
#
|
|
957
|
-
#
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
994
|
+
# Continues a suspended AgentInvocation without blocking the caller.
|
|
995
|
+
#
|
|
996
|
+
# This method is safe to call from an EventLoop stream callback. The
|
|
997
|
+
# returned Task completes when the resumed AgentInvocation finishes,
|
|
998
|
+
# suspends again, or fails.
|
|
999
|
+
# @return [Phronomy::Task]
|
|
1000
|
+
# @api public
|
|
1001
|
+
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
1002
|
+
result_task = Phronomy::Task.deferred(
|
|
1003
|
+
name: "agent-approval-resume:#{agent_invocation_id}"
|
|
964
1004
|
)
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1005
|
+
|
|
1006
|
+
begin
|
|
1007
|
+
entry = Agent::AgentInvocationRegistry.consume_approval(
|
|
1008
|
+
agent_invocation_id, approval_request_id
|
|
1009
|
+
)
|
|
1010
|
+
unless entry
|
|
1011
|
+
raise ArgumentError,
|
|
1012
|
+
"No pending approval found for AgentInvocation #{agent_invocation_id}"
|
|
1013
|
+
end
|
|
1014
|
+
|
|
1015
|
+
_start_approval_resume(
|
|
1016
|
+
result_task,
|
|
1017
|
+
entry.invocation,
|
|
1018
|
+
approved: approved,
|
|
1019
|
+
config: config
|
|
1020
|
+
)
|
|
1021
|
+
rescue => e
|
|
1022
|
+
_fail_result_task(result_task, e)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
result_task
|
|
969
1026
|
end
|
|
1027
|
+
public :approve_async
|
|
970
1028
|
|
|
971
|
-
#
|
|
972
|
-
# the
|
|
1029
|
+
# Parent completion handling is installed before EventLoop registration,
|
|
1030
|
+
# and the parent session is registered before child sessions so immediate
|
|
1031
|
+
# child events cannot be lost.
|
|
973
1032
|
# @api private
|
|
974
|
-
def
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1033
|
+
def _start_approval_resume(result_task, invocation, approved:, config:)
|
|
1034
|
+
invocation.merge_config!(config)
|
|
1035
|
+
invocation.begin_approval_resume!(approved: approved)
|
|
1036
|
+
runtime = Phronomy::Runtime.instance
|
|
1037
|
+
event_loop = runtime.event_loop
|
|
1038
|
+
source_task = Phronomy::Task.deferred(
|
|
1039
|
+
name: "#{result_task.name}-source"
|
|
1040
|
+
)
|
|
1041
|
+
parent_session = Agent::AgentInvocationSessionBuilder.build_for_resume(
|
|
1042
|
+
agent_invocation: invocation,
|
|
1043
|
+
resume_event: :resume,
|
|
1044
|
+
resume_phase: :suspended,
|
|
1045
|
+
runtime: runtime
|
|
1046
|
+
)
|
|
1047
|
+
stream_listener = invocation.stream_listener
|
|
1048
|
+
mode = stream_listener ? :stream : :invoke
|
|
1049
|
+
callback_error_policy =
|
|
1050
|
+
Phronomy.configuration.stream_callback_error_policy
|
|
1051
|
+
|
|
1052
|
+
source_task.on_complete do |completed_invocation, error|
|
|
1053
|
+
_handle_agent_completion(
|
|
1054
|
+
result_task: result_task,
|
|
1055
|
+
invocation: completed_invocation,
|
|
1056
|
+
error: error,
|
|
1057
|
+
mode: mode,
|
|
1058
|
+
listener: stream_listener,
|
|
1059
|
+
event_loop: event_loop,
|
|
1060
|
+
callback_error_policy: callback_error_policy
|
|
1061
|
+
)
|
|
1062
|
+
end
|
|
1063
|
+
|
|
1064
|
+
# The parent must exist before any child can post an immediate result.
|
|
1065
|
+
event_loop.register(parent_session, completion: source_task)
|
|
1066
|
+
|
|
1067
|
+
invocation.tool_invocations.each do |child|
|
|
1068
|
+
child_session = if child.awaiting_approval?
|
|
1069
|
+
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
1070
|
+
tool_invocation: child,
|
|
1071
|
+
resume_event: approved ? :approve : :reject,
|
|
1072
|
+
resume_phase: :awaiting_approval,
|
|
1073
|
+
runtime: runtime
|
|
1074
|
+
)
|
|
1075
|
+
elsif !approved && child.authorized?
|
|
1076
|
+
Agent::ToolInvocationSessionBuilder.build_for_resume(
|
|
1077
|
+
tool_invocation: child,
|
|
1078
|
+
resume_event: :cancel,
|
|
1079
|
+
resume_phase: :authorized,
|
|
1080
|
+
runtime: runtime
|
|
1081
|
+
)
|
|
1082
|
+
end
|
|
1083
|
+
_register_tool_invocation_session(event_loop, runtime, child, child_session) if child_session
|
|
1084
|
+
end
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def _extract_invoke_result(invocation)
|
|
1088
|
+
if invocation.phase == :suspended
|
|
1089
|
+
request = invocation.approval_request
|
|
1090
|
+
Agent::AgentInvocationRegistry.store_suspended(invocation, request)
|
|
1091
|
+
_dispatch_tool_approval_notification(invocation, request)
|
|
1092
|
+
{
|
|
1093
|
+
suspended: true,
|
|
1094
|
+
agent_invocation_id: invocation.id,
|
|
1095
|
+
approval_request: request,
|
|
1096
|
+
messages: invocation.messages
|
|
1097
|
+
}
|
|
1098
|
+
elsif invocation.input_blocked? || invocation.output_blocked?
|
|
1099
|
+
raise invocation.block_error
|
|
1100
|
+
elsif invocation.error
|
|
1101
|
+
raise invocation.error
|
|
1102
|
+
elsif invocation.rejected
|
|
1103
|
+
{rejected: true, messages: invocation.messages}
|
|
983
1104
|
else
|
|
984
|
-
{output:
|
|
1105
|
+
{output: invocation.output, messages: invocation.messages, usage: invocation.usage}
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
def _register_tool_invocation_session(event_loop, runtime, child, session)
|
|
1110
|
+
completion = Phronomy::Task.deferred(name: "tool-session:#{child.id}")
|
|
1111
|
+
completion.on_complete do |_result, error|
|
|
1112
|
+
next unless error
|
|
1113
|
+
|
|
1114
|
+
child.mark_framework_failed!(error)
|
|
1115
|
+
runtime.event_loop.post(
|
|
1116
|
+
Phronomy::Event.new(
|
|
1117
|
+
type: :tool_failed,
|
|
1118
|
+
target_id: child.parent_agent_invocation_id,
|
|
1119
|
+
payload: {tool_invocation_id: child.id}
|
|
1120
|
+
)
|
|
1121
|
+
)
|
|
1122
|
+
end
|
|
1123
|
+
event_loop.register(session, completion: completion)
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
def _dispatch_tool_approval_notification(invocation, request)
|
|
1127
|
+
listener = invocation.approval_listener
|
|
1128
|
+
return unless listener
|
|
1129
|
+
|
|
1130
|
+
Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
|
|
1131
|
+
listener.call(request)
|
|
1132
|
+
end
|
|
1133
|
+
rescue => e
|
|
1134
|
+
message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
|
|
1135
|
+
if Phronomy.configuration.logger
|
|
1136
|
+
Phronomy.configuration.logger.warn(message)
|
|
1137
|
+
else
|
|
1138
|
+
Kernel.warn(message)
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def _approval_configuration_mutex
|
|
1143
|
+
return @approval_configuration_mutex if @approval_configuration_mutex
|
|
1144
|
+
|
|
1145
|
+
APPROVAL_CONFIGURATION_INIT_MUTEX.synchronize do
|
|
1146
|
+
@approval_configuration_mutex ||= Mutex.new
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
def _approval_configuration_snapshot(invocation_listener = nil)
|
|
1151
|
+
_approval_configuration_mutex.synchronize do
|
|
1152
|
+
{
|
|
1153
|
+
policy: @tool_approval_policy,
|
|
1154
|
+
listener: invocation_listener || @tool_approval_listener
|
|
1155
|
+
}.freeze
|
|
985
1156
|
end
|
|
986
1157
|
end
|
|
987
1158
|
|
|
@@ -1002,22 +1173,6 @@ module Phronomy
|
|
|
1002
1173
|
context[:messages].each { |msg| chat.messages << msg }
|
|
1003
1174
|
end
|
|
1004
1175
|
|
|
1005
|
-
def _drain_stream(chat, user_message, config, &block)
|
|
1006
|
-
adapter = Phronomy.configuration.llm_adapter
|
|
1007
|
-
chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
|
|
1008
|
-
pending = adapter.stream_async(chat, user_message, config: config, enqueue_to: chunk_queue)
|
|
1009
|
-
|
|
1010
|
-
loop do
|
|
1011
|
-
chunk = chunk_queue.pop
|
|
1012
|
-
break if chunk.nil?
|
|
1013
|
-
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
1014
|
-
check_cancellation!(config, "invocation cancelled during streaming")
|
|
1015
|
-
end
|
|
1016
|
-
|
|
1017
|
-
response = pending.blocking_wait
|
|
1018
|
-
[response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
|
|
1019
|
-
end
|
|
1020
|
-
|
|
1021
1176
|
# Builds a TokenBudget for this agent's model if possible.
|
|
1022
1177
|
# When context_window is set at the class level, that value is used directly
|
|
1023
1178
|
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
@@ -1065,7 +1220,7 @@ module Phronomy
|
|
|
1065
1220
|
t = self.class.temperature
|
|
1066
1221
|
parallel_class = build_chat_class
|
|
1067
1222
|
chat = if parallel_class
|
|
1068
|
-
parallel_class.new(
|
|
1223
|
+
parallel_class.new(**opts)
|
|
1069
1224
|
else
|
|
1070
1225
|
RubyLLM.chat(**opts)
|
|
1071
1226
|
end
|
|
@@ -1126,33 +1281,12 @@ module Phronomy
|
|
|
1126
1281
|
raise Phronomy::CancellationError, message if ct&.cancelled?
|
|
1127
1282
|
end
|
|
1128
1283
|
|
|
1129
|
-
# Builds the final
|
|
1130
|
-
#
|
|
1131
|
-
#
|
|
1132
|
-
# {Phronomy::Tools::Mcp} returned by +Phronomy::Tools::Mcp.from_server+), it is
|
|
1133
|
-
# returned as-is. RubyLLM's +with_tool+ accepts both classes and
|
|
1134
|
-
# instances, so no wrapping is needed.
|
|
1135
|
-
#
|
|
1136
|
-
# For tool classes, three transformations are applied in order:
|
|
1137
|
-
# 1. Alias override — when the Hash form of .tools maps this class to an
|
|
1138
|
-
# explicit name, an anonymous subclass with that tool_name is returned.
|
|
1139
|
-
# 2. Scope policy — when a scope is declared on the tool, the configured
|
|
1140
|
-
# {Phronomy::Agent::Context::Capability::ScopePolicy} (or the default) is evaluated.
|
|
1141
|
-
# +:reject+ wraps the tool to return a denial message without executing.
|
|
1142
|
-
# +:approve+ behaves like requiring approval (same as step 3 when the
|
|
1143
|
-
# tool does not already have +requires_approval+).
|
|
1144
|
-
# 3. Approval gate — when the tool class has +requires_approval+ set AND
|
|
1145
|
-
# an approval handler has been registered via #on_approval_required,
|
|
1146
|
-
# the tool's #call method is wrapped: the handler is invoked with
|
|
1147
|
-
# (tool_name, args) and, if it returns falsy, the tool returns a denial
|
|
1148
|
-
# message instead of executing.
|
|
1284
|
+
# Builds the final Tool class to register with RubyLLM. Alias and Tool
|
|
1285
|
+
# result filters remain wrappers; authorization is handled only by
|
|
1286
|
+
# ToolInvocation before Tool#call begins.
|
|
1149
1287
|
def prepare_tool_class(tool_class)
|
|
1150
|
-
# When an instantiated tool object is passed (e.g. Phronomy::Tools::Mcp.from_server
|
|
1151
|
-
# returns an instance, not a class), skip class-level processing and
|
|
1152
|
-
# return it directly. RubyLLM#with_tool handles both forms.
|
|
1153
1288
|
return tool_class unless tool_class.is_a?(Class)
|
|
1154
1289
|
|
|
1155
|
-
# Step 1: apply alias if needed.
|
|
1156
1290
|
resolved = if (alias_name = self.class.tool_aliases[tool_class])
|
|
1157
1291
|
parent_description = tool_class.description
|
|
1158
1292
|
Class.new(tool_class) do
|
|
@@ -1163,62 +1297,17 @@ module Phronomy
|
|
|
1163
1297
|
tool_class
|
|
1164
1298
|
end
|
|
1165
1299
|
|
|
1166
|
-
# Step 2: evaluate scope policy.
|
|
1167
|
-
scope = resolved.scope
|
|
1168
|
-
if scope
|
|
1169
|
-
policy = @scope_policy || Phronomy::Agent::Context::Capability::ScopePolicy::DEFAULT
|
|
1170
|
-
decision = policy.call(resolved, scope, self)
|
|
1171
|
-
case decision
|
|
1172
|
-
when :reject
|
|
1173
|
-
effective_name = resolved.new.name
|
|
1174
|
-
rejected_class = Class.new(resolved) do
|
|
1175
|
-
tool_name effective_name
|
|
1176
|
-
define_method(:call) do |_args, **_kwargs|
|
|
1177
|
-
"Tool execution denied: scope :#{scope} is not permitted."
|
|
1178
|
-
end
|
|
1179
|
-
end
|
|
1180
|
-
return rejected_class
|
|
1181
|
-
when :approve
|
|
1182
|
-
# Treat as requires_approval unless the tool already has that flag.
|
|
1183
|
-
unless resolved.requires_approval
|
|
1184
|
-
effective_name = resolved.new.name
|
|
1185
|
-
resolved = Class.new(resolved) do
|
|
1186
|
-
tool_name effective_name
|
|
1187
|
-
requires_approval true
|
|
1188
|
-
end
|
|
1189
|
-
end
|
|
1190
|
-
end
|
|
1191
|
-
end
|
|
1192
|
-
|
|
1193
|
-
# Step 3: wrap with approval gate when handler is registered.
|
|
1194
|
-
if resolved.requires_approval && @approval_handler
|
|
1195
|
-
handler = @approval_handler
|
|
1196
|
-
# Capture the effective tool name before building the anonymous subclass.
|
|
1197
|
-
# Class-level instance variables (@tool_name) are not inherited through
|
|
1198
|
-
# subclassing, so the wrapper must set it explicitly.
|
|
1199
|
-
effective_name = resolved.new.name
|
|
1200
|
-
resolved = Class.new(resolved) do
|
|
1201
|
-
tool_name effective_name
|
|
1202
|
-
define_method(:call) do |args, **kwargs|
|
|
1203
|
-
if handler.call(name, args)
|
|
1204
|
-
super(args, **kwargs)
|
|
1205
|
-
else
|
|
1206
|
-
"Tool execution denied."
|
|
1207
|
-
end
|
|
1208
|
-
end
|
|
1209
|
-
end
|
|
1210
|
-
end
|
|
1211
|
-
|
|
1212
|
-
# Step 4: wrap with tool result filters when registered.
|
|
1213
1300
|
result_filters = _tool_result_filters_for(tool_class)
|
|
1214
1301
|
return resolved if result_filters.empty?
|
|
1215
1302
|
|
|
1216
|
-
|
|
1303
|
+
effective_name = resolved.new.name
|
|
1217
1304
|
Class.new(resolved) do
|
|
1218
|
-
tool_name
|
|
1305
|
+
tool_name effective_name
|
|
1219
1306
|
define_method(:call) do |args, **kwargs|
|
|
1220
1307
|
result = super(args, **kwargs)
|
|
1221
|
-
result_filters.inject(result) { |val,
|
|
1308
|
+
result_filters.inject(result) { |val, filter|
|
|
1309
|
+
filter.call(val, tool_name: name, args: args)
|
|
1310
|
+
}
|
|
1222
1311
|
end
|
|
1223
1312
|
end
|
|
1224
1313
|
end
|