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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- 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
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -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
|
|
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
|
|