phronomy 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
3
  require "securerandom"
5
4
  require_relative "concerns/retryable"
6
5
  require_relative "concerns/guardrailable"
@@ -60,12 +59,12 @@ module Phronomy
60
59
  end
61
60
 
62
61
  # Sets or reads the system instructions for this agent.
63
- # Accepts a String, a {Phronomy::PromptTemplate}, or a block (Proc).
62
+ # Accepts a String, a {Phronomy::Agent::Context::Instruction::PromptTemplate}, or a block (Proc).
64
63
  # When used as a reader (no argument, no block), returns the stored value.
65
64
  #
66
- # @param text [String, Phronomy::PromptTemplate, nil]
65
+ # @param text [String, Phronomy::Agent::Context::Instruction::PromptTemplate, nil]
67
66
  # @yield optionally provide instructions as a block
68
- # @return [String, Phronomy::PromptTemplate, Proc, nil]
67
+ # @return [String, Phronomy::Agent::Context::Instruction::PromptTemplate, Proc, nil]
69
68
  # @example String instructions
70
69
  # class MyAgent < Phronomy::Agent::Base
71
70
  # instructions "You are a helpful assistant."
@@ -225,7 +224,7 @@ module Phronomy
225
224
  # Defaults to +nil+ (no timeout).
226
225
  # Inherited by subclasses; the most-specific definition wins.
227
226
  #
228
- # When the timeout fires, a {Phronomy::CancellationScope} is cancelled
227
+ # When the timeout fires, a {Phronomy::Concurrency::CancellationScope} is cancelled
229
228
  # and its token is propagated to the FSM config so that in-flight LLM,
230
229
  # tool, and RAG calls observe cancellation via their +cancellation_token:+
231
230
  # keyword argument. +Phronomy::TimeoutError+ is raised to the caller.
@@ -255,10 +254,10 @@ module Phronomy
255
254
  # the first time +invoke+ is called. The cache persists for the lifetime
256
255
  # of the process; call {.static_knowledge_refresh!} to force a reload.
257
256
  #
258
- # @param sources [Array<Phronomy::KnowledgeSource::Base>]
257
+ # @param sources [Array<Phronomy::Agent::Context::Knowledge::Base>]
259
258
  # @example
260
259
  # class PolicyAgent < Phronomy::Agent::Base
261
- # static_knowledge Phronomy::KnowledgeSource::StaticKnowledge.new(POLICY_TEXT)
260
+ # static_knowledge Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(POLICY_TEXT)
262
261
  # end
263
262
  # @api public
264
263
  def static_knowledge(*sources)
@@ -269,7 +268,7 @@ module Phronomy
269
268
  end
270
269
 
271
270
  # Returns the registered static knowledge sources.
272
- # @return [Array<Phronomy::KnowledgeSource::Base>]
271
+ # @return [Array<Phronomy::Agent::Context::Knowledge::Base>]
273
272
  # @api public
274
273
  def static_knowledge_sources
275
274
  @static_knowledge_sources || []
@@ -302,80 +301,6 @@ module Phronomy
302
301
  @static_knowledge_chunks = nil
303
302
  end
304
303
 
305
- # Registers a callback that is invoked before every LLM call so the
306
- # application can remove stale or irrelevant messages from the
307
- # conversation history.
308
- #
309
- # The block receives a {Phronomy::Context::TrimContext} and may call
310
- # +ctx.remove(seqs)+ to drop messages by seq number. Changes affect
311
- # only the current invocation; the underlying memory store is unchanged.
312
- #
313
- # @yield [ctx] Phronomy::Context::TrimContext
314
- # @example Drop the oldest message when over 80% of budget is used
315
- # on_trim do |ctx|
316
- # limit = ctx.budget&.available(used: 0) || Float::INFINITY
317
- # ctx.remove(ctx.message_elements.first[:seq]) if ctx.total_tokens > limit * 0.8
318
- # end
319
- # @api public
320
- def on_trim(&block)
321
- @on_trim_callback = block
322
- end
323
-
324
- # @return [Proc, nil]
325
- # @api private
326
- def _on_trim_callback
327
- @on_trim_callback
328
- end
329
-
330
- # Registers a callback that decides whether compaction should run.
331
- # Evaluated before every LLM call (after on_trim). If the block returns
332
- # truthy AND an +on_compact+ callback is also registered, the compact
333
- # pipeline is executed.
334
- #
335
- # The block receives a read-only {Phronomy::Context::TriggerContext}.
336
- #
337
- # @yield [ctx] Phronomy::Context::TriggerContext
338
- # @return [Boolean] truthy → run on_compact; falsy → skip
339
- # @example Trigger when messages exceed 70% of token budget
340
- # on_compaction_trigger do |ctx|
341
- # limit = ctx.budget&.available(used: 0) || Float::INFINITY
342
- # ctx.total_tokens > limit * 0.7
343
- # end
344
- # @api public
345
- def on_compaction_trigger(&block)
346
- @on_compaction_trigger_callback = block
347
- end
348
-
349
- # @return [Proc, nil]
350
- # @api private
351
- def _on_compaction_trigger_callback
352
- @on_compaction_trigger_callback
353
- end
354
-
355
- # Registers a callback that performs the actual compaction when the
356
- # +on_compaction_trigger+ callback fires. The block receives a
357
- # {Phronomy::Context::CompactionContext} and should call +ctx.compact+
358
- # to specify which messages to summarise.
359
- #
360
- # @yield [ctx] Phronomy::Context::CompactionContext
361
- # @example Replace the first 4 messages with a short summary
362
- # on_compact do |ctx|
363
- # ctx.compact(0..3) do |elements|
364
- # texts = elements.map { |e| e[:message].content }.join(" | ")
365
- # "Earlier conversation summary: #{texts}"
366
- # end
367
- # end
368
- # @api public
369
- def on_compact(&block)
370
- @on_compact_callback = block
371
- end
372
-
373
- # @return [Proc, nil]
374
- # @api private
375
- def _on_compact_callback
376
- @on_compact_callback
377
- end
378
-
379
304
  # When enabled, attaches Anthropic prompt-cache markers to the system
380
305
  # message so that the fixed instructions are served from cache on
381
306
  # subsequent turns, reducing input-token costs.
@@ -453,7 +378,7 @@ module Phronomy
453
378
 
454
379
  # Registers an anonymous handoff tool class on this agent instance.
455
380
  # Called by Runner during construction when routes are configured.
456
- # @param tool_class [Class<Phronomy::Tool::Base>]
381
+ # @param tool_class [Class<Phronomy::Agent::Context::Capability::Base>]
457
382
  # @return [self]
458
383
  # @api private
459
384
  def _add_handoff_tool(tool_class)
@@ -482,7 +407,6 @@ module Phronomy
482
407
  # @param thread_id [String, nil] conversation thread identifier, forwarded
483
408
  # to the compaction context when on_compact is configured.
484
409
  # @param config [Hash] additional runtime options:
485
- # +:knowledge_sources+ (Array) — dynamic knowledge sources for this turn
486
410
  # +:user_id+ (+String+, optional) — caller identity forwarded to the tracer
487
411
  # +:session_id+ (+String+, optional) — session identity forwarded to the tracer
488
412
  # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
@@ -509,7 +433,7 @@ module Phronomy
509
433
  # @example With InvocationContext (deadline-based timeout)
510
434
  # ctx = Phronomy::InvocationContext.new(
511
435
  # thread_id: "conv-123",
512
- # deadline: Phronomy::Deadline.in(30),
436
+ # deadline: Phronomy::Concurrency::Deadline.in(30),
513
437
  # task_id: SecureRandom.uuid
514
438
  # )
515
439
  # result = MyAgent.new.invoke("Hello", invocation_context: ctx)
@@ -519,60 +443,9 @@ module Phronomy
519
443
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
520
444
  end
521
445
  if Phronomy.configuration.event_loop
522
- # Protect against blocking the EventLoop thread itself.
523
- if Phronomy::EventLoop.current?
524
- raise Phronomy::Error,
525
- "Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
526
- "entry action. Use agent.run_as_child(input, ctx: ctx) instead."
527
- end
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
-
542
- fsm = Agent::FSM.new(
543
- agent: self,
544
- input: input,
545
- messages: messages,
546
- thread_id: thread_id || SecureRandom.uuid,
547
- config: effective_config
548
- )
549
- completion_queue = Phronomy::EventLoop.instance.register(fsm)
550
- result = if scope
551
- scope.pop_queue(completion_queue) do
552
- raise Phronomy::TimeoutError,
553
- "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
554
- end
555
- else
556
- completion_queue.pop
557
- end
558
- raise result if result.is_a?(Exception)
559
- result
446
+ _invoke_via_event_loop(input, messages: messages, thread_id: thread_id, config: config)
560
447
  else
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
448
+ _check_scheduler_reentrancy
576
449
  invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
577
450
  end
578
451
  end
@@ -687,19 +560,11 @@ module Phronomy
687
560
  raise
688
561
  end
689
562
 
690
- # Returns the {Context::ContextVersionCache} built during the most recent
691
- # {#invoke} call on this agent instance. The thread-local cache entry is
692
- # cleaned up in the +ensure+ block of {#invoke}, but a reference is kept
693
- # in +@last_context_version_cache+ so callers can inspect it after invoke
694
- # returns.
695
- #
696
- # NOTE: Not thread-safe. When the same Agent instance is used concurrently,
697
- # +@last_context_version_cache+ reflects the most recent +invoke+ on *any*
698
- # thread. For per-invocation isolation, use a separate Agent instance per
699
- # thread.
563
+ # @deprecated The context version cache has been removed. Returns nil.
564
+ # Retained for backward compatibility with callers using safe navigation (+&.reset+).
700
565
  # @api private
701
566
  def context_version_cache
702
- @last_context_version_cache
567
+ nil
703
568
  end
704
569
 
705
570
  private
@@ -722,29 +587,75 @@ module Phronomy
722
587
  [effective_thread_id, effective_config]
723
588
  end
724
589
 
725
- # Streaming implementation for #stream.
726
- def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
727
- caller_meta = {}
728
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
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
590
+ def _invoke_via_event_loop(input, messages:, thread_id:, config:)
591
+ if Phronomy::EventLoop.current?
592
+ raise Phronomy::Error,
593
+ "Cannot call Agent#invoke (EventLoop mode) from within an EventLoop " \
594
+ "entry action. Use agent.run_as_child(input, ctx: ctx) instead."
595
+ end
596
+
597
+ timeout_sec = self.class.invoke_timeout
598
+ effective_config, scope = if timeout_sec
599
+ s = Phronomy::Concurrency::CancellationScope.new(parent_token: config[:cancellation_token])
600
+ s.deadline_in(timeout_sec)
601
+ [config.merge(cancellation_token: s.token), s]
602
+ else
603
+ [config, nil]
604
+ end
605
+
606
+ fsm = Agent::FSM.new(
607
+ agent: self,
608
+ input: input,
609
+ messages: messages,
610
+ thread_id: thread_id || SecureRandom.uuid,
611
+ config: effective_config
612
+ )
613
+ completion_queue = Phronomy::EventLoop.instance.register(fsm)
614
+ result = if scope
615
+ scope.pop_queue(completion_queue) do
616
+ raise Phronomy::TimeoutError,
617
+ "Agent #{self.class.name} invoke timed out after #{timeout_sec}s"
618
+ end
619
+ else
620
+ completion_queue.pop
733
621
  end
622
+ raise result if result.is_a?(Exception)
623
+ result
624
+ end
625
+
626
+ def _check_scheduler_reentrancy
627
+ return unless Phronomy::Task.current
734
628
 
735
- trace("agent.invoke", input: input, **caller_meta) do |_span|
629
+ msg = "#{self.class.name}#invoke called from inside a scheduler task. " \
630
+ "This blocks the scheduler until the inner invocation completes, preventing " \
631
+ "other tasks from making progress. Use invoke_async + await instead."
632
+ if Phronomy.configuration.strict_runtime_guards
633
+ raise Phronomy::SchedulerReentrancyError, msg
634
+ elsif Phronomy.configuration.logger
635
+ Phronomy.configuration.logger.warn(msg)
636
+ else
637
+ Kernel.warn("[phronomy] WARNING: #{msg}")
638
+ end
639
+ end
640
+
641
+ # Streaming implementation for #stream.
642
+ def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
643
+ trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
736
644
  run_input_guardrails!(input)
737
645
 
738
646
  chat = build_chat
739
647
  user_message = extract_message(input)
648
+ context = build_context(
649
+ input,
650
+ messages: messages,
651
+ thread_id: thread_id,
652
+ config: config,
653
+ budget: build_token_budget,
654
+ instruction: build_instructions(input),
655
+ tools: self.class.tools + _handoff_tools
656
+ )
657
+ _apply_context_to_chat(chat, context)
740
658
 
741
- # Assemble context (system prompt + history). Override #build_context to
742
- # inject custom context editing logic at the Agent subclass level.
743
- context = build_context(input, messages: messages, thread_id: thread_id, config: config)
744
- apply_instructions(chat, context[:system]) if context[:system]
745
- context[:messages].each { |msg| chat.messages << msg }
746
-
747
- # Wire per-event callbacks to yield StreamEvents.
748
659
  current_tool_call = nil
749
660
  chat.on_tool_call do |tool_call|
750
661
  current_tool_call = tool_call
@@ -758,32 +669,9 @@ module Phronomy
758
669
  }))
759
670
  end
760
671
 
761
- # Run before_completion hooks (global → class → instance) before the LLM call.
762
672
  run_before_completion_hooks!(chat, config)
763
673
 
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
778
- block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
779
- check_cancellation!(config, "invocation cancelled during streaming")
780
- end
781
-
782
- response = pending.await
783
-
784
- output = response.content
785
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
786
-
674
+ output, usage = _drain_stream(chat, user_message, config, &block)
787
675
  run_output_guardrails!(output)
788
676
 
789
677
  result = {output: output, messages: chat.messages, usage: usage}
@@ -797,183 +685,212 @@ module Phronomy
797
685
  # inject custom context editing logic without having to override
798
686
  # the full #invoke_once pipeline.
799
687
  #
800
- # @param input [String, Hash] the user's input for this turn
801
- # @param messages [Array<RubyLLM::Message>] raw conversation history
802
- # @param thread_id [String, nil] conversation thread identifier
803
- # @param config [Hash] the invocation config (see #invoke)
804
- # @return [Hash] { system: String|nil, messages: Array }
688
+ # The keyword arguments +budget+, +instruction+, +tools+, and +knowledge+
689
+ # carry pre-computed values. Override them in a subclass call to +super+
690
+ # to inject custom context without recomputing the defaults.
691
+ #
692
+ # @param input [String, Hash] the user's input for this turn
693
+ # @param messages [Array<RubyLLM::Message>] raw conversation history
694
+ # @param thread_id [String, nil] conversation thread identifier
695
+ # @param config [Hash] the invocation config (see #invoke)
696
+ # @param budget [LlmContextWindow::TokenBudget, nil] pre-computed token budget
697
+ # @param instruction [String, nil] pre-computed system instruction
698
+ # @param tools [Array<Class>] tool classes to expose
699
+ # @param knowledge [Array<Hash>] knowledge chunks ({ content:, type:, source: })
700
+ # @return [Hash] { system: String|nil, messages: Array, tool_classes: Array }
805
701
  # @api public
806
- def build_context(input, messages: [], thread_id: nil, config: {})
807
- history = prepare_history(messages: messages, thread_id: thread_id, config: config)
808
- budget = build_token_budget
809
- system_text = build_cached_system_text(input)
810
- user_message = extract_message(input)
811
-
812
- assembler = Context::Assembler.new(budget: budget)
813
- assembler.add_instruction(system_text) if system_text
814
-
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
702
+ def build_context(
703
+ input,
704
+ messages: [],
705
+ thread_id: nil,
706
+ config: {},
707
+ budget: build_token_budget,
708
+ instruction: build_instructions(input),
709
+ tools: self.class.tools + _handoff_tools,
710
+ knowledge: self.class.static_knowledge_chunks + instance_knowledge_chunks
711
+ )
712
+ assembler = LlmContextWindow::Assembler.new(budget: budget)
713
+ assembler.add_instruction(instruction) if instruction
714
+ assembler.add_capability(tools)
715
+ knowledge.each { |chunk| assembler.add_knowledge(chunk[:content], type: chunk[:type] || :static, trusted: true, source: chunk[:source]) }
716
+
717
+ msgs = Array(messages)
718
+
719
+ if budget && budget_exceeded?(msgs)
720
+ # Default strategy when the token budget is tight:
721
+ # 1. Compact: keep the most recent half of the messages verbatim and
722
+ # replace the older half with a brief omission marker.
723
+ # 2. Trim: if the compacted history still exceeds the budget, call
724
+ # trim_to_budget with the :safe strategy, which discards the oldest
725
+ # message one at a time until the history fits.
726
+ # Subclasses can override build_context to apply a different strategy
727
+ # (e.g. LLM-based summarisation) before calling super.
728
+ keep = [msgs.size / 2, 2].max
729
+ msgs = compact_messages(msgs, keep_tail: keep) do |dropped|
730
+ "[#{dropped.size} earlier messages omitted]"
858
731
  end
732
+ remaining = assembler.available_for_messages
733
+ msgs = trim_to_budget(msgs, remaining: remaining, strategy: :safe)
859
734
  end
860
735
 
861
- assembler.add_messages(history)
862
- assembler.build
736
+ assembler.add_messages(msgs)
737
+ @last_context = assembler.build
863
738
  end
864
739
  protected :build_context
865
740
 
866
- # Runs the on_trim / on_compaction_trigger / on_compact pipeline on the
867
- # supplied message array and returns the final Array of message objects
868
- # ready to pass to the Assembler.
741
+ # Keeps the last +keep+ messages from +messages+, discarding older ones.
742
+ # Use this inside a +build_context+ override to trim conversation history.
869
743
  #
870
- # Override this method in a subclass to customize how conversation
871
- # history is filtered or compressed before context assembly.
744
+ # @param messages [Array<RubyLLM::Message>] conversation history
745
+ # @param keep [Integer] number of messages to retain (from the tail)
746
+ # @return [Array<RubyLLM::Message>]
747
+ # @api public
748
+ def trim_messages(messages, keep:)
749
+ Array(messages).last(keep)
750
+ end
751
+ protected :trim_messages
752
+
753
+ # Removes the oldest messages one at a time until the count is within +limit+.
872
754
  #
873
- # @param messages [Array<RubyLLM::Message>] raw conversation history
874
- # @param thread_id [String, nil] conversation thread identifier
875
- # @param config [Hash] additional invocation options
876
- # @return [Array] filtered and/or compacted message objects
755
+ # @param messages [Array<RubyLLM::Message>] conversation history
756
+ # @param limit [Integer] maximum number of messages to retain
757
+ # @return [Array<RubyLLM::Message>]
877
758
  # @api public
878
- def prepare_history(messages: [], thread_id: nil, config: {})
879
- budget = build_token_budget
880
- elements = build_message_elements(Array(messages))
881
-
882
- if (trim_cb = self.class._on_trim_callback)
883
- trim_ctx = Context::TrimContext.new(message_elements: elements, budget: budget)
884
- trim_cb.call(trim_ctx)
885
- elements = trim_ctx.message_elements
886
- end
759
+ def drop_messages_over(messages, limit:)
760
+ msgs = Array(messages).dup
761
+ msgs.shift while msgs.size > limit
762
+ msgs
763
+ end
764
+ protected :drop_messages_over
887
765
 
888
- if (trigger_cb = self.class._on_compaction_trigger_callback)
889
- trigger_ctx = Context::TriggerContext.new(message_elements: elements, budget: budget)
890
- if trigger_cb.call(trigger_ctx)
891
- if (compact_cb = self.class._on_compact_callback)
892
- compact_ctx = Context::CompactionContext.new(
893
- message_elements: elements,
894
- budget: budget,
895
- thread_id: thread_id
896
- )
897
- compact_cb.call(compact_ctx)
898
- elements = build_message_elements(compact_ctx.result_messages)
899
- end
900
- end
766
+ # Replaces all but the last +keep_tail+ messages with a single system summary.
767
+ # The block receives the dropped messages and must return a summary String.
768
+ #
769
+ # @param messages [Array<RubyLLM::Message>] conversation history
770
+ # @param keep_tail [Integer] number of recent messages to preserve verbatim
771
+ # @yield [Array<RubyLLM::Message>] the messages being summarised
772
+ # @yieldreturn [String] summary text
773
+ # @return [Array<RubyLLM::Message>]
774
+ # @api public
775
+ def compact_messages(messages, keep_tail:, &summariser)
776
+ msgs = Array(messages)
777
+ return msgs if msgs.size <= keep_tail
778
+ tail = msgs.last(keep_tail)
779
+ dropped = msgs.first(msgs.size - keep_tail)
780
+ summary_text = summariser.call(dropped)
781
+ [RubyLLM::Message.new(role: :system, content: summary_text)] + tail
782
+ end
783
+ protected :compact_messages
784
+
785
+ # Trims +messages+ to fit within +remaining+ tokens using the given
786
+ # +strategy+. Returns the trimmed message array without touching the
787
+ # assembler. The caller is responsible for passing the result to
788
+ # +assembler.add_messages+ and calling +assembler.build+.
789
+ #
790
+ # Supported strategies:
791
+ # +:safe+ — discard the oldest message one at a time (default)
792
+ #
793
+ # @param messages [Array<RubyLLM::Message>] conversation history
794
+ # @param remaining [Integer, nil] token allowance for messages; when +nil+
795
+ # the messages are returned unchanged
796
+ # @param strategy [Symbol] trim strategy (default +:safe+)
797
+ # @return [Array<RubyLLM::Message>]
798
+ # @api public
799
+ def trim_to_budget(messages, remaining:, strategy: :safe)
800
+ return Array(messages) unless remaining
801
+ msgs = Array(messages)
802
+ loop do
803
+ used = msgs.sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
804
+ return msgs if used <= remaining
805
+ break if msgs.empty?
806
+ msgs = trim_messages(msgs, keep: msgs.size - 1)
901
807
  end
808
+ msgs
809
+ end
810
+ protected :trim_to_budget
811
+
812
+ # Returns +true+ when the estimated token usage of +messages+ exceeds
813
+ # +threshold+ times the available context budget.
814
+ # Always returns +false+ when no token budget is available.
815
+ #
816
+ # @param messages [Array<RubyLLM::Message>] conversation history
817
+ # @param threshold [Float] fraction of the available budget (default 0.8)
818
+ # @return [Boolean]
819
+ # @api public
820
+ def budget_exceeded?(messages, threshold: 0.8)
821
+ return false unless (b = build_token_budget)
822
+ total = Array(messages).sum { |m| LlmContextWindow::TokenEstimator.estimate(m.content.to_s) }
823
+ limit = b.available(used: 0)
824
+ total > limit * threshold
825
+ end
826
+ protected :budget_exceeded?
827
+
828
+ # Registers a per-instance knowledge source. Knowledge chunks from all
829
+ # registered sources are included in every LLM call via +build_context+.
830
+ #
831
+ # @param source [#fetch] any object responding to +fetch(query:)+
832
+ # @return [void]
833
+ # @api public
834
+ def add_knowledge_source(source)
835
+ @instance_knowledge_sources ||= []
836
+ @instance_knowledge_sources << source
837
+ end
838
+ protected :add_knowledge_source
902
839
 
903
- elements.map { |e| e[:message] }
840
+ # Returns knowledge chunks fetched from all instance-level knowledge sources.
841
+ #
842
+ # @return [Array<Hash>]
843
+ # @api private
844
+ def instance_knowledge_chunks
845
+ return [] unless @instance_knowledge_sources
846
+ @instance_knowledge_sources.flat_map { |ks| ks.fetch(query: nil) }
904
847
  end
905
- protected :prepare_history
848
+ protected :instance_knowledge_chunks
906
849
 
907
850
  # Performs a single (non-retried) invocation. Extracted so that #invoke can
908
851
  # wrap it in a retry loop without duplicating the LLM interaction logic.
909
852
  def invoke_once(input, messages: [], thread_id: nil, config: {})
910
- caller_meta = {}
911
- caller_meta[:user_id] = config[:user_id] if config[:user_id]
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
853
+ trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
854
+ Agent::InvocationPipeline.new(self).run(
855
+ input,
856
+ messages: messages,
857
+ thread_id: thread_id,
858
+ config: config
859
+ )
916
860
  end
861
+ end
917
862
 
918
- trace("agent.invoke", input: input, **caller_meta) do |_span|
919
- # Run input guardrails before touching the LLM.
920
- run_input_guardrails!(input)
921
-
922
- user_message = extract_message(input)
923
- chat = build_chat
924
-
925
- # Assemble context (system prompt + history). Override #build_context to
926
- # inject custom context editing logic at the Agent subclass level.
927
- context = build_context(input, messages: messages, thread_id: thread_id, config: config)
928
- apply_instructions(chat, context[:system]) if context[:system]
929
- context[:messages].each { |msg| chat.messages << msg }
930
-
931
- # Run before_completion hooks (global → class → instance) before the LLM call.
932
- run_before_completion_hooks!(chat, config)
933
-
934
- # Register suspension hook for approval-required tools (no-op when a
935
- # synchronous on_approval_required handler is already registered).
936
- _register_suspension_hook!(chat)
937
-
938
- # Check for cancellation immediately before the LLM call.
939
- check_cancellation!(config, "invocation cancelled before LLM call")
940
-
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=)
945
-
946
- begin
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
952
- rescue SuspendSignal => signal
953
- checkpoint = Checkpoint.new(
954
- thread_id: thread_id,
955
- original_input: input,
956
- messages: chat.messages.dup,
957
- pending_tool_name: signal.tool_name,
958
- pending_tool_args: signal.args,
959
- pending_tool_call_id: signal.tool_call_id
960
- )
961
- suspended_result = {output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}
962
- next [suspended_result, nil]
963
- ensure
964
- # Clear the chat's cancellation token reference after each LLM call.
965
- chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
966
- end
863
+ def _build_caller_meta(config)
864
+ meta = {}
865
+ meta[:user_id] = config[:user_id] if config[:user_id]
866
+ meta[:session_id] = config[:session_id] if config[:session_id]
867
+ if (ic = config[:invocation_context])
868
+ meta[:task_id] = ic.task_id if ic.task_id
869
+ meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
870
+ end
871
+ meta
872
+ end
967
873
 
968
- output = response.content
969
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
874
+ def _apply_context_to_chat(chat, context)
875
+ apply_instructions(chat, context[:system]) if context[:system]
876
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
877
+ context[:messages].each { |msg| chat.messages << msg }
878
+ end
970
879
 
971
- # Run output guardrails before returning to the caller.
972
- run_output_guardrails!(output)
880
+ def _drain_stream(chat, user_message, config, &block)
881
+ adapter = Phronomy.configuration.llm_adapter
882
+ chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
883
+ pending = adapter.stream_async(chat, user_message, config: config, enqueue_to: chunk_queue)
973
884
 
974
- result = {output: output, messages: chat.messages, usage: usage}
975
- [result, usage]
885
+ loop do
886
+ chunk = chunk_queue.pop
887
+ break if chunk.nil?
888
+ block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
889
+ check_cancellation!(config, "invocation cancelled during streaming")
976
890
  end
891
+
892
+ response = pending.await
893
+ [response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
977
894
  end
978
895
 
979
896
  # Builds a TokenBudget for this agent's model if possible.
@@ -986,77 +903,29 @@ module Phronomy
986
903
  return nil unless model_name
987
904
 
988
905
  if (cw = self.class.context_window)
989
- Phronomy::Context::TokenBudget.new(
906
+ Phronomy::LlmContextWindow::TokenBudget.new(
990
907
  context_window: cw,
991
908
  max_output_tokens: self.class.max_output_tokens || 0,
992
909
  overhead: self.class.context_overhead
993
910
  )
994
911
  else
995
- Phronomy::Context::TokenBudget.new(
912
+ Phronomy::LlmContextWindow::TokenBudget.new(
996
913
  model: model_name,
997
914
  max_output_tokens: self.class.max_output_tokens,
998
915
  overhead: self.class.context_overhead
999
916
  )
1000
917
  end
1001
- rescue Phronomy::Context::UnknownModelError, RubyLLM::ModelNotFoundError
918
+ rescue Phronomy::LlmContextWindow::UnknownModelError, RubyLLM::ModelNotFoundError
1002
919
  nil
1003
920
  end
1004
921
 
1005
- # Converts a flat Array of message objects into the internal message_elements
1006
- # format used by TrimContext, TriggerContext, and CompactionContext.
1007
- # Each element receives a 0-based synthetic seq number.
1008
- #
1009
- # @param messages [Array] message-like objects with #role and #content
1010
- # @return [Array<Hash>]
1011
- # @api public
1012
- def build_message_elements(messages)
1013
- Array(messages).each_with_index.map do |msg, idx|
1014
- tokens = Context::TokenEstimator.estimate(msg.content.to_s)
1015
- {seq: idx, message: msg, tokens: tokens, role: msg.role}
1016
- end
1017
- end
1018
-
1019
- # Builds (or returns a cached) system prompt text.
1020
- # The fingerprint is a SHA-256 digest of the instruction text concatenated
1021
- # with the content of every registered static knowledge source.
1022
- # When the fingerprint is unchanged the ContextVersionCache returns the
1023
- # previously assembled text without re-fetching any sources.
1024
- #
1025
- # @param input [String, Hash] the agent's current input (used for template evaluation)
1026
- # @return [String, nil] assembled system text, or nil when empty
1027
- # @api public
1028
- def build_cached_system_text(input)
1029
- instruction = build_instructions(input)
1030
-
1031
- static_chunks = self.class.static_knowledge_chunks
1032
-
1033
- fingerprint = Digest::SHA256.hexdigest(
1034
- [instruction.to_s, *static_chunks.map { |c| c[:content] }].join("\0")
1035
- )
1036
-
1037
- cache = (@context_version_cache ||= Context::ContextVersionCache.new)
1038
- unless cache.valid?(fingerprint)
1039
- parts = [instruction]
1040
- static_chunks.each do |chunk|
1041
- parts << Context::Assembler.xml_tag(chunk[:content], type: chunk[:type], trusted: true)
1042
- end
1043
- cache.update(fingerprint: fingerprint, system_text: parts.compact.join("\n\n"))
1044
- end
1045
-
1046
- # Persist a reference on the instance so that context_version_cache
1047
- # remains accessible after invoke completes.
1048
- @last_context_version_cache = cache
1049
-
1050
- cache.system_text.empty? ? nil : cache.system_text
1051
- end
1052
-
1053
922
  # Returns the chat class to instantiate for this invocation.
1054
923
  # When EventLoop mode is enabled ({Phronomy.configuration.event_loop}),
1055
924
  # returns {ParallelToolChat} so that concurrent tool dispatch is enabled.
1056
925
  # Falls back to +nil+ otherwise, signalling {#build_chat} to use the
1057
926
  # standard +RubyLLM.chat+ factory.
1058
927
  def build_chat_class
1059
- Phronomy.configuration.event_loop ? Agent::ParallelToolChat : nil
928
+ Phronomy.configuration.event_loop ? Phronomy::MultiAgent::ParallelToolChat : nil
1060
929
  end
1061
930
 
1062
931
  def build_chat
@@ -1076,17 +945,13 @@ module Phronomy
1076
945
  RubyLLM.chat(**opts)
1077
946
  end
1078
947
  chat.with_temperature(t) if t
1079
- self.class.tools.each do |tool_class|
1080
- chat.with_tool(prepare_tool_class(tool_class))
1081
- end
1082
- _handoff_tools.each { |tc| chat.with_tool(tc) }
1083
948
  chat
1084
949
  end
1085
950
 
1086
951
  def build_instructions(input)
1087
952
  instr = self.class.instructions
1088
953
  case instr
1089
- when Phronomy::PromptTemplate
954
+ when Phronomy::Agent::Context::Instruction::PromptTemplate
1090
955
  vars = input.is_a?(Hash) ? input : {input: input}
1091
956
  instr.format_system(**vars) || instr.format(**vars)
1092
957
  when String then instr
@@ -1139,7 +1004,7 @@ module Phronomy
1139
1004
  # Builds the final tool class to register with the chat.
1140
1005
  #
1141
1006
  # When an already-instantiated tool object is passed (e.g. a
1142
- # {Phronomy::Tool::McpTool} returned by +McpTool.from_server+), it is
1007
+ # {Phronomy::Tools::Mcp} returned by +Phronomy::Tools::Mcp.from_server+), it is
1143
1008
  # returned as-is. RubyLLM's +with_tool+ accepts both classes and
1144
1009
  # instances, so no wrapping is needed.
1145
1010
  #
@@ -1147,7 +1012,7 @@ module Phronomy
1147
1012
  # 1. Alias override — when the Hash form of .tools maps this class to an
1148
1013
  # explicit name, an anonymous subclass with that tool_name is returned.
1149
1014
  # 2. Scope policy — when a scope is declared on the tool, the configured
1150
- # {Phronomy::Tool::ScopePolicy} (or the default) is evaluated.
1015
+ # {Phronomy::Agent::Context::Capability::ScopePolicy} (or the default) is evaluated.
1151
1016
  # +:reject+ wraps the tool to return a denial message without executing.
1152
1017
  # +:approve+ behaves like requiring approval (same as step 3 when the
1153
1018
  # tool does not already have +requires_approval+).
@@ -1157,7 +1022,7 @@ module Phronomy
1157
1022
  # (tool_name, args) and, if it returns falsy, the tool returns a denial
1158
1023
  # message instead of executing.
1159
1024
  def prepare_tool_class(tool_class)
1160
- # When an instantiated tool object is passed (e.g. McpTool.from_server
1025
+ # When an instantiated tool object is passed (e.g. Phronomy::Tools::Mcp.from_server
1161
1026
  # returns an instance, not a class), skip class-level processing and
1162
1027
  # return it directly. RubyLLM#with_tool handles both forms.
1163
1028
  return tool_class unless tool_class.is_a?(Class)
@@ -1176,7 +1041,7 @@ module Phronomy
1176
1041
  # Step 2: evaluate scope policy.
1177
1042
  scope = resolved.scope
1178
1043
  if scope
1179
- policy = @scope_policy || Phronomy::Tool::ScopePolicy::DEFAULT
1044
+ policy = @scope_policy || Phronomy::Agent::Context::Capability::ScopePolicy::DEFAULT
1180
1045
  decision = policy.call(resolved, scope, self)
1181
1046
  case decision
1182
1047
  when :reject