phronomy 0.7.0 → 0.8.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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # A deterministic, manually-advanced clock for use in tests.
6
+ #
7
+ # Replaces real +Process.clock_gettime+ calls so that time-sensitive code
8
+ # can be tested without relying on wall-clock sleeps.
9
+ #
10
+ # @example
11
+ # clock = Phronomy::Testing::FakeClock.new
12
+ # clock.now # => 0.0
13
+ # clock.advance(5) # advance by 5 seconds
14
+ # clock.now # => 5.0
15
+ class FakeClock
16
+ # @return [Float] the current logical time in seconds since the epoch (t=0)
17
+ attr_reader :now
18
+
19
+ def initialize
20
+ @now = 0.0
21
+ @callbacks = [] # [[fire_at, block], ...]
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ # Advance the clock by +seconds+ and fire any registered callbacks whose
26
+ # deadline has passed.
27
+ #
28
+ # @param seconds [Numeric]
29
+ # @return [self]
30
+ # @api private
31
+ def advance(seconds)
32
+ @mutex.synchronize do
33
+ @now += seconds.to_f
34
+ fire_expired_callbacks!
35
+ end
36
+ self
37
+ end
38
+
39
+ # Register a one-shot callback that fires when the clock reaches +at+.
40
+ #
41
+ # @param at [Numeric] logical time to fire
42
+ # @yield called with no arguments when the clock reaches +at+
43
+ # @return [self]
44
+ # @api private
45
+ def at(at, &block)
46
+ @mutex.synchronize { @callbacks << [at.to_f, block] }
47
+ self
48
+ end
49
+
50
+ # Schedule a one-shot callback to fire after +seconds+ from the current
51
+ # logical time. This is the same interface as {Runtime::TimerQueue#schedule}
52
+ # so that a +FakeClock+ can be passed as a +timer_queue:+ argument in tests.
53
+ #
54
+ # @param seconds [Numeric] delay in logical seconds
55
+ # @yield called when the clock reaches the scheduled time
56
+ # @return [self]
57
+ # @api private
58
+ def schedule(seconds:, &block)
59
+ at(@now + seconds.to_f, &block)
60
+ end
61
+
62
+ # Returns the number of pending (un-fired) callbacks.
63
+ # @return [Integer]
64
+ # @api private
65
+ def pending_callbacks
66
+ @mutex.synchronize { @callbacks.size }
67
+ end
68
+
69
+ # Returns the logical time of the next pending callback, or +nil+ if
70
+ # there are no pending callbacks.
71
+ #
72
+ # @return [Float, nil]
73
+ # @api private
74
+ def next_timer_at
75
+ @mutex.synchronize { @callbacks.min_by { |(t, _)| t }&.first }
76
+ end
77
+
78
+ # Advance the clock exactly to the next pending callback and fire it.
79
+ # Raises +RuntimeError+ when there are no pending callbacks.
80
+ #
81
+ # @return [self]
82
+ # @api private
83
+ def advance_to_next_timer
84
+ target = next_timer_at
85
+ raise "No pending timers to advance to" unless target
86
+
87
+ advance(target - @now)
88
+ end
89
+
90
+ # Returns descriptive entries for all pending callbacks.
91
+ # Used by {Phronomy::Runtime::FakeScheduler#pending_timers}.
92
+ #
93
+ # @return [Array<Hash>] each entry: +{ fire_at:, description: nil }+
94
+ # @api private
95
+ def pending_timer_entries
96
+ @mutex.synchronize do
97
+ @callbacks.sort_by { |(t, _)| t }.map { |(t, _)| {fire_at: t, description: nil} }
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def fire_expired_callbacks!
104
+ fired, @callbacks = @callbacks.partition { |(t, _)| t <= @now }
105
+ fired.sort_by { |(t, _)| t }.each { |(_, cb)| cb.call }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # A deterministic event dispatcher for use in tests.
6
+ #
7
+ # Wraps a {Thread::Queue} and dispatches events one at a time via {#tick}
8
+ # or drains all pending events via {#tick_until_idle}. Tests can inspect
9
+ # queue depth and verify event ordering without wall-clock sleeps.
10
+ #
11
+ # @example
12
+ # scheduler = Phronomy::Testing::FakeScheduler.new
13
+ # scheduler.post(:a)
14
+ # scheduler.post(:b)
15
+ # scheduler.queue_depth # => 2
16
+ # scheduler.tick # dispatches :a
17
+ # scheduler.queue_depth # => 1
18
+ # scheduler.tick_until_idle
19
+ # scheduler.dispatched # => [:a, :b]
20
+ class FakeScheduler
21
+ # @return [Array] all events dispatched so far (in order)
22
+ attr_reader :dispatched
23
+
24
+ def initialize
25
+ @queue = Thread::Queue.new
26
+ @dispatched = []
27
+ @handlers = {}
28
+ end
29
+
30
+ # Enqueue an event for later dispatch.
31
+ #
32
+ # @param event [Object]
33
+ # @return [self]
34
+ # @api private
35
+ def post(event)
36
+ @queue.push(event)
37
+ self
38
+ end
39
+
40
+ # Dispatch the next queued event.
41
+ # Calls the registered handler (if any) and records the event.
42
+ # Returns the dispatched event, or +nil+ if the queue is empty.
43
+ #
44
+ # @return [Object, nil]
45
+ # @api private
46
+ def tick
47
+ return nil if @queue.empty?
48
+
49
+ event = begin
50
+ @queue.pop(true)
51
+ rescue
52
+ nil
53
+ end
54
+ return nil unless event
55
+
56
+ @dispatched << event
57
+ handler = @handlers[event.class] || @handlers[:any]
58
+ handler&.call(event)
59
+ event
60
+ end
61
+
62
+ # Dispatch events until the queue is empty.
63
+ # Bounded by +max_ticks+ to prevent infinite loops.
64
+ #
65
+ # @param max_ticks [Integer]
66
+ # @return [Integer] number of events dispatched
67
+ # @api private
68
+ def tick_until_idle(max_ticks: 1000)
69
+ count = 0
70
+ while !@queue.empty? && count < max_ticks
71
+ tick
72
+ count += 1
73
+ end
74
+ count
75
+ end
76
+
77
+ # Returns the number of events waiting to be dispatched.
78
+ # @return [Integer]
79
+ # @api private
80
+ def queue_depth
81
+ @queue.size
82
+ end
83
+
84
+ # Register a handler block for events of the given class.
85
+ # Use +:any+ to handle all event types.
86
+ #
87
+ # @param klass [Class, :any]
88
+ # @yield [event]
89
+ # @return [self]
90
+ # @api private
91
+ def on(klass, &block)
92
+ @handlers[klass] = block
93
+ self
94
+ end
95
+
96
+ # Returns true when the queue is empty.
97
+ # @return [Boolean]
98
+ # @api private
99
+ def idle?
100
+ @queue.empty?
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # RSpec helper module that provides a deterministic {Runtime} backed by
6
+ # {Phronomy::Runtime::FakeScheduler}.
7
+ #
8
+ # Include this module in your RSpec describe/context blocks and call
9
+ # {#with_fake_scheduler} to run a block of code inside a fully
10
+ # synchronous, event-logged runtime.
11
+ #
12
+ # @example Basic usage (no clock)
13
+ # include Phronomy::Testing::SchedulerHelpers
14
+ #
15
+ # it "records completed events" do
16
+ # with_fake_scheduler do |sched|
17
+ # Phronomy::Runtime.instance.spawn(name: "my-task") { 42 }
18
+ # expect(sched.event_log.map { |e| e[:type] }).to include(:completed)
19
+ # end
20
+ # end
21
+ #
22
+ # @example With a FakeClock
23
+ # include Phronomy::Testing::SchedulerHelpers
24
+ #
25
+ # it "surfaces pending timers" do
26
+ # clock = Phronomy::Testing::FakeClock.new
27
+ # with_fake_scheduler(clock: clock) do |sched|
28
+ # clock.schedule(seconds: 5) { :fired }
29
+ # expect(sched.pending_timers.first[:fire_at]).to eq(5.0)
30
+ # end
31
+ # end
32
+ module SchedulerHelpers
33
+ # Run +block+ with a {Phronomy::Runtime} that uses
34
+ # {Phronomy::Runtime::FakeScheduler}.
35
+ #
36
+ # The global runtime is replaced for the duration of the block and
37
+ # restored afterwards, whether the block raises or not.
38
+ #
39
+ # @param clock [Phronomy::Testing::FakeClock, nil]
40
+ # Optional fake clock to inject into the scheduler for timer support
41
+ # and event timestamping.
42
+ # @yield [scheduler, clock] the {Runtime::FakeScheduler} and the clock
43
+ # @return [Object] the return value of the block
44
+ # @api private
45
+ def with_fake_scheduler(clock: nil)
46
+ scheduler = Phronomy::Runtime::FakeScheduler.new
47
+ scheduler.clock = clock if clock
48
+ runtime = Phronomy::Runtime.new(scheduler: scheduler)
49
+ original = Phronomy::Runtime.instance
50
+ Phronomy::Runtime.instance = runtime
51
+ begin
52
+ yield scheduler, clock
53
+ ensure
54
+ Phronomy::Runtime.instance = original
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Test helpers for deterministic, timer-independent testing.
5
+ #
6
+ # @example
7
+ # require "phronomy/testing"
8
+ # clock = Phronomy::Testing::FakeClock.new
9
+ # scheduler = Phronomy::Testing::FakeScheduler.new
10
+ module Testing
11
+ end
12
+ end
@@ -68,6 +68,7 @@ module Phronomy
68
68
  # Returns nested schema definitions registered via .param(properties: ...).
69
69
  # @return [Hash{Symbol => Hash}]
70
70
  # @api public
71
+ # mutant:disable - neutral failure: unparser round-trip produces different source
71
72
  def param_schemas
72
73
  @param_schemas ||= {}
73
74
  end
@@ -76,6 +77,7 @@ module Phronomy
76
77
 
77
78
  # Recursively normalises a properties hash so all keys are Symbols and
78
79
  # each spec has a :type key.
80
+ # mutant:disable
79
81
  def normalize_nested_schema(props)
80
82
  props.transform_keys(&:to_sym).transform_values do |spec|
81
83
  s = spec.transform_keys(&:to_sym)
@@ -91,12 +93,45 @@ module Phronomy
91
93
  # the Workflow/Guardrail layer).
92
94
  # @param value [Symbol] e.g. :read_only, :write, :admin
93
95
  # @api public
96
+ # mutant:disable - neutral failure: unparser round-trip produces different source
94
97
  def scope(value = nil)
95
98
  return @scope if value.nil?
96
99
 
97
100
  @scope = value
98
101
  end
99
102
 
103
+ # Sets or reads the execution mode for this tool.
104
+ #
105
+ # Execution mode is the concurrency contract declaration for the tool.
106
+ # In Phronomy's non-preemptive, cooperative concurrency model it controls
107
+ # which runtime resource is used to dispatch the tool:
108
+ #
109
+ # | Mode | Dispatcher | Constraint |
110
+ # |------|-----------|------------|
111
+ # | +:cooperative+ | +Runtime.instance.spawn+ (scheduler task) | *Must not* block the scheduler thread; use only for in-memory computation |
112
+ # | +:blocking_io+ | {Phronomy::Concurrency::BlockingAdapterPool} (bounded thread pool) | **Default**. Safe for all blocking I/O (HTTP, DB, file) |
113
+ # | +:cpu_bound+ | Falls back to +:blocking_io+ + emits a warning | No dedicated process pool yet; use +:blocking_io+ explicitly to suppress the warning |
114
+ # | +:external_process+ | Falls back to +:blocking_io+ | No process manager yet |
115
+ #
116
+ # Tools that perform network calls, file I/O, or database queries should use
117
+ # +:blocking_io+ (the default). Tools that only perform in-memory computation
118
+ # may declare +:cooperative+ for lower overhead.
119
+ #
120
+ # @param value [Symbol, nil] when nil, returns the current value
121
+ # @return [Symbol] the current execution mode (default :blocking_io)
122
+ # @api public
123
+ # mutant:disable
124
+ def execution_mode(value = nil)
125
+ return @execution_mode || :blocking_io if value.nil?
126
+
127
+ valid = %i[cooperative blocking_io cpu_bound external_process]
128
+ unless valid.include?(value)
129
+ raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
130
+ end
131
+
132
+ @execution_mode = value
133
+ end
134
+
100
135
  # Configures error-handling behavior when +execute+ raises an unexpected error.
101
136
  #
102
137
  # @param behavior [Symbol]
@@ -130,6 +165,7 @@ module Phronomy
130
165
  # :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
131
166
  # falls back to :return_error when coercion is not possible.
132
167
  # @api public
168
+ # mutant:disable - neutral failure: unparser round-trip produces different source
133
169
  def on_schema_error(behavior = nil)
134
170
  return @on_schema_error || :return_error if behavior.nil?
135
171
 
@@ -139,12 +175,41 @@ module Phronomy
139
175
  # Configures whether human approval is required before executing this tool.
140
176
  # @param value [Boolean]
141
177
  # @api public
178
+ # mutant:disable - neutral failure: unparser round-trip produces different source
142
179
  def requires_approval(value = nil)
143
180
  return @requires_approval || false if value.nil?
144
181
 
145
182
  @requires_approval = value
146
183
  end
147
184
 
185
+ # Marks one or more parameter names as sensitive so their values are
186
+ # replaced with +"[REDACTED]"+ in log and trace output.
187
+ #
188
+ # @param names [Array<Symbol>] parameter names to redact
189
+ # @return [Array<Symbol>] the full list of redacted param names
190
+ # @api public
191
+ # mutant:disable
192
+ def redact_params(*names)
193
+ if names.empty?
194
+ parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
195
+ ((@redacted_params || []) + parent).uniq
196
+ else
197
+ @redacted_params = ((@redacted_params || []) + names.map(&:to_sym)).uniq
198
+ end
199
+ end
200
+
201
+ # Sets a per-tool maximum result size (in characters).
202
+ # Overrides the global +Phronomy.configuration.tool_result_max_size+ when set.
203
+ # Set to +nil+ to inherit the global limit.
204
+ #
205
+ # @param value [Integer, nil]
206
+ # @api public
207
+ def max_result_size(value = :__unset__)
208
+ return @max_result_size if value == :__unset__
209
+
210
+ @max_result_size = value
211
+ end
212
+
148
213
  # Registers a retry policy for one or more exception classes.
149
214
  #
150
215
  # When the tool raises one of the listed exception classes, it will be
@@ -170,6 +235,7 @@ module Phronomy
170
235
  # Returns all retry policies registered on this tool class.
171
236
  # @return [Array<Hash>]
172
237
  # @api public
238
+ # mutant:disable - neutral failure: unparser round-trip produces different source
173
239
  def retry_policies
174
240
  @retry_policies || []
175
241
  end
@@ -178,6 +244,7 @@ module Phronomy
178
244
  # Defaults to Kernel#sleep.
179
245
  # @return [#call]
180
246
  # @api private
247
+ # mutant:disable - neutral failure: unparser round-trip produces different source
181
248
  def _sleep_proc
182
249
  @_sleep_proc || method(:sleep)
183
250
  end
@@ -190,12 +257,18 @@ module Phronomy
190
257
  # Returns the function name exposed to the LLM.
191
258
  # Uses the class-level tool_name if set; otherwise falls back to RubyLLM's
192
259
  # automatic conversion (CamelCase → snake_case, strips trailing "_tool").
260
+ # mutant:disable - neutral failure: unparser round-trip produces different source
193
261
  def name
194
262
  self.class.tool_name || super
195
263
  end
196
264
 
197
265
  # Returns the JSON Schema for this tool's parameters.
198
266
  # Injects "enum" entries for any param declared with enum: [...].
267
+ # mutant:disable - genuine equivalent mutations:
268
+ # 1. `|| schema.dig(:properties)`: dead code because RubyLLM::Tool always returns a
269
+ # string-keyed hash; schema.dig(:properties) is always nil in practice.
270
+ # 2. `return schema unless properties` guard: dead code when schema is non-nil because
271
+ # RubyLLM::Tool always includes a "properties" key when parameters are declared.
199
272
  def params_schema
200
273
  schema = super
201
274
  return schema if schema.nil?
@@ -245,10 +318,11 @@ module Phronomy
245
318
  # 5. On persistent failure, apply on_error policy.
246
319
  #
247
320
  # @param args [Hash]
248
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; takes precedence over the thread-local token
321
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; takes precedence over the thread-local token
249
322
  # @api public
323
+ # mutant:disable
250
324
  def call(args, cancellation_token: nil)
251
- ct = cancellation_token || Thread.current[:phronomy_cancellation_token]
325
+ ct = cancellation_token
252
326
  ct&.raise_if_cancelled!
253
327
  validated_args, schema_error = validate_and_coerce(args)
254
328
  if schema_error
@@ -261,7 +335,8 @@ module Phronomy
261
335
  end
262
336
  end
263
337
  validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
264
- with_tool_retry { super(validated_args) }
338
+ result = with_tool_retry { super(validated_args) }
339
+ truncate_result_if_needed(result)
265
340
  rescue Phronomy::ToolError
266
341
  raise
267
342
  rescue Phronomy::CancellationError
@@ -281,12 +356,34 @@ module Phronomy
281
356
  end
282
357
  end
283
358
 
359
+ # Invokes this tool asynchronously and returns a {Phronomy::Task}.
360
+ #
361
+ # Routing is governed by the class-level {.execution_mode} setting.
362
+ # Delegates to {Phronomy::Agent::ToolExecutor.call_async} which is the single
363
+ # place in the framework that applies the execution-mode routing rules.
364
+ #
365
+ # @param args [Hash]
366
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
367
+ # @return [#await]
368
+ # @api public
369
+ # mutant:disable
370
+ def call_async(args, cancellation_token: nil)
371
+ Phronomy::Agent::ToolExecutor.call_async(
372
+ tool: self,
373
+ args: args,
374
+ cancellation_token: cancellation_token
375
+ )
376
+ end
377
+
284
378
  # Instance method accessor — delegates to the class-level flag.
285
379
  def requires_approval
286
380
  self.class.requires_approval
287
381
  end
288
382
 
289
383
  # Instance method for requires_approval? (convenience accessor).
384
+ # mutant:disable - genuine equivalent: self.requires_approval delegates to
385
+ # self.class.requires_approval via the instance method defined above, so
386
+ # both expressions produce the same value.
290
387
  def requires_approval?
291
388
  self.class.requires_approval
292
389
  end
@@ -316,16 +413,57 @@ module Phronomy
316
413
 
317
414
  # Returns true when the #execute method declares a +cancellation_token:+
318
415
  # keyword parameter, indicating it opts into cooperative cancellation.
416
+ # mutant:disable
319
417
  def execute_accepts_cancellation_token?
320
- method(:execute).parameters.any? do |type, name|
418
+ method(:execute).parameters.any? do |type, name| # mutant:disable
321
419
  name == :cancellation_token && %i[key keyreq].include?(type)
322
420
  end
323
421
  end
324
422
 
423
+ # Truncates the result string when it exceeds the configured maximum size.
424
+ # Uses the per-tool limit first, then the global configuration limit.
425
+ # Returns the original result when no limit is configured.
426
+ def truncate_result_if_needed(result)
427
+ max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
428
+ return result unless max && result.respond_to?(:length) && result.length > max
429
+
430
+ msg = "[Phronomy] Tool #{self.class.name} result truncated " \
431
+ "(#{result.length} chars > #{max} limit)"
432
+ if Phronomy.configuration.logger
433
+ Phronomy.configuration.logger.warn(msg)
434
+ else
435
+ warn msg
436
+ end
437
+ "#{result[0, max]}...[truncated]"
438
+ end
439
+
440
+ # Returns a copy of +args+ with redacted parameter values replaced by
441
+ # +"[REDACTED]"+. Used for logging and tracing.
442
+ # @param args [Hash]
443
+ # @return [Hash]
444
+ # @api private
445
+ def redacted_args(args)
446
+ redacted = self.class.redact_params
447
+ return args if redacted.empty?
448
+
449
+ args.each_with_object({}) do |(k, v), h|
450
+ h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
451
+ end
452
+ end
453
+
325
454
  # Executes the given block inside a retry loop driven by the class-level
326
455
  # retry_policies. Each policy matches by exception class; the first matching
327
456
  # policy governs the wait and retry count. Raises immediately when no policy
328
457
  # covers the exception or when all retries are exhausted.
458
+ # mutant:disable - genuine equivalent mutations:
459
+ # 1. `if policies.empty?; return yield; end` early-return variants (nil, false, block
460
+ # removal): behavior is identical because when policies is empty, yield is still
461
+ # called inside begin/rescue, any exception is re-raised (policy=nil, condition
462
+ # false), and successful returns propagate the same value either way.
463
+ # 2. `p[:exceptions].any?` vs `p.fetch(:exceptions).any?`: :exceptions key is always
464
+ # present (set unconditionally by .retry_on), so fetch/[] are equivalent.
465
+ # 3. `policy[:times]`, `policy[:wait]`, `policy[:base]` vs `.fetch(...)`: same reason
466
+ # as #2 — all keys are always set by .retry_on.
329
467
  def with_tool_retry
330
468
  policies = self.class.retry_policies
331
469
  return yield if policies.empty?
@@ -371,14 +509,21 @@ module Phronomy
371
509
  # @param args [Hash] raw args passed to #call (string or symbol keys)
372
510
  # @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
373
511
  # @api public
512
+ # mutant:disable
374
513
  def validate_and_coerce(args)
514
+ # mutant:disable - genuine equivalents:
515
+ # 1. `return [args, nil]` vs `return [args]`: Ruby multiple assignment
516
+ # fills nil for missing elements, so both are identical to callers.
517
+ # 2. `self.class.parameters` vs `self.parameters`: RubyLLM::Tool exposes
518
+ # `parameters` as both a class method and an instance method that
519
+ # delegates to the class method, so both return the same value.
375
520
  return [args, nil] if self.class.parameters.empty?
376
521
 
377
522
  normalized = (args || {}).transform_keys(&:to_sym)
378
523
  coerce_mode = self.class.on_schema_error == :coerce
379
524
  result = {}
380
525
 
381
- self.class.parameters.each do |name, param|
526
+ self.class.parameters.each do |name, param| # mutant:disable
382
527
  value = normalized[name]
383
528
  if value.nil?
384
529
  # Return a descriptive error for missing required params so the LLM
@@ -415,12 +560,12 @@ module Phronomy
415
560
 
416
561
  # Reject any keys not covered by declared parameters to prevent silent
417
562
  # parameter injection (e.g. via prompt injection).
418
- extra = normalized.keys - self.class.parameters.keys
563
+ extra = normalized.keys - self.class.parameters.keys # mutant:disable
419
564
  unless extra.empty?
420
565
  return [nil, "unknown parameter(s): #{extra.inspect}"]
421
566
  end
422
567
 
423
- [result, nil]
568
+ [result, nil] # mutant:disable
424
569
  end
425
570
 
426
571
  # Converts the internal normalized nested schema (from param_schemas) to
@@ -430,6 +575,7 @@ module Phronomy
430
575
  # @param nested [Hash{Symbol=>Hash}] normalized schema from param_schemas
431
576
  # @return [Hash{String=>Hash}] JSON Schema properties
432
577
  # @api public
578
+ # mutant:disable
433
579
  def nested_schema_to_json_schema(nested)
434
580
  nested.each_with_object({}) do |(prop_name, spec), acc|
435
581
  entry = {"type" => spec[:type].to_s}
@@ -447,6 +593,7 @@ module Phronomy
447
593
  # @param properties [Hash{Symbol=>Hash}] nested schema from param_schemas
448
594
  # @param path [String] dot-separated field path for error messages
449
595
  # @api public
596
+ # mutant:disable
450
597
  def validate_nested_object(value, properties, path)
451
598
  return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
452
599
 
@@ -484,6 +631,7 @@ module Phronomy
484
631
  # @param value [Object]
485
632
  # @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
486
633
  # @api public
634
+ # mutant:disable
487
635
  def type_error(value, declared_type)
488
636
  return nil if value.nil?
489
637
 
@@ -506,6 +654,7 @@ module Phronomy
506
654
 
507
655
  # Attempts to coerce +value+ to +declared_type+.
508
656
  # Returns [coerced_value, nil] on success, [nil, error_message] on failure.
657
+ # mutant:disable
509
658
  def coerce_value(value, declared_type)
510
659
  return [value, nil] if value.nil?
511
660