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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -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
- # **Note**: +invoke_timeout+ is a *wait timeout*, not a cancellation.
230
- # When the timeout fires, +Phronomy::TimeoutError+ is raised to the
231
- # caller, but the background agent thread and any in-flight LLM or tool
232
- # calls are **not** interrupted they continue running until they
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 Thread.current[:phronomy_event_loop_thread]
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: config
547
+ config: effective_config
524
548
  )
525
549
  completion_queue = Phronomy::EventLoop.instance.register(fsm)
526
- timeout_sec = self.class.invoke_timeout
527
- result = if timeout_sec
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
- _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
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
- # An optional block may be provided to write the result back into the parent
561
- # WorkflowContext <b>before</b> the +:child_completed+ event is dispatched.
562
- # +Thread::Queue+ provides the happens-before guarantee \u2014 no Mutex is needed.
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 Without block (result available only as event payload)
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: {}, &result_writer)
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
- response = chat.ask(user_message) do |chunk|
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]).each do |ks|
719
- check_cancellation!(config, "invocation cancelled during RAG fetch")
720
- ks.fetch(query: user_message, cancellation_token: config[:cancellation_token]).each do |chunk|
721
- assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
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 via a thread-local
802
- # so that tool dispatch batches can observe cancellation without needing
803
- # direct access to config.
804
- prev_ct = Thread.current[:phronomy_cancellation_token]
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
- response = chat.ask(user_message)
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
- Thread.current[:phronomy_cancellation_token] = prev_ct
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
- agent_id = object_id
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's ensure block cleans up the
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 the +:phronomy_agent_parallel_tools+ thread-local flag is set
916
- # (i.e. inside an {AgentFSM} IO thread), returns {ParallelToolChat} so
917
- # that concurrent tool dispatch is enabled. Falls back to +nil+ otherwise,
918
- # signalling {#build_chat} to use the standard +RubyLLM.chat+ factory.
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
- Thread.current[:phronomy_agent_parallel_tools] ? Agent::ParallelToolChat : nil
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 = parallel_class ? parallel_class.new(**opts) : RubyLLM.chat(**opts)
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
- # Two transformations are applied in order:
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. Approval gate — when the tool class has +requires_approval+ set AND
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: wrap with approval gate when handler is registered.
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