phronomy 0.7.0 → 0.7.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/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "securerandom"
|
|
5
|
-
require "timeout"
|
|
6
5
|
require_relative "concerns/retryable"
|
|
7
6
|
require_relative "concerns/guardrailable"
|
|
8
7
|
require_relative "concerns/before_completion"
|
|
@@ -226,13 +225,10 @@ module Phronomy
|
|
|
226
225
|
# Defaults to +nil+ (no timeout).
|
|
227
226
|
# Inherited by subclasses; the most-specific definition wins.
|
|
228
227
|
#
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
# complete naturally. The agent therefore keeps consuming threads,
|
|
234
|
-
# memory, and external API credits after the caller has already received
|
|
235
|
-
# the error. True cancellation is not yet supported.
|
|
228
|
+
# When the timeout fires, a {Phronomy::CancellationScope} is cancelled
|
|
229
|
+
# and its token is propagated to the FSM config so that in-flight LLM,
|
|
230
|
+
# tool, and RAG calls observe cancellation via their +cancellation_token:+
|
|
231
|
+
# keyword argument. +Phronomy::TimeoutError+ is raised to the caller.
|
|
236
232
|
#
|
|
237
233
|
# @param val [Numeric, nil]
|
|
238
234
|
# @return [Numeric, nil]
|
|
@@ -489,6 +485,11 @@ module Phronomy
|
|
|
489
485
|
# +:knowledge_sources+ (Array) — dynamic knowledge sources for this turn
|
|
490
486
|
# +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
|
|
491
487
|
# +:session_id+ (+String+, optional) — session identity forwarded to the tracer
|
|
488
|
+
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
489
|
+
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
490
|
+
# derived from it (existing +config:+ keys take precedence as backward-compat
|
|
491
|
+
# aliases). The object is also stored in +config[:invocation_context]+ so that
|
|
492
|
+
# +task_id+ / +parent_task_id+ appear in trace spans automatically.
|
|
492
493
|
# @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
|
|
493
494
|
# or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
|
|
494
495
|
# messages: Array }+ when the invocation was suspended awaiting tool approval.
|
|
@@ -505,29 +506,49 @@ module Phronomy
|
|
|
505
506
|
# result = agent.resume(result[:checkpoint], approved: true)
|
|
506
507
|
# end
|
|
507
508
|
# puts result[:output]
|
|
509
|
+
# @example With InvocationContext (deadline-based timeout)
|
|
510
|
+
# ctx = Phronomy::InvocationContext.new(
|
|
511
|
+
# thread_id: "conv-123",
|
|
512
|
+
# deadline: Phronomy::Deadline.in(30),
|
|
513
|
+
# task_id: SecureRandom.uuid
|
|
514
|
+
# )
|
|
515
|
+
# result = MyAgent.new.invoke("Hello", invocation_context: ctx)
|
|
508
516
|
# @api public
|
|
509
|
-
def invoke(input, messages: [], thread_id: nil, config: {})
|
|
517
|
+
def invoke(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
|
|
518
|
+
if invocation_context
|
|
519
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
520
|
+
end
|
|
510
521
|
if Phronomy.configuration.event_loop
|
|
511
522
|
# Protect against blocking the EventLoop thread itself.
|
|
512
|
-
if
|
|
523
|
+
if Phronomy::EventLoop.current?
|
|
513
524
|
raise Phronomy::Error,
|
|
514
525
|
"Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
|
|
515
526
|
"entry action. Use agent.run_as_child(input, ctx: ctx) instead."
|
|
516
527
|
end
|
|
517
528
|
|
|
529
|
+
# Build an effective config that includes the invoke_timeout scope's
|
|
530
|
+
# CancellationToken before constructing the FSM. This ensures that
|
|
531
|
+
# every LLM, tool, and RAG call made inside _invoke_impl observes
|
|
532
|
+
# cancellation when the deadline fires.
|
|
533
|
+
timeout_sec = self.class.invoke_timeout
|
|
534
|
+
effective_config, scope = if timeout_sec
|
|
535
|
+
s = Phronomy::CancellationScope.new(parent_token: config[:cancellation_token])
|
|
536
|
+
s.deadline_in(timeout_sec)
|
|
537
|
+
[config.merge(cancellation_token: s.token), s]
|
|
538
|
+
else
|
|
539
|
+
[config, nil]
|
|
540
|
+
end
|
|
541
|
+
|
|
518
542
|
fsm = Agent::FSM.new(
|
|
519
543
|
agent: self,
|
|
520
544
|
input: input,
|
|
521
545
|
messages: messages,
|
|
522
546
|
thread_id: thread_id || SecureRandom.uuid,
|
|
523
|
-
config:
|
|
547
|
+
config: effective_config
|
|
524
548
|
)
|
|
525
549
|
completion_queue = Phronomy::EventLoop.instance.register(fsm)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
begin
|
|
529
|
-
Timeout.timeout(timeout_sec) { completion_queue.pop }
|
|
530
|
-
rescue Timeout::Error
|
|
550
|
+
result = if scope
|
|
551
|
+
scope.pop_queue(completion_queue) do
|
|
531
552
|
raise Phronomy::TimeoutError,
|
|
532
553
|
"Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
|
|
533
554
|
end
|
|
@@ -537,13 +558,60 @@ module Phronomy
|
|
|
537
558
|
raise result if result.is_a?(Exception)
|
|
538
559
|
result
|
|
539
560
|
else
|
|
540
|
-
|
|
561
|
+
# Guard: calling invoke from inside a scheduler task would block the task
|
|
562
|
+
# against itself when using a cooperative backend. Use invoke_async
|
|
563
|
+
# instead to compose agents without introducing a blocking wait.
|
|
564
|
+
if Phronomy::Task.current
|
|
565
|
+
msg = "#{self.class.name}#invoke called from inside a scheduler task. " \
|
|
566
|
+
"This blocks the scheduler until the inner invocation completes, preventing " \
|
|
567
|
+
"other tasks from making progress. Use invoke_async + await instead."
|
|
568
|
+
if Phronomy.configuration.strict_runtime_guards
|
|
569
|
+
raise Phronomy::SchedulerReentrancyError, msg
|
|
570
|
+
elsif Phronomy.configuration.logger
|
|
571
|
+
Phronomy.configuration.logger.warn(msg)
|
|
572
|
+
else
|
|
573
|
+
Kernel.warn("[phronomy] WARNING: #{msg}")
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Invokes this agent asynchronously and returns a {Phronomy::Task}.
|
|
581
|
+
#
|
|
582
|
+
# This is the primary async entry point. {#invoke} is a synchronous wrapper
|
|
583
|
+
# that calls this method and blocks the caller until the task completes.
|
|
584
|
+
# Calling {#invoke} from inside an active scheduler task raises
|
|
585
|
+
# {Phronomy::SchedulerReentrancyError}; use +invoke_async+ directly in that
|
|
586
|
+
# context.
|
|
587
|
+
#
|
|
588
|
+
# The task is registered with the Runtime task registry so {Runtime#shutdown}
|
|
589
|
+
# drains in-flight invocations before process exit.
|
|
590
|
+
#
|
|
591
|
+
# @example
|
|
592
|
+
# task = agent.invoke_async("Hello!")
|
|
593
|
+
# result = task.await # => { output: "...", messages: [...], usage: ... }
|
|
594
|
+
#
|
|
595
|
+
# @param input [String, Hash]
|
|
596
|
+
# @param messages [Array]
|
|
597
|
+
# @param thread_id [String, nil]
|
|
598
|
+
# @param config [Hash]
|
|
599
|
+
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
600
|
+
# @return [Phronomy::Task]
|
|
601
|
+
# @api public
|
|
602
|
+
def invoke_async(input, messages: [], thread_id: nil, config: {}, invocation_context: nil)
|
|
603
|
+
if invocation_context
|
|
604
|
+
thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
|
|
605
|
+
end
|
|
606
|
+
bp = Phronomy.configuration.backpressure
|
|
607
|
+
on_full = (bp == :raise) ? :reject : (bp || :wait)
|
|
608
|
+
bp_timeout = Phronomy.configuration.backpressure_timeout
|
|
609
|
+
gate = Phronomy::Runtime.instance.gate(:agent)
|
|
610
|
+
Phronomy::Runtime.instance.spawn(name: "agent-#{(self.class.name || "anonymous").downcase}-async") do
|
|
611
|
+
gate.acquire(on_full: on_full, timeout: bp_timeout) do
|
|
612
|
+
_invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
|
|
613
|
+
end
|
|
541
614
|
end
|
|
542
|
-
ensure
|
|
543
|
-
# Remove this agent's context cache entry from the current thread to
|
|
544
|
-
# prevent unbounded growth of the thread-local hash in long-lived
|
|
545
|
-
# processes (e.g. Rails servers).
|
|
546
|
-
Thread.current[:phronomy_context_version_caches]&.delete(object_id)
|
|
547
615
|
end
|
|
548
616
|
|
|
549
617
|
# Registers this agent as a child {AgentFSM} inside the given Workflow context.
|
|
@@ -557,31 +625,24 @@ module Phronomy
|
|
|
557
625
|
# result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
|
|
558
626
|
# transition in your Workflow to advance to the next state.
|
|
559
627
|
#
|
|
560
|
-
#
|
|
561
|
-
#
|
|
562
|
-
#
|
|
628
|
+
# The result is delivered exclusively as the +:child_completed+ event payload.
|
|
629
|
+
# The parent Workflow task is the sole owner of the parent +WorkflowContext+ and
|
|
630
|
+
# applies the result after receiving the event — no background thread writes to
|
|
631
|
+
# the parent context directly.
|
|
563
632
|
#
|
|
564
|
-
# @example
|
|
633
|
+
# @example
|
|
565
634
|
# entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
|
|
566
635
|
# transition from: :run_agent, on: :child_completed, to: :process_result
|
|
567
636
|
#
|
|
568
|
-
# @example With block (writes result into context)
|
|
569
|
-
# entry :run_agent, ->(ctx) {
|
|
570
|
-
# MyAgent.new.run_as_child(ctx.query, ctx: ctx) { |r| ctx.answer = r[:output] }
|
|
571
|
-
# }
|
|
572
|
-
# transition from: :run_agent, on: :child_completed, to: :process_result
|
|
573
|
-
#
|
|
574
637
|
# @param input [String, Hash] user input passed to the agent
|
|
575
638
|
# @param ctx [Object] a WorkflowContext that responds to +#thread_id+
|
|
576
639
|
# @param messages [Array] prior conversation history
|
|
577
640
|
# @param config [Hash] invocation config (forwarded to +_invoke_impl+)
|
|
578
|
-
# @yield [Hash] result hash +{ output:, messages:, usage: }+ — called from the
|
|
579
|
-
# agent IO thread before +:child_completed+ is posted
|
|
580
641
|
# @return [nil] the caller must not wait on any return value;
|
|
581
642
|
# the result arrives as a +:child_completed+ event
|
|
582
643
|
# @raise [Phronomy::Error] when EventLoop mode is not enabled
|
|
583
644
|
# @api public
|
|
584
|
-
def run_as_child(input, ctx:, messages: [], config: {}
|
|
645
|
+
def run_as_child(input, ctx:, messages: [], config: {})
|
|
585
646
|
unless Phronomy.configuration.event_loop
|
|
586
647
|
raise Phronomy::Error,
|
|
587
648
|
"run_as_child requires EventLoop mode. " \
|
|
@@ -594,8 +655,7 @@ module Phronomy
|
|
|
594
655
|
messages: messages,
|
|
595
656
|
thread_id: "#{ctx.thread_id}_agent_#{SecureRandom.uuid}",
|
|
596
657
|
config: config,
|
|
597
|
-
parent_id: ctx.thread_id
|
|
598
|
-
result_writer: result_writer
|
|
658
|
+
parent_id: ctx.thread_id
|
|
599
659
|
)
|
|
600
660
|
Phronomy::EventLoop.instance.enqueue_child(fsm)
|
|
601
661
|
nil
|
|
@@ -644,11 +704,33 @@ module Phronomy
|
|
|
644
704
|
|
|
645
705
|
private
|
|
646
706
|
|
|
707
|
+
# Merges an {InvocationContext} into the +thread_id+ / +config+ pair.
|
|
708
|
+
# Returns +[effective_thread_id, effective_config]+.
|
|
709
|
+
#
|
|
710
|
+
# Precedence rules (existing explicit values always win):
|
|
711
|
+
# - +thread_id+ argument > +ic.thread_id+
|
|
712
|
+
# - +config[:cancellation_token]+ > +ic.cancellation_token+ > token derived from +ic.deadline+
|
|
713
|
+
# - +ic+ is stored in +config[:invocation_context]+ (overwriting any previous value)
|
|
714
|
+
def _apply_invocation_context(thread_id, config, ic)
|
|
715
|
+
effective_thread_id = thread_id || ic.thread_id
|
|
716
|
+
effective_config = config.merge(invocation_context: ic)
|
|
717
|
+
if effective_config[:cancellation_token].nil?
|
|
718
|
+
if (tok = ic.effective_timeout_token)
|
|
719
|
+
effective_config = effective_config.merge(cancellation_token: tok)
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
[effective_thread_id, effective_config]
|
|
723
|
+
end
|
|
724
|
+
|
|
647
725
|
# Streaming implementation for #stream.
|
|
648
726
|
def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
|
|
649
727
|
caller_meta = {}
|
|
650
728
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
651
729
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
730
|
+
if (ic = config[:invocation_context])
|
|
731
|
+
caller_meta[:task_id] = ic.task_id if ic.task_id
|
|
732
|
+
caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
|
|
733
|
+
end
|
|
652
734
|
|
|
653
735
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
654
736
|
run_input_guardrails!(input)
|
|
@@ -679,11 +761,26 @@ module Phronomy
|
|
|
679
761
|
# Run before_completion hooks (global → class → instance) before the LLM call.
|
|
680
762
|
run_before_completion_hooks!(chat, config)
|
|
681
763
|
|
|
682
|
-
|
|
764
|
+
# Route the LLM streaming call through the configured LLMAdapter.
|
|
765
|
+
# Chunks are pushed into a token queue by the pool worker thread and
|
|
766
|
+
# drained here (on the caller's side) so that the user block is never
|
|
767
|
+
# executed on a BlockingAdapterPool worker thread.
|
|
768
|
+
# The queue capacity is bounded by Configuration#stream_queue_max_size
|
|
769
|
+
# (nil = unbounded) to provide backpressure against a fast LLM producer.
|
|
770
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
771
|
+
chunk_queue = Phronomy::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
|
|
772
|
+
pending = adapter.stream_async(chat, user_message, config: config, enqueue_to: chunk_queue)
|
|
773
|
+
|
|
774
|
+
# Drain the chunk queue on this side (scheduler task / caller thread).
|
|
775
|
+
loop do
|
|
776
|
+
chunk = chunk_queue.pop
|
|
777
|
+
break if chunk.nil? # queue closed — LLM streaming complete
|
|
683
778
|
block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
|
|
684
779
|
check_cancellation!(config, "invocation cancelled during streaming")
|
|
685
780
|
end
|
|
686
781
|
|
|
782
|
+
response = pending.await
|
|
783
|
+
|
|
687
784
|
output = response.content
|
|
688
785
|
usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
689
786
|
|
|
@@ -715,10 +812,49 @@ module Phronomy
|
|
|
715
812
|
assembler = Context::Assembler.new(budget: budget)
|
|
716
813
|
assembler.add_instruction(system_text) if system_text
|
|
717
814
|
|
|
718
|
-
Array(config[:knowledge_sources])
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
815
|
+
sources = Array(config[:knowledge_sources])
|
|
816
|
+
unless sources.empty?
|
|
817
|
+
check_cancellation!(config, "invocation cancelled before RAG fetch")
|
|
818
|
+
# Determine TaskGroup failure policy: :skip (default) ignores per-source
|
|
819
|
+
# failures so the agent can still answer with partial context; :fail
|
|
820
|
+
# surfaces the first error immediately via :fail_fast.
|
|
821
|
+
failure_policy =
|
|
822
|
+
case config[:rag_failure_policy]
|
|
823
|
+
when :fail then :fail_fast
|
|
824
|
+
else :skip_failed
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
group = Phronomy::Runtime.instance.task_group(failure_policy: failure_policy)
|
|
828
|
+
|
|
829
|
+
bp = Phronomy.configuration.backpressure
|
|
830
|
+
rag_on_full = (bp == :raise) ? :reject : (bp || :wait)
|
|
831
|
+
rag_bp_timeout = Phronomy.configuration.backpressure_timeout
|
|
832
|
+
|
|
833
|
+
# Spawn all fetches concurrently. Results are returned in spawn order
|
|
834
|
+
# (i.e. registration order of knowledge sources) by TaskGroup#await_all.
|
|
835
|
+
sources.each do |ks|
|
|
836
|
+
group.spawn do
|
|
837
|
+
Phronomy::Runtime.instance.gate(:rag).acquire(on_full: rag_on_full, timeout: rag_bp_timeout) do
|
|
838
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
839
|
+
result = ks.fetch_async(
|
|
840
|
+
query: user_message,
|
|
841
|
+
cancellation_token: config[:cancellation_token],
|
|
842
|
+
timeout: config[:rag_timeout]
|
|
843
|
+
).await
|
|
844
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
845
|
+
Phronomy.configuration.logger&.debug { "RAG fetch from #{ks.class.name} completed in #{(elapsed * 1000).round}ms" }
|
|
846
|
+
result
|
|
847
|
+
end
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# await_all returns results in spawn order; nil entries indicate
|
|
852
|
+
# skipped failures when using :skip_failed.
|
|
853
|
+
per_source_chunks = group.await_all
|
|
854
|
+
per_source_chunks.each do |chunks|
|
|
855
|
+
Array(chunks).each do |chunk|
|
|
856
|
+
assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
|
|
857
|
+
end
|
|
722
858
|
end
|
|
723
859
|
end
|
|
724
860
|
|
|
@@ -774,6 +910,10 @@ module Phronomy
|
|
|
774
910
|
caller_meta = {}
|
|
775
911
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
776
912
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
913
|
+
if (ic = config[:invocation_context])
|
|
914
|
+
caller_meta[:task_id] = ic.task_id if ic.task_id
|
|
915
|
+
caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
|
|
916
|
+
end
|
|
777
917
|
|
|
778
918
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
779
919
|
# Run input guardrails before touching the LLM.
|
|
@@ -798,14 +938,17 @@ module Phronomy
|
|
|
798
938
|
# Check for cancellation immediately before the LLM call.
|
|
799
939
|
check_cancellation!(config, "invocation cancelled before LLM call")
|
|
800
940
|
|
|
801
|
-
# Forward the cancellation token to ParallelToolChat
|
|
802
|
-
# so that tool dispatch batches can observe
|
|
803
|
-
#
|
|
804
|
-
|
|
805
|
-
Thread.current[:phronomy_cancellation_token] = config[:cancellation_token]
|
|
941
|
+
# Forward the cancellation token to ParallelToolChat explicitly
|
|
942
|
+
# via the chat instance so that tool dispatch batches can observe
|
|
943
|
+
# cancellation without needing Thread.current.
|
|
944
|
+
chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
|
|
806
945
|
|
|
807
946
|
begin
|
|
808
|
-
|
|
947
|
+
# Route the LLM call through the configured LLMAdapter so that the
|
|
948
|
+
# blocking HTTP request runs inside BlockingAdapterPool and the
|
|
949
|
+
# adapter can be swapped without changing agent code.
|
|
950
|
+
adapter = Phronomy.configuration.llm_adapter
|
|
951
|
+
response = adapter.complete_async(chat, user_message, config: config).await
|
|
809
952
|
rescue SuspendSignal => signal
|
|
810
953
|
checkpoint = Checkpoint.new(
|
|
811
954
|
thread_id: thread_id,
|
|
@@ -818,7 +961,8 @@ module Phronomy
|
|
|
818
961
|
suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
|
|
819
962
|
next [suspended_result, nil]
|
|
820
963
|
ensure
|
|
821
|
-
|
|
964
|
+
# Clear the chat's cancellation token reference after each LLM call.
|
|
965
|
+
chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
|
|
822
966
|
end
|
|
823
967
|
|
|
824
968
|
output = response.content
|
|
@@ -890,9 +1034,7 @@ module Phronomy
|
|
|
890
1034
|
[instruction.to_s, *static_chunks.map { |c| c[:content] }].join("\0")
|
|
891
1035
|
)
|
|
892
1036
|
|
|
893
|
-
|
|
894
|
-
cache = (Thread.current[:phronomy_context_version_caches] ||= {})[agent_id] ||=
|
|
895
|
-
Context::ContextVersionCache.new
|
|
1037
|
+
cache = (@context_version_cache ||= Context::ContextVersionCache.new)
|
|
896
1038
|
unless cache.valid?(fingerprint)
|
|
897
1039
|
parts = [instruction]
|
|
898
1040
|
static_chunks.each do |chunk|
|
|
@@ -902,22 +1044,19 @@ module Phronomy
|
|
|
902
1044
|
end
|
|
903
1045
|
|
|
904
1046
|
# Persist a reference on the instance so that context_version_cache
|
|
905
|
-
# remains accessible after invoke
|
|
906
|
-
# thread-local entry.
|
|
1047
|
+
# remains accessible after invoke completes.
|
|
907
1048
|
@last_context_version_cache = cache
|
|
908
1049
|
|
|
909
1050
|
cache.system_text.empty? ? nil : cache.system_text
|
|
910
1051
|
end
|
|
911
1052
|
|
|
912
|
-
# Load messages from a ConversationManager.
|
|
913
|
-
#
|
|
914
1053
|
# Returns the chat class to instantiate for this invocation.
|
|
915
|
-
# When
|
|
916
|
-
#
|
|
917
|
-
#
|
|
918
|
-
#
|
|
1054
|
+
# When EventLoop mode is enabled ({Phronomy.configuration.event_loop}),
|
|
1055
|
+
# returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
|
|
1056
|
+
# Falls back to +nil+ otherwise, signalling {#build_chat} to use the
|
|
1057
|
+
# standard +RubyLLM.chat+ factory.
|
|
919
1058
|
def build_chat_class
|
|
920
|
-
|
|
1059
|
+
Phronomy.configuration.event_loop ? Agent::ParallelToolChat : nil
|
|
921
1060
|
end
|
|
922
1061
|
|
|
923
1062
|
def build_chat
|
|
@@ -931,7 +1070,11 @@ module Phronomy
|
|
|
931
1070
|
end
|
|
932
1071
|
t = self.class.temperature
|
|
933
1072
|
parallel_class = build_chat_class
|
|
934
|
-
chat =
|
|
1073
|
+
chat = if parallel_class
|
|
1074
|
+
parallel_class.new(max_parallel_tools: self.class.max_parallel_tools, **opts)
|
|
1075
|
+
else
|
|
1076
|
+
RubyLLM.chat(**opts)
|
|
1077
|
+
end
|
|
935
1078
|
chat.with_temperature(t) if t
|
|
936
1079
|
self.class.tools.each do |tool_class|
|
|
937
1080
|
chat.with_tool(prepare_tool_class(tool_class))
|
|
@@ -995,15 +1138,30 @@ module Phronomy
|
|
|
995
1138
|
|
|
996
1139
|
# Builds the final tool class to register with the chat.
|
|
997
1140
|
#
|
|
998
|
-
#
|
|
1141
|
+
# When an already-instantiated tool object is passed (e.g. a
|
|
1142
|
+
# {Phronomy::Tool::McpTool} returned by +McpTool.from_server+), it is
|
|
1143
|
+
# returned as-is. RubyLLM's +with_tool+ accepts both classes and
|
|
1144
|
+
# instances, so no wrapping is needed.
|
|
1145
|
+
#
|
|
1146
|
+
# For tool classes, three transformations are applied in order:
|
|
999
1147
|
# 1. Alias override — when the Hash form of .tools maps this class to an
|
|
1000
1148
|
# explicit name, an anonymous subclass with that tool_name is returned.
|
|
1001
|
-
# 2.
|
|
1149
|
+
# 2. Scope policy — when a scope is declared on the tool, the configured
|
|
1150
|
+
# {Phronomy::Tool::ScopePolicy} (or the default) is evaluated.
|
|
1151
|
+
# +:reject+ wraps the tool to return a denial message without executing.
|
|
1152
|
+
# +:approve+ behaves like requiring approval (same as step 3 when the
|
|
1153
|
+
# tool does not already have +requires_approval+).
|
|
1154
|
+
# 3. Approval gate — when the tool class has +requires_approval+ set AND
|
|
1002
1155
|
# an approval handler has been registered via #on_approval_required,
|
|
1003
1156
|
# the tool's #call method is wrapped: the handler is invoked with
|
|
1004
1157
|
# (tool_name, args) and, if it returns falsy, the tool returns a denial
|
|
1005
1158
|
# message instead of executing.
|
|
1006
1159
|
def prepare_tool_class(tool_class)
|
|
1160
|
+
# When an instantiated tool object is passed (e.g. McpTool.from_server
|
|
1161
|
+
# returns an instance, not a class), skip class-level processing and
|
|
1162
|
+
# return it directly. RubyLLM#with_tool handles both forms.
|
|
1163
|
+
return tool_class unless tool_class.is_a?(Class)
|
|
1164
|
+
|
|
1007
1165
|
# Step 1: apply alias if needed.
|
|
1008
1166
|
resolved = if (alias_name = self.class.tool_aliases[tool_class])
|
|
1009
1167
|
parent_description = tool_class.description
|
|
@@ -1015,7 +1173,34 @@ module Phronomy
|
|
|
1015
1173
|
tool_class
|
|
1016
1174
|
end
|
|
1017
1175
|
|
|
1018
|
-
# Step 2:
|
|
1176
|
+
# Step 2: evaluate scope policy.
|
|
1177
|
+
scope = resolved.scope
|
|
1178
|
+
if scope
|
|
1179
|
+
policy = @scope_policy || Phronomy::Tool::ScopePolicy::DEFAULT
|
|
1180
|
+
decision = policy.call(resolved, scope, self)
|
|
1181
|
+
case decision
|
|
1182
|
+
when :reject
|
|
1183
|
+
effective_name = resolved.new.name
|
|
1184
|
+
rejected_class = Class.new(resolved) do
|
|
1185
|
+
tool_name effective_name
|
|
1186
|
+
define_method(:call) do |_args|
|
|
1187
|
+
"Tool execution denied: scope :#{scope} is not permitted."
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
return rejected_class
|
|
1191
|
+
when :approve
|
|
1192
|
+
# Treat as requires_approval unless the tool already has that flag.
|
|
1193
|
+
unless resolved.requires_approval
|
|
1194
|
+
effective_name = resolved.new.name
|
|
1195
|
+
resolved = Class.new(resolved) do
|
|
1196
|
+
tool_name effective_name
|
|
1197
|
+
requires_approval true
|
|
1198
|
+
end
|
|
1199
|
+
end
|
|
1200
|
+
end
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
# Step 3: wrap with approval gate when handler is registered.
|
|
1019
1204
|
return resolved unless resolved.requires_approval && @approval_handler
|
|
1020
1205
|
|
|
1021
1206
|
handler = @approval_handler
|
|
@@ -32,6 +32,21 @@ module Phronomy
|
|
|
32
32
|
self
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
# Registers a scope policy callable for this agent instance.
|
|
36
|
+
#
|
|
37
|
+
# The callable receives +(tool_class, scope, agent)+ and must return
|
|
38
|
+
# +:allow+, +:reject+, or +:approve+.
|
|
39
|
+
#
|
|
40
|
+
# @example Reject all write-scoped tools
|
|
41
|
+
# agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
|
|
42
|
+
#
|
|
43
|
+
# @param policy [#call]
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @api public
|
|
46
|
+
def scope_policy=(policy)
|
|
47
|
+
@scope_policy = policy
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
# Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
|
|
36
51
|
#
|
|
37
52
|
# This method reconstructs the conversation state captured at suspension
|