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
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Manages a bounded set of concurrent {Task}s with structured concurrency.
5
+ #
6
+ # Enforces an upper bound on simultaneously running tasks (+limit+).
7
+ # When the limit is reached, {#spawn} blocks the caller until a slot
8
+ # becomes available. Results are always returned in the order tasks
9
+ # were spawned, regardless of completion order.
10
+ #
11
+ # A configurable +failure_policy+ controls how errors propagate:
12
+ # - +:fail_fast+ (default) — cancels all remaining tasks on the first error
13
+ # - +:collect_all+ — waits for every task to complete, then raises the first error
14
+ # - +:skip_failed+ — ignores failed tasks and returns only successful results
15
+ #
16
+ # {#cancel_all!} cancels every task in the group and joins them, guaranteeing
17
+ # that the active child task count reaches zero before returning.
18
+ #
19
+ # @example Parallel tool calls with a concurrency cap
20
+ # group = Phronomy::TaskGroup.new(limit: 5)
21
+ # tasks = items.map { |item| group.spawn { process(item) } }
22
+ # results = group.await_all # Array in spawn order
23
+ #
24
+ # @example Collect-all failure policy
25
+ # group = Phronomy::TaskGroup.new(failure_policy: :collect_all)
26
+ # …
27
+ class TaskGroup
28
+ # Valid failure policies.
29
+ FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
30
+
31
+ # @param limit [Integer, Float::INFINITY] maximum simultaneous active tasks
32
+ # @param failure_policy [Symbol] one of {FAILURE_POLICIES} (default +:fail_fast+)
33
+ # @param runtime [Runtime, nil] runtime used to spawn tasks via {Runtime#spawn};
34
+ # when +nil+, tasks are created directly via +Task.new+ (backward-compatible mode).
35
+ # Pass +runtime: self+ from {Runtime#task_group} to keep task execution consistent
36
+ # with the configured scheduler backend.
37
+ # @api private
38
+ def initialize(limit: Float::INFINITY, failure_policy: :fail_fast, runtime: nil)
39
+ raise ArgumentError, "unknown failure_policy: #{failure_policy}" unless FAILURE_POLICIES.include?(failure_policy)
40
+
41
+ @limit = limit
42
+ @failure_policy = failure_policy
43
+ @runtime = runtime
44
+ @tasks = []
45
+ @mutex = Mutex.new
46
+ @cond = ConditionVariable.new
47
+ @active = 0
48
+ end
49
+
50
+ # Spawns a new task within the group.
51
+ # Blocks if the number of currently active tasks equals +limit+.
52
+ #
53
+ # @yield block to execute concurrently
54
+ # @return [Task] the spawned task
55
+ # @api private
56
+ def spawn(&block)
57
+ wait_for_slot!
58
+
59
+ task = if @runtime
60
+ @runtime.spawn(name: "task-group-worker") do
61
+ block.call
62
+ ensure
63
+ release_slot!
64
+ end
65
+ else
66
+ Task.new do
67
+ block.call
68
+ ensure
69
+ release_slot!
70
+ end
71
+ end
72
+
73
+ @mutex.synchronize { @tasks << task }
74
+ task
75
+ end
76
+
77
+ # Waits for all spawned tasks to complete.
78
+ # Returns results in spawn order.
79
+ #
80
+ # Failure behaviour is controlled by the +failure_policy+ set at
81
+ # construction time:
82
+ # - +:fail_fast+ — raises the first error after cancelling unfinished tasks
83
+ # - +:collect_all+ — waits for all tasks, then raises the first error
84
+ # - +:skip_failed+ — returns only the values of successful tasks
85
+ #
86
+ # @return [Array] results in spawn order (or successful-only for :skip_failed)
87
+ # @raise [Exception] when any task failed (except :skip_failed)
88
+ # @api private
89
+ def await_all
90
+ tasks = @mutex.synchronize { @tasks.dup }
91
+ return [] if tasks.empty?
92
+
93
+ if Phronomy::Runtime::Scheduler.current
94
+ _await_all_cooperative(tasks)
95
+ else
96
+ _await_all_threaded(tasks)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # Cooperative await_all for DeterministicScheduler context.
103
+ # Uses on_complete callbacks + AsyncQueue to observe task completions in
104
+ # arrival order (not spawn order), matching the fail-fast semantics of the
105
+ # threaded path. AsyncQueue#pop suspends the current Fiber cooperatively
106
+ # rather than blocking the OS thread.
107
+ # @api private
108
+ # @param tasks [Array<Task>]
109
+ # @return [Array]
110
+ def _await_all_cooperative(tasks)
111
+ completion_q = AsyncQueue.new
112
+ tasks.each_with_index do |task, idx|
113
+ task.on_complete do |value, error|
114
+ completion_q.push({index: idx, value: value, error: error})
115
+ end
116
+ end
117
+
118
+ entries = Array.new(tasks.length)
119
+ cancelled = false
120
+ fail_fast_error = nil
121
+
122
+ tasks.length.times do
123
+ entry = completion_q.pop # cooperative suspend via scheduler signal
124
+ entries[entry[:index]] = entry
125
+
126
+ if entry[:error] && @failure_policy == :fail_fast && !cancelled
127
+ cancelled = true
128
+ fail_fast_error = entry[:error]
129
+ tasks.each { |t| t.cancel! unless t.done? }
130
+ end
131
+ end
132
+
133
+ case @failure_policy
134
+ when :fail_fast
135
+ raise fail_fast_error if fail_fast_error
136
+ entries.map { |r| r[:value] }
137
+ when :skip_failed
138
+ entries.filter_map { |r| r[:value] unless r[:error] }
139
+ else # :collect_all
140
+ errors = entries.filter_map { |r| r[:error] }
141
+ raise errors.first if errors.any?
142
+ entries.map { |r| r[:value] }
143
+ end
144
+ end
145
+
146
+ # Thread-blocking await_all for ThreadBackend / ImmediateBackend context.
147
+ # Uses Task#on_complete callbacks instead of spawning N additional watcher
148
+ # tasks (Issue #328). on_complete receives the task's value and error
149
+ # directly — no await call is needed, eliminating the risk of a self-join
150
+ # when the callback fires inside the task's own execution thread.
151
+ def _await_all_threaded(tasks)
152
+ completion_q = Queue.new
153
+ tasks.each_with_index do |task, idx|
154
+ task.on_complete do |value, error|
155
+ completion_q.push({index: idx, value: value, error: error})
156
+ end
157
+ end
158
+
159
+ entries = Array.new(tasks.length)
160
+ cancelled = false
161
+ # The error that triggered fail_fast cancellation (tracked separately so
162
+ # we raise it rather than a secondary CancellationError from cancelled tasks).
163
+ fail_fast_error = nil
164
+
165
+ tasks.length.times do
166
+ entry = completion_q.pop
167
+ entries[entry[:index]] = entry
168
+
169
+ if entry[:error] && @failure_policy == :fail_fast && !cancelled
170
+ cancelled = true
171
+ fail_fast_error = entry[:error]
172
+ tasks.each { |t| t.cancel! unless t.done? }
173
+ end
174
+ end
175
+
176
+ case @failure_policy
177
+ when :fail_fast
178
+ raise fail_fast_error if fail_fast_error
179
+ entries.map { |r| r[:value] }
180
+ when :skip_failed
181
+ entries.filter_map { |r| r[:value] unless r[:error] }
182
+ else # :collect_all
183
+ errors = entries.filter_map { |r| r[:error] }
184
+ raise errors.first if errors.any?
185
+ entries.map { |r| r[:value] }
186
+ end
187
+ end
188
+
189
+ public
190
+
191
+ # Cancels all tasks currently in the group and waits for each to finish.
192
+ # After this method returns, the active child task count is guaranteed to
193
+ # be zero.
194
+ #
195
+ # Note: if a task is cancelled before its block has started executing, the
196
+ # internal +ensure+ clause inside the block may not run, so @active is
197
+ # reset explicitly after all tasks are joined.
198
+ #
199
+ # @return [self]
200
+ # @api private
201
+ def cancel_all!
202
+ tasks = @mutex.synchronize { @tasks.dup }
203
+ tasks.each(&:cancel!)
204
+ tasks.each do |t|
205
+ t.join
206
+ rescue
207
+ nil
208
+ end
209
+ # Force @active to zero: tasks cancelled before block execution starts
210
+ # may not decrement @active via their ensure clause.
211
+ scheduler = Phronomy::Runtime::Scheduler.current
212
+ if scheduler && @coop_signal
213
+ @active = 0
214
+ scheduler.raise_signal_all(@coop_signal)
215
+ else
216
+ @mutex.synchronize do
217
+ @active = 0
218
+ @cond.broadcast
219
+ end
220
+ end
221
+ self
222
+ end
223
+
224
+ # Returns the number of currently executing child tasks.
225
+ # @return [Integer]
226
+ # @api private
227
+ def active_task_count
228
+ @mutex.synchronize { @active }
229
+ end
230
+
231
+ private
232
+
233
+ def wait_for_slot!
234
+ scheduler = Phronomy::Runtime::Scheduler.current
235
+ if scheduler
236
+ @coop_signal ||= scheduler.new_signal
237
+ loop do
238
+ if @active < @limit
239
+ @active += 1
240
+ return
241
+ end
242
+ scheduler.wait_for_signal(@coop_signal)
243
+ end
244
+ else
245
+ @mutex.synchronize do
246
+ @cond.wait(@mutex) while @active >= @limit
247
+ @active += 1
248
+ end
249
+ end
250
+ end
251
+
252
+ def release_slot!
253
+ scheduler = Phronomy::Runtime::Scheduler.current
254
+ if scheduler && @coop_signal
255
+ @active -= 1
256
+ scheduler.raise_signal(@coop_signal)
257
+ else
258
+ @mutex.synchronize do
259
+ @active -= 1
260
+ @cond.signal
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -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
@@ -97,6 +97,37 @@ module Phronomy
97
97
  @scope = value
98
98
  end
99
99
 
100
+ # Sets or reads the execution mode for this tool.
101
+ #
102
+ # Execution mode is the concurrency contract declaration for the tool.
103
+ # In Phronomy's non-preemptive, cooperative concurrency model it controls
104
+ # which runtime resource is used to dispatch the tool:
105
+ #
106
+ # | Mode | Dispatcher | Constraint |
107
+ # |------|-----------|------------|
108
+ # | +:cooperative+ | +Runtime.instance.spawn+ (scheduler task) | *Must not* block the scheduler thread; use only for in-memory computation |
109
+ # | +:blocking_io+ | {Phronomy::BlockingAdapterPool} (bounded thread pool) | **Default**. Safe for all blocking I/O (HTTP, DB, file) |
110
+ # | +:cpu_bound+ | Falls back to +:blocking_io+ + emits a warning | No dedicated process pool yet; use +:blocking_io+ explicitly to suppress the warning |
111
+ # | +:external_process+ | Falls back to +:blocking_io+ | No process manager yet |
112
+ #
113
+ # Tools that perform network calls, file I/O, or database queries should use
114
+ # +:blocking_io+ (the default). Tools that only perform in-memory computation
115
+ # may declare +:cooperative+ for lower overhead.
116
+ #
117
+ # @param value [Symbol, nil] when nil, returns the current value
118
+ # @return [Symbol] the current execution mode (default :blocking_io)
119
+ # @api public
120
+ def execution_mode(value = nil)
121
+ return @execution_mode || :blocking_io if value.nil?
122
+
123
+ valid = %i[cooperative blocking_io cpu_bound external_process]
124
+ unless valid.include?(value)
125
+ raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
126
+ end
127
+
128
+ @execution_mode = value
129
+ end
130
+
100
131
  # Configures error-handling behavior when +execute+ raises an unexpected error.
101
132
  #
102
133
  # @param behavior [Symbol]
@@ -145,6 +176,33 @@ module Phronomy
145
176
  @requires_approval = value
146
177
  end
147
178
 
179
+ # Marks one or more parameter names as sensitive so their values are
180
+ # replaced with +"[REDACTED]"+ in log and trace output.
181
+ #
182
+ # @param names [Array<Symbol>] parameter names to redact
183
+ # @return [Array<Symbol>] the full list of redacted param names
184
+ # @api public
185
+ def redact_params(*names)
186
+ if names.empty?
187
+ parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
188
+ ((@redacted_params || []) + parent).uniq
189
+ else
190
+ @redacted_params = ((@redacted_params || []) + names.map(&:to_sym)).uniq
191
+ end
192
+ end
193
+
194
+ # Sets a per-tool maximum result size (in characters).
195
+ # Overrides the global +Phronomy.configuration.tool_result_max_size+ when set.
196
+ # Set to +nil+ to inherit the global limit.
197
+ #
198
+ # @param value [Integer, nil]
199
+ # @api public
200
+ def max_result_size(value = :__unset__)
201
+ return @max_result_size if value == :__unset__
202
+
203
+ @max_result_size = value
204
+ end
205
+
148
206
  # Registers a retry policy for one or more exception classes.
149
207
  #
150
208
  # When the tool raises one of the listed exception classes, it will be
@@ -248,7 +306,7 @@ module Phronomy
248
306
  # @param cancellation_token [Phronomy::CancellationToken, nil] optional; takes precedence over the thread-local token
249
307
  # @api public
250
308
  def call(args, cancellation_token: nil)
251
- ct = cancellation_token || Thread.current[:phronomy_cancellation_token]
309
+ ct = cancellation_token
252
310
  ct&.raise_if_cancelled!
253
311
  validated_args, schema_error = validate_and_coerce(args)
254
312
  if schema_error
@@ -261,7 +319,8 @@ module Phronomy
261
319
  end
262
320
  end
263
321
  validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
264
- with_tool_retry { super(validated_args) }
322
+ result = with_tool_retry { super(validated_args) }
323
+ truncate_result_if_needed(result)
265
324
  rescue Phronomy::ToolError
266
325
  raise
267
326
  rescue Phronomy::CancellationError
@@ -281,6 +340,24 @@ module Phronomy
281
340
  end
282
341
  end
283
342
 
343
+ # Invokes this tool asynchronously and returns a {Phronomy::Task}.
344
+ #
345
+ # Routing is governed by the class-level {.execution_mode} setting.
346
+ # Delegates to {Phronomy::ToolExecutor.call_async} which is the single
347
+ # place in the framework that applies the execution-mode routing rules.
348
+ #
349
+ # @param args [Hash]
350
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
351
+ # @return [#await]
352
+ # @api public
353
+ def call_async(args, cancellation_token: nil)
354
+ Phronomy::ToolExecutor.call_async(
355
+ tool: self,
356
+ args: args,
357
+ cancellation_token: cancellation_token
358
+ )
359
+ end
360
+
284
361
  # Instance method accessor — delegates to the class-level flag.
285
362
  def requires_approval
286
363
  self.class.requires_approval
@@ -322,6 +399,37 @@ module Phronomy
322
399
  end
323
400
  end
324
401
 
402
+ # Truncates the result string when it exceeds the configured maximum size.
403
+ # Uses the per-tool limit first, then the global configuration limit.
404
+ # Returns the original result when no limit is configured.
405
+ def truncate_result_if_needed(result)
406
+ max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
407
+ return result unless max && result.respond_to?(:length) && result.length > max
408
+
409
+ msg = "[Phronomy] Tool #{self.class.name} result truncated " \
410
+ "(#{result.length} chars > #{max} limit)"
411
+ if Phronomy.configuration.logger
412
+ Phronomy.configuration.logger.warn(msg)
413
+ else
414
+ warn msg
415
+ end
416
+ "#{result[0, max]}...[truncated]"
417
+ end
418
+
419
+ # Returns a copy of +args+ with redacted parameter values replaced by
420
+ # +"[REDACTED]"+. Used for logging and tracing.
421
+ # @param args [Hash]
422
+ # @return [Hash]
423
+ # @api private
424
+ def redacted_args(args)
425
+ redacted = self.class.redact_params
426
+ return args if redacted.empty?
427
+
428
+ args.each_with_object({}) do |(k, v), h|
429
+ h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
430
+ end
431
+ end
432
+
325
433
  # Executes the given block inside a retry loop driven by the class-level
326
434
  # retry_policies. Each policy matches by exception class; the first matching
327
435
  # policy governs the wait and retry count. Raises immediately when no policy