phronomy 0.14.0 → 0.15.1
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 +77 -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 +553 -0
- data/lib/phronomy/agent/base.rb +242 -509
- 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 -47
- 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
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,172 +364,26 @@ 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
|
-
|
|
428
|
-
self
|
|
429
|
-
end
|
|
372
|
+
def tool_approval_policy(&block)
|
|
373
|
+
raise ArgumentError, "tool_approval_policy requires a block" unless block
|
|
430
374
|
|
|
431
|
-
|
|
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]
|
|
438
|
-
# @api public
|
|
439
|
-
attr_writer :scope_policy
|
|
440
|
-
|
|
441
|
-
# Invokes the agent with the given input and returns a result Hash.
|
|
442
|
-
# Applies the retry policy configured via {.retry_policy} when transient
|
|
443
|
-
# errors occur. {Phronomy::FilterBlockError} is never retried.
|
|
444
|
-
#
|
|
445
|
-
# @param input [String, Hash] the user message; a Hash may supply
|
|
446
|
-
# +:message+, +:query+, or +:user+ as the text key, plus any template
|
|
447
|
-
# variables consumed by the configured instructions template.
|
|
448
|
-
# @param messages [Array<RubyLLM::Message>] conversation history from a
|
|
449
|
-
# previous invocation. The application owns and persists this array;
|
|
450
|
-
# pass it on every turn to maintain multi-turn context.
|
|
451
|
-
# @param thread_id [String, nil] conversation thread identifier, forwarded
|
|
452
|
-
# to the compaction context when on_compact is configured.
|
|
453
|
-
# @param config [Hash] additional runtime options:
|
|
454
|
-
# +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
|
|
455
|
-
# +:session_id+ (+String+, optional) — session identity forwarded to the tracer
|
|
456
|
-
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
457
|
-
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
458
|
-
# derived from it (existing +config:+ keys take precedence as backward-compat
|
|
459
|
-
# aliases). The object is also stored in +config[:invocation_context]+ so that
|
|
460
|
-
# +task_id+ / +parent_task_id+ appear in trace spans automatically.
|
|
461
|
-
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
462
|
-
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
463
|
-
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
464
|
-
# @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
|
|
465
|
-
# @example Normal invocation
|
|
466
|
-
# result = MyAgent.new.invoke("What is Ruby?")
|
|
467
|
-
# puts result[:output]
|
|
468
|
-
# @example Multi-turn conversation
|
|
469
|
-
# result1 = agent.invoke("Hi, I'm Alice.")
|
|
470
|
-
# result2 = agent.invoke("What's my name?", messages: result1[:messages])
|
|
471
|
-
# @example Suspend / resume flow
|
|
472
|
-
# result = agent.invoke("Perform task X")
|
|
473
|
-
# if result[:suspended]
|
|
474
|
-
# result = agent.resume(result[:checkpoint], approved: true)
|
|
475
|
-
# end
|
|
476
|
-
# puts result[:output]
|
|
477
|
-
# @example With InvocationContext (deadline-based timeout)
|
|
478
|
-
# ctx = Phronomy::InvocationContext.new(
|
|
479
|
-
# thread_id: "conv-123",
|
|
480
|
-
# deadline: Phronomy::Concurrency::Deadline.in(30),
|
|
481
|
-
# task_id: SecureRandom.uuid
|
|
482
|
-
# )
|
|
483
|
-
# result = MyAgent.new.invoke("Hello", invocation_context: ctx)
|
|
484
|
-
# @api public
|
|
485
|
-
def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
|
|
486
|
-
if invocation_context
|
|
487
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
488
|
-
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
|
|
514
|
-
|
|
515
|
-
result = scope.pop_queue(completion_queue) do
|
|
516
|
-
raise Phronomy::TimeoutError,
|
|
517
|
-
"Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
|
|
518
|
-
end
|
|
519
|
-
raise result if result.is_a?(Exception)
|
|
520
|
-
result
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
# Invokes this agent asynchronously and returns a {Phronomy::Task}.
|
|
524
|
-
#
|
|
525
|
-
# This is the primary async entry point. {#invoke} is a synchronous wrapper
|
|
526
|
-
# that calls this method and blocks the caller until the task completes.
|
|
527
|
-
# Calling {#invoke} from inside an active scheduler task raises
|
|
528
|
-
# {Phronomy::SchedulerReentrancyError}; use +invoke_async+ directly in that
|
|
529
|
-
# context.
|
|
530
|
-
#
|
|
531
|
-
# The task is registered with the Runtime task registry so {Runtime#shutdown}
|
|
532
|
-
# drains in-flight invocations before process exit.
|
|
533
|
-
#
|
|
534
|
-
# @example
|
|
535
|
-
# task = agent.invoke_async("Hello!")
|
|
536
|
-
# result = task.wait_result # => { output: "...", messages: [...], usage: ... }
|
|
537
|
-
#
|
|
538
|
-
# @param input [String, Hash]
|
|
539
|
-
# @param messages [Array]
|
|
540
|
-
# @param thread_id [String, nil]
|
|
541
|
-
# @param config [Hash]
|
|
542
|
-
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
543
|
-
# @return [Phronomy::Task]
|
|
544
|
-
# @api public
|
|
545
|
-
def invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
|
|
546
|
-
if invocation_context
|
|
547
|
-
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
548
|
-
end
|
|
549
|
-
result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
|
|
550
|
-
_start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
|
|
551
|
-
result_task
|
|
375
|
+
_approval_configuration_mutex.synchronize { @tool_approval_policy = block }
|
|
376
|
+
self
|
|
552
377
|
end
|
|
553
378
|
|
|
554
|
-
#
|
|
555
|
-
#
|
|
556
|
-
#
|
|
557
|
-
# Events emitted (in order):
|
|
558
|
-
# :token — each content delta from the LLM
|
|
559
|
-
# :tool_call — when the LLM requests a tool
|
|
560
|
-
# :tool_result — after a tool completes
|
|
561
|
-
# :done — final event carrying output, messages, and usage
|
|
562
|
-
# :error — if an unrecoverable error occurs
|
|
563
|
-
#
|
|
564
|
-
# @param input [String, Hash] same as #invoke
|
|
565
|
-
# @param messages [Array<RubyLLM::Message>] same as #invoke
|
|
566
|
-
# @param thread_id [String, nil] same as #invoke
|
|
567
|
-
# @param config [Hash] same as #invoke
|
|
568
|
-
# @yield [Phronomy::Agent::StreamEvent]
|
|
569
|
-
# @return [Hash] { output:, messages:, usage: } — same as #invoke
|
|
379
|
+
# Registers a non-blocking Application notification listener.
|
|
380
|
+
# @return [self]
|
|
570
381
|
# @api public
|
|
571
|
-
def
|
|
572
|
-
|
|
382
|
+
def on_tool_approval_required(&block)
|
|
383
|
+
raise ArgumentError, "on_tool_approval_required requires a block" unless block
|
|
573
384
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
block&.call(StreamEvent.new(type: :error, payload: {error: e}))
|
|
577
|
-
raise
|
|
385
|
+
_approval_configuration_mutex.synchronize { @tool_approval_listener = block }
|
|
386
|
+
self
|
|
578
387
|
end
|
|
579
388
|
|
|
580
389
|
# @deprecated The context version cache has been removed. Returns nil.
|
|
@@ -604,12 +413,18 @@ module Phronomy
|
|
|
604
413
|
[effective_thread_id, effective_config]
|
|
605
414
|
end
|
|
606
415
|
|
|
607
|
-
def _check_scheduler_reentrancy
|
|
416
|
+
def _check_scheduler_reentrancy(sync_method, async_method)
|
|
417
|
+
if Phronomy::Runtime.instance.event_loop.current?
|
|
418
|
+
raise Phronomy::SchedulerReentrancyError,
|
|
419
|
+
"#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
|
|
420
|
+
"Use #{async_method} and return immediately."
|
|
421
|
+
end
|
|
422
|
+
|
|
608
423
|
return unless Phronomy::Task.current
|
|
609
424
|
|
|
610
|
-
msg = "#{self.class.name}
|
|
425
|
+
msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
|
|
611
426
|
"This blocks the scheduler until the inner invocation completes, preventing " \
|
|
612
|
-
"other tasks from making progress. Use
|
|
427
|
+
"other tasks from making progress. Use #{async_method} + await instead."
|
|
613
428
|
if Phronomy.configuration.strict_runtime_guards
|
|
614
429
|
raise Phronomy::SchedulerReentrancyError, msg
|
|
615
430
|
elsif Phronomy.configuration.logger
|
|
@@ -619,48 +434,6 @@ module Phronomy
|
|
|
619
434
|
end
|
|
620
435
|
end
|
|
621
436
|
|
|
622
|
-
# Streaming implementation for #stream.
|
|
623
|
-
def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
|
|
624
|
-
trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
|
|
625
|
-
input = run_input_filters!(input)
|
|
626
|
-
|
|
627
|
-
chat = build_chat
|
|
628
|
-
user_message = extract_message(input)
|
|
629
|
-
context = build_context(
|
|
630
|
-
input,
|
|
631
|
-
messages: messages,
|
|
632
|
-
thread_id: thread_id,
|
|
633
|
-
config: config,
|
|
634
|
-
budget: build_token_budget,
|
|
635
|
-
instruction: build_instructions(input),
|
|
636
|
-
tools: self.class.tools + _handoff_tools
|
|
637
|
-
)
|
|
638
|
-
_apply_context_to_chat(chat, context)
|
|
639
|
-
|
|
640
|
-
current_tool_call = nil
|
|
641
|
-
chat.on_tool_call do |tool_call|
|
|
642
|
-
current_tool_call = tool_call
|
|
643
|
-
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call}))
|
|
644
|
-
end
|
|
645
|
-
chat.on_tool_result do |tool_result|
|
|
646
|
-
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
647
|
-
tool_call_id: current_tool_call&.id,
|
|
648
|
-
tool_name: current_tool_call&.name,
|
|
649
|
-
tool_result: tool_result
|
|
650
|
-
}))
|
|
651
|
-
end
|
|
652
|
-
|
|
653
|
-
run_before_completion_hooks!(chat, config)
|
|
654
|
-
|
|
655
|
-
output, usage = _drain_stream(chat, user_message, config, &block)
|
|
656
|
-
output = run_output_filters!(output)
|
|
657
|
-
|
|
658
|
-
result = {output: output, messages: chat.messages, usage: usage}
|
|
659
|
-
block.call(StreamEvent.new(type: :done, payload: result))
|
|
660
|
-
[result, usage]
|
|
661
|
-
end
|
|
662
|
-
end
|
|
663
|
-
|
|
664
437
|
# Assembles the LLM context (system prompt + conversation messages)
|
|
665
438
|
# for a single invocation. Subclasses may override this method to
|
|
666
439
|
# inject custom context editing logic without having to override
|
|
@@ -828,160 +601,202 @@ module Phronomy
|
|
|
828
601
|
end
|
|
829
602
|
protected :instance_knowledge_chunks
|
|
830
603
|
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
def
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
604
|
+
def _complete_result_task(task, result)
|
|
605
|
+
task.backend.unblock(result, nil)
|
|
606
|
+
task.transition!(:completed, value: result)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def _fail_result_task(task, error)
|
|
610
|
+
task.backend.unblock(nil, error)
|
|
611
|
+
task.transition!(:failed, error: error)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def _translated_error(error)
|
|
615
|
+
translate_and_reraise!(error)
|
|
616
|
+
rescue => translated
|
|
617
|
+
translated
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def _build_stream_terminal_event(result)
|
|
621
|
+
if result[:suspended]
|
|
622
|
+
StreamEvent.new(
|
|
623
|
+
type: :approval_required,
|
|
624
|
+
payload: {request: result[:approval_request]}
|
|
849
625
|
)
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
raise ctx if ctx.is_a?(Exception)
|
|
853
|
-
result = _extract_invoke_result(ctx, session.id)
|
|
854
|
-
[result, result[:usage]]
|
|
626
|
+
else
|
|
627
|
+
StreamEvent.new(type: :done, payload: result)
|
|
855
628
|
end
|
|
856
629
|
end
|
|
857
630
|
|
|
858
|
-
#
|
|
859
|
-
#
|
|
860
|
-
#
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
631
|
+
# Returns the Application exception instead of allowing it to escape the
|
|
632
|
+
# shared EventLoop. A nil return means delivery succeeded or no listener
|
|
633
|
+
# was registered.
|
|
634
|
+
def _deliver_stream_event(listener, event)
|
|
635
|
+
return unless listener
|
|
636
|
+
|
|
637
|
+
listener.call(event)
|
|
638
|
+
nil
|
|
639
|
+
rescue => callback_error
|
|
640
|
+
callback_error
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def _build_stream_callback_error(event_type:, callback_error:, result:)
|
|
644
|
+
wrapped = Phronomy::StreamCallbackError.new(
|
|
645
|
+
event_type: event_type,
|
|
646
|
+
original_error: callback_error,
|
|
647
|
+
result: result
|
|
874
648
|
)
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
result_task.backend.unblock(nil, translated)
|
|
908
|
-
result_task.transition!(:failed, error: translated)
|
|
909
|
-
end
|
|
910
|
-
else
|
|
911
|
-
begin
|
|
912
|
-
result = _extract_invoke_result(ctx, session_id)
|
|
913
|
-
result_task.backend.unblock(result, nil)
|
|
914
|
-
result_task.transition!(:completed, value: result)
|
|
915
|
-
rescue => e
|
|
916
|
-
result_task.backend.unblock(nil, e)
|
|
917
|
-
result_task.transition!(:failed, error: e)
|
|
918
|
-
end
|
|
919
|
-
end
|
|
649
|
+
|
|
650
|
+
begin
|
|
651
|
+
raise wrapped, cause: callback_error
|
|
652
|
+
rescue Phronomy::StreamCallbackError => error
|
|
653
|
+
error.set_backtrace(callback_error.backtrace)
|
|
654
|
+
error
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def _report_stream_callback_error(callback_error, event:, invocation_id:,
|
|
659
|
+
callback_error_policy:)
|
|
660
|
+
lines = [
|
|
661
|
+
"[Phronomy] Stream callback failed",
|
|
662
|
+
"event=#{event.type.inspect}",
|
|
663
|
+
"agent_invocation_id=#{invocation_id || "unknown"}",
|
|
664
|
+
"policy=#{callback_error_policy.inspect}",
|
|
665
|
+
"error=#{callback_error.class}: #{callback_error.message}"
|
|
666
|
+
]
|
|
667
|
+
Array(callback_error.backtrace).each { |line| lines << " #{line}" }
|
|
668
|
+
_warn_stream_callback_error(lines.join("\n"))
|
|
669
|
+
rescue => reporting_error
|
|
670
|
+
_kernel_warn_safely(
|
|
671
|
+
"[Phronomy] Failed to report stream callback error: " \
|
|
672
|
+
"#{reporting_error.class}: #{reporting_error.message}"
|
|
673
|
+
)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def _warn_stream_callback_error(message)
|
|
677
|
+
logger = Phronomy.configuration.logger
|
|
678
|
+
unless logger
|
|
679
|
+
_kernel_warn_safely(message)
|
|
680
|
+
return
|
|
920
681
|
end
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
682
|
+
|
|
683
|
+
logger.warn(message)
|
|
684
|
+
rescue => logger_error
|
|
685
|
+
_kernel_warn_safely(
|
|
686
|
+
"#{message}\n" \
|
|
687
|
+
"[Phronomy] Logger failed while reporting a stream callback error: " \
|
|
688
|
+
"#{logger_error.class}: #{logger_error.message}"
|
|
689
|
+
)
|
|
924
690
|
end
|
|
925
691
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
#
|
|
692
|
+
def _kernel_warn_safely(message)
|
|
693
|
+
Kernel.warn(message)
|
|
694
|
+
rescue
|
|
695
|
+
nil
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Continues a suspended AgentInvocation. The parent session is registered
|
|
699
|
+
# asynchronously; this method is only the synchronous wrapper.
|
|
933
700
|
# @return [Hash]
|
|
934
701
|
# @api public
|
|
935
|
-
def approve(
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
ctx.rejected = !approved # signals _extract_invoke_result for rejection
|
|
944
|
-
|
|
945
|
-
if approved
|
|
946
|
-
_resume_fsm(ctx, :approve)
|
|
947
|
-
else
|
|
948
|
-
_resume_fsm(ctx, :reject)
|
|
949
|
-
end
|
|
702
|
+
def approve(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
703
|
+
_check_scheduler_reentrancy(:approve, :approve_async)
|
|
704
|
+
approve_async(
|
|
705
|
+
agent_invocation_id,
|
|
706
|
+
approval_request_id: approval_request_id,
|
|
707
|
+
approved: approved,
|
|
708
|
+
config: config
|
|
709
|
+
).wait_result
|
|
950
710
|
end
|
|
951
711
|
public :approve
|
|
952
712
|
|
|
953
|
-
#
|
|
954
|
-
#
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
runtime: runtime
|
|
713
|
+
# Continues a suspended AgentInvocation without blocking the caller.
|
|
714
|
+
#
|
|
715
|
+
# This method is safe to call from an EventLoop stream callback. The
|
|
716
|
+
# returned Task completes when the resumed AgentInvocation finishes,
|
|
717
|
+
# suspends again, or fails.
|
|
718
|
+
# @return [Phronomy::Task]
|
|
719
|
+
# @api public
|
|
720
|
+
def approve_async(agent_invocation_id, approval_request_id:, approved: true, config: {})
|
|
721
|
+
result_task = Phronomy::Task.deferred(
|
|
722
|
+
name: "agent-approval-resume:#{agent_invocation_id}"
|
|
964
723
|
)
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
724
|
+
|
|
725
|
+
begin
|
|
726
|
+
entry = Agent::AgentInvocationRegistry.consume_approval(
|
|
727
|
+
agent_invocation_id, approval_request_id
|
|
728
|
+
)
|
|
729
|
+
unless entry
|
|
730
|
+
raise ArgumentError,
|
|
731
|
+
"No pending approval found for AgentInvocation #{agent_invocation_id}"
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
_start_approval_resume(
|
|
735
|
+
result_task,
|
|
736
|
+
entry.invocation,
|
|
737
|
+
approved: approved,
|
|
738
|
+
config: config
|
|
739
|
+
)
|
|
740
|
+
rescue => e
|
|
741
|
+
_fail_result_task(result_task, e)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
result_task
|
|
745
|
+
end
|
|
746
|
+
public :approve_async
|
|
747
|
+
|
|
748
|
+
def _extract_invoke_result(invocation)
|
|
749
|
+
if invocation.phase == :suspended
|
|
750
|
+
request = invocation.approval_request
|
|
751
|
+
Agent::AgentInvocationRegistry.store_suspended(invocation, request)
|
|
752
|
+
_dispatch_tool_approval_notification(invocation, request)
|
|
753
|
+
{
|
|
754
|
+
suspended: true,
|
|
755
|
+
agent_invocation_id: invocation.id,
|
|
756
|
+
approval_request: request,
|
|
757
|
+
messages: invocation.messages
|
|
758
|
+
}
|
|
759
|
+
elsif invocation.input_blocked? || invocation.output_blocked?
|
|
760
|
+
raise invocation.block_error
|
|
761
|
+
elsif invocation.error
|
|
762
|
+
raise invocation.error
|
|
763
|
+
elsif invocation.rejected
|
|
764
|
+
{rejected: true, messages: invocation.messages}
|
|
765
|
+
else
|
|
766
|
+
{output: invocation.output, messages: invocation.messages, usage: invocation.usage}
|
|
767
|
+
end
|
|
969
768
|
end
|
|
970
769
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
{rejected: true, messages: ctx.messages}
|
|
770
|
+
def _dispatch_tool_approval_notification(invocation, request)
|
|
771
|
+
listener = invocation.approval_listener
|
|
772
|
+
return unless listener
|
|
773
|
+
|
|
774
|
+
Phronomy::Runtime.instance.blocking_io.submit(on_full: :raise) do
|
|
775
|
+
listener.call(request)
|
|
776
|
+
end
|
|
777
|
+
rescue => e
|
|
778
|
+
message = "[Phronomy] Tool approval notification failed: #{e.class}: #{e.message}"
|
|
779
|
+
if Phronomy.configuration.logger
|
|
780
|
+
Phronomy.configuration.logger.warn(message)
|
|
983
781
|
else
|
|
984
|
-
|
|
782
|
+
Kernel.warn(message)
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def _approval_configuration_mutex
|
|
787
|
+
return @approval_configuration_mutex if @approval_configuration_mutex
|
|
788
|
+
|
|
789
|
+
APPROVAL_CONFIGURATION_INIT_MUTEX.synchronize do
|
|
790
|
+
@approval_configuration_mutex ||= Mutex.new
|
|
791
|
+
end
|
|
792
|
+
end
|
|
793
|
+
|
|
794
|
+
def _approval_configuration_snapshot(invocation_listener = nil)
|
|
795
|
+
_approval_configuration_mutex.synchronize do
|
|
796
|
+
{
|
|
797
|
+
policy: @tool_approval_policy,
|
|
798
|
+
listener: invocation_listener || @tool_approval_listener
|
|
799
|
+
}.freeze
|
|
985
800
|
end
|
|
986
801
|
end
|
|
987
802
|
|
|
@@ -1002,22 +817,6 @@ module Phronomy
|
|
|
1002
817
|
context[:messages].each { |msg| chat.messages << msg }
|
|
1003
818
|
end
|
|
1004
819
|
|
|
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
820
|
# Builds a TokenBudget for this agent's model if possible.
|
|
1022
821
|
# When context_window is set at the class level, that value is used directly
|
|
1023
822
|
# (bypassing the RubyLLM catalogue) — useful for locally-hosted models where
|
|
@@ -1065,7 +864,7 @@ module Phronomy
|
|
|
1065
864
|
t = self.class.temperature
|
|
1066
865
|
parallel_class = build_chat_class
|
|
1067
866
|
chat = if parallel_class
|
|
1068
|
-
parallel_class.new(
|
|
867
|
+
parallel_class.new(**opts)
|
|
1069
868
|
else
|
|
1070
869
|
RubyLLM.chat(**opts)
|
|
1071
870
|
end
|
|
@@ -1126,33 +925,12 @@ module Phronomy
|
|
|
1126
925
|
raise Phronomy::CancellationError, message if ct&.cancelled?
|
|
1127
926
|
end
|
|
1128
927
|
|
|
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.
|
|
928
|
+
# Builds the final Tool class to register with RubyLLM. Alias and Tool
|
|
929
|
+
# result filters remain wrappers; authorization is handled only by
|
|
930
|
+
# ToolInvocation before Tool#call begins.
|
|
1149
931
|
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
932
|
return tool_class unless tool_class.is_a?(Class)
|
|
1154
933
|
|
|
1155
|
-
# Step 1: apply alias if needed.
|
|
1156
934
|
resolved = if (alias_name = self.class.tool_aliases[tool_class])
|
|
1157
935
|
parent_description = tool_class.description
|
|
1158
936
|
Class.new(tool_class) do
|
|
@@ -1163,62 +941,17 @@ module Phronomy
|
|
|
1163
941
|
tool_class
|
|
1164
942
|
end
|
|
1165
943
|
|
|
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
944
|
result_filters = _tool_result_filters_for(tool_class)
|
|
1214
945
|
return resolved if result_filters.empty?
|
|
1215
946
|
|
|
1216
|
-
|
|
947
|
+
effective_name = resolved.new.name
|
|
1217
948
|
Class.new(resolved) do
|
|
1218
|
-
tool_name
|
|
949
|
+
tool_name effective_name
|
|
1219
950
|
define_method(:call) do |args, **kwargs|
|
|
1220
951
|
result = super(args, **kwargs)
|
|
1221
|
-
result_filters.inject(result) { |val,
|
|
952
|
+
result_filters.inject(result) { |val, filter|
|
|
953
|
+
filter.call(val, tool_name: name, args: args)
|
|
954
|
+
}
|
|
1222
955
|
end
|
|
1223
956
|
end
|
|
1224
957
|
end
|