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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- 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 +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -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 +374 -0
- 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 +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -5,21 +5,39 @@ module Phronomy
|
|
|
5
5
|
# RubyLLM::Chat subclass that executes multiple tool calls concurrently.
|
|
6
6
|
#
|
|
7
7
|
# When the LLM returns more than one tool call in a single response, each
|
|
8
|
-
# tool is dispatched
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# are
|
|
8
|
+
# tool is dispatched according to its +execution_mode+:
|
|
9
|
+
# - +:cooperative+ tools run via +Runtime.instance.spawn+, delegating
|
|
10
|
+
# scheduling to the configured runtime backend.
|
|
11
|
+
# - +:blocking_io+ tools are offloaded to a +BlockingAdapterPool+ worker
|
|
12
|
+
# thread so they do not occupy a scheduler task slot.
|
|
13
|
+
# All results are collected before being appended to the message history,
|
|
14
|
+
# preserving deterministic message order while reducing wall-clock latency
|
|
15
|
+
# when tools are IO-bound (HTTP calls, DB queries, etc.).
|
|
12
16
|
#
|
|
13
17
|
# Single-tool responses fall through to the standard sequential path via
|
|
14
18
|
# +super+, preserving all existing edge-case behaviour (Tool::Halt,
|
|
15
19
|
# forced_tool_choice, streaming, SuspendSignal, etc.).
|
|
16
20
|
#
|
|
17
|
-
# This class is used automatically when
|
|
18
|
-
# {
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
+
# This class is used automatically when EventLoop mode is enabled
|
|
22
|
+
# ({Phronomy.configuration.event_loop}). It is not used for direct
|
|
23
|
+
# synchronous +invoke+ calls so that the streaming callback state remains
|
|
24
|
+
# single-threaded.
|
|
21
25
|
# @api private
|
|
22
26
|
class ParallelToolChat < RubyLLM::Chat
|
|
27
|
+
# @param max_parallel_tools [Integer] maximum simultaneous tool executions
|
|
28
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] token observed before each batch
|
|
29
|
+
# @param opts [Hash] remaining kwargs forwarded to RubyLLM::Chat
|
|
30
|
+
# @api private
|
|
31
|
+
def initialize(max_parallel_tools: 10, cancellation_token: nil, **opts)
|
|
32
|
+
super(**opts)
|
|
33
|
+
@max_parallel_tools = max_parallel_tools
|
|
34
|
+
@cancellation_token = cancellation_token
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Allows the owning agent to update the token between retries.
|
|
38
|
+
# @api private
|
|
39
|
+
attr_writer :cancellation_token
|
|
40
|
+
|
|
23
41
|
private
|
|
24
42
|
|
|
25
43
|
# Overrides RubyLLM::Chat#handle_tool_calls to parallelise execution
|
|
@@ -29,7 +47,8 @@ module Phronomy
|
|
|
29
47
|
# 1. Pre-execution callbacks (+on_new_message+, +on_tool_call+) —
|
|
30
48
|
# sequential so that the Suspendable concern's approval hook can
|
|
31
49
|
# raise +SuspendSignal+ before any tool is executed.
|
|
32
|
-
# 2. Parallel tool execution —
|
|
50
|
+
# 2. Parallel tool execution — cooperative tools via Runtime.instance.spawn
|
|
51
|
+
# (respects the configured runtime backend), blocking_io tools via BlockingAdapterPool.
|
|
33
52
|
# 3. Post-execution callbacks and message recording — sequential,
|
|
34
53
|
# in the original tool-call order.
|
|
35
54
|
#
|
|
@@ -52,29 +71,48 @@ module Phronomy
|
|
|
52
71
|
end
|
|
53
72
|
|
|
54
73
|
# Phase 2 — parallel tool execution.
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
74
|
+
# :cooperative tools run inside a Task (no pool).
|
|
75
|
+
# :blocking_io/:cpu_bound/:external_process tools are submitted directly
|
|
76
|
+
# to BlockingAdapterPool when available — eliminating the extra Task
|
|
77
|
+
# Thread that previously wrapped each pool operation.
|
|
59
78
|
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
ct =
|
|
63
|
-
max =
|
|
64
|
-
|
|
79
|
+
# Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
|
|
80
|
+
# #await, so results are collected uniformly below.
|
|
81
|
+
ct = @cancellation_token
|
|
82
|
+
max = @max_parallel_tools
|
|
83
|
+
tool_results = tool_calls.each_slice(max).flat_map do |batch|
|
|
65
84
|
if ct&.cancelled?
|
|
66
85
|
raise Phronomy::CancellationError, "invocation cancelled before tool execution"
|
|
67
86
|
end
|
|
68
87
|
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
# Dispatch all tools in this batch via ToolExecutor (centralised routing).
|
|
89
|
+
dispatched = batch.map do |tc|
|
|
90
|
+
tool = tools[tc.name.to_sym]
|
|
91
|
+
unless tool
|
|
92
|
+
next {tool_call: tc, awaitable: nil, result: {
|
|
93
|
+
error: "Model tried to call unavailable tool `#{tc.name}`. " \
|
|
94
|
+
"Available tools: #{tools.keys.to_json}."
|
|
95
|
+
}}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
awaitable = Phronomy::ToolExecutor.call_async(
|
|
99
|
+
tool: tool,
|
|
100
|
+
args: tc.arguments,
|
|
101
|
+
cancellation_token: ct
|
|
102
|
+
)
|
|
103
|
+
{tool_call: tc, awaitable: awaitable, result: nil}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Await all dispatched operations in original order.
|
|
107
|
+
dispatched.map do |item|
|
|
108
|
+
result = item[:awaitable] ? item[:awaitable].await : item[:result]
|
|
109
|
+
{tool_call: item[:tool_call], result: result}
|
|
71
110
|
end
|
|
72
|
-
threads.map(&:value)
|
|
73
111
|
end
|
|
74
112
|
|
|
75
113
|
# Phase 3 — post-execution callbacks and message recording (sequential).
|
|
76
114
|
halt_result = nil
|
|
77
|
-
|
|
115
|
+
tool_results.each do |item|
|
|
78
116
|
result = item[:result]
|
|
79
117
|
@on[:tool_result]&.call(result)
|
|
80
118
|
tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
|
|
@@ -87,6 +125,25 @@ module Phronomy
|
|
|
87
125
|
reset_tool_choice if forced_tool_choice?
|
|
88
126
|
halt_result || complete(&block)
|
|
89
127
|
end
|
|
128
|
+
|
|
129
|
+
# Overrides RubyLLM::Chat#execute_tool to forward the cancellation token
|
|
130
|
+
# explicitly and to route the call through {ToolExecutor} so that the
|
|
131
|
+
# execution_mode decision is made in a single place.
|
|
132
|
+
def execute_tool(tool_call)
|
|
133
|
+
tool = tools[tool_call.name.to_sym]
|
|
134
|
+
unless tool
|
|
135
|
+
return {
|
|
136
|
+
error: "Model tried to call unavailable tool `#{tool_call.name}`. " \
|
|
137
|
+
"Available tools: #{tools.keys.to_json}."
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
Phronomy::ToolExecutor.call_async(
|
|
142
|
+
tool: tool,
|
|
143
|
+
args: tool_call.arguments,
|
|
144
|
+
cancellation_token: @cancellation_token
|
|
145
|
+
).await
|
|
146
|
+
end
|
|
90
147
|
end
|
|
91
148
|
end
|
|
92
149
|
end
|
|
@@ -13,6 +13,10 @@ module Phronomy
|
|
|
13
13
|
caller_meta = {}
|
|
14
14
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
15
15
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
16
|
+
if (ic = config[:invocation_context])
|
|
17
|
+
caller_meta[:task_id] = ic.task_id if ic.task_id
|
|
18
|
+
caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
|
|
19
|
+
end
|
|
16
20
|
|
|
17
21
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
18
22
|
# Run input guardrails before any LLM interaction.
|
|
@@ -68,6 +72,10 @@ module Phronomy
|
|
|
68
72
|
caller_meta = {}
|
|
69
73
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
70
74
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
75
|
+
if (ic = config[:invocation_context])
|
|
76
|
+
caller_meta[:task_id] = ic.task_id if ic.task_id
|
|
77
|
+
caller_meta[:parent_task_id] = ic.parent_task_id if ic.parent_task_id
|
|
78
|
+
end
|
|
71
79
|
|
|
72
80
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
73
81
|
run_input_guardrails!(input)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# A thread-safe FIFO queue for passing values between concurrent tasks.
|
|
5
|
+
#
|
|
6
|
+
# Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
|
|
7
|
+
# standard-library type directly. A future implementation may replace the
|
|
8
|
+
# backing primitive without changing call sites.
|
|
9
|
+
#
|
|
10
|
+
# @example Producer / consumer
|
|
11
|
+
# queue = Phronomy::AsyncQueue.new
|
|
12
|
+
# Runtime.instance.spawn { queue.push(expensive_io()) }
|
|
13
|
+
# value = queue.pop # blocks until the producer pushes
|
|
14
|
+
# @api private
|
|
15
|
+
class AsyncQueue
|
|
16
|
+
# @param max_size [Integer, nil] optional upper bound on queue depth.
|
|
17
|
+
# When set, {#push} blocks the caller until a slot is available.
|
|
18
|
+
# @api private
|
|
19
|
+
def initialize(max_size: nil)
|
|
20
|
+
@queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
|
|
21
|
+
@max_size = max_size
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Enqueues +item+.
|
|
25
|
+
# In a cooperative scheduler context with a bounded queue (max_size:), suspends
|
|
26
|
+
# the current Fiber via a scheduler signal when the queue is full rather than
|
|
27
|
+
# blocking the OS thread. Without a scheduler, falls back to the standard
|
|
28
|
+
# SizedQueue blocking behaviour.
|
|
29
|
+
# @param item [Object] value to enqueue
|
|
30
|
+
# @return [self]
|
|
31
|
+
# @api private
|
|
32
|
+
def push(item)
|
|
33
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
34
|
+
if scheduler && @max_size
|
|
35
|
+
_push_cooperative(scheduler, item)
|
|
36
|
+
else
|
|
37
|
+
@queue.push(item)
|
|
38
|
+
scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
|
|
39
|
+
end
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Dequeues and returns the next item.
|
|
44
|
+
# In a cooperative scheduler context, suspends the current Fiber (yielding
|
|
45
|
+
# control back to the scheduler) rather than blocking the OS thread.
|
|
46
|
+
#
|
|
47
|
+
# When +timeout+ is given the semantics depend on the active backend:
|
|
48
|
+
#
|
|
49
|
+
# * **Thread backend** (`:thread`) — uses real wall-clock time via
|
|
50
|
+
# +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
|
|
51
|
+
# Returns +nil+ if no item arrives within the specified number of real seconds.
|
|
52
|
+
# * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
|
|
53
|
+
# *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
|
|
54
|
+
# the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
|
|
55
|
+
# In tests this means the timeout is fully deterministic and does not depend on
|
|
56
|
+
# actual elapsed wall time. However, in production `:fiber` mode the timeout
|
|
57
|
+
# may never expire unless the scheduler explicitly advances virtual time.
|
|
58
|
+
#
|
|
59
|
+
# @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
|
|
60
|
+
# in production workloads is not guaranteed and may differ from wall-clock
|
|
61
|
+
# expectations.
|
|
62
|
+
# @note **Cooperative timeout limitation**: on the cooperative path, the
|
|
63
|
+
# deadline is re-checked *after* a wake-up signal arrives. If virtual time
|
|
64
|
+
# has already passed the deadline when the consumer is woken by a producer
|
|
65
|
+
# push, the consumer returns +nil+ rather than the pushed item. Without any
|
|
66
|
+
# wake-up signal the waiting Fiber remains suspended even after
|
|
67
|
+
# +scheduler.advance+ — the timeout does not self-fire.
|
|
68
|
+
# @param timeout [Numeric, nil] seconds to wait before returning +nil+.
|
|
69
|
+
# Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
|
|
70
|
+
# @return [Object, nil] the next item, or +nil+ when timeout expires
|
|
71
|
+
# @api private
|
|
72
|
+
def pop(timeout: nil)
|
|
73
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
74
|
+
if scheduler
|
|
75
|
+
_pop_cooperative(scheduler, timeout: timeout)
|
|
76
|
+
elsif timeout
|
|
77
|
+
@queue.pop(timeout: timeout)
|
|
78
|
+
else
|
|
79
|
+
@queue.pop
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the current number of items in the queue.
|
|
84
|
+
# @return [Integer]
|
|
85
|
+
# @api private
|
|
86
|
+
def size
|
|
87
|
+
@queue.size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns +true+ when the queue contains no items.
|
|
91
|
+
# @return [Boolean]
|
|
92
|
+
# @api private
|
|
93
|
+
def empty?
|
|
94
|
+
@queue.empty?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
|
|
98
|
+
# @return [self]
|
|
99
|
+
# @api private
|
|
100
|
+
def close
|
|
101
|
+
@queue.close
|
|
102
|
+
self
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Cooperative pop for DeterministicScheduler context.
|
|
108
|
+
# Suspends the current Fiber via the scheduler's signal mechanism rather than
|
|
109
|
+
# blocking the OS thread. Because cooperative mode is single-threaded, the
|
|
110
|
+
# empty?/pop pair is race-free (no other Fiber can run between the two calls).
|
|
111
|
+
# After dequeuing, notifies any push-waiter so that a backpressure-suspended
|
|
112
|
+
# producer can be unblocked.
|
|
113
|
+
# @api private
|
|
114
|
+
# @param scheduler [Runtime::Scheduler]
|
|
115
|
+
# @param timeout [Numeric, nil]
|
|
116
|
+
# @return [Object, nil]
|
|
117
|
+
def _pop_cooperative(scheduler, timeout:)
|
|
118
|
+
@coop_signal ||= scheduler.new_signal
|
|
119
|
+
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
120
|
+
|
|
121
|
+
loop do
|
|
122
|
+
unless @queue.empty?
|
|
123
|
+
item = @queue.pop(timeout: 0)
|
|
124
|
+
# Notify a push-waiter (bounded queue) that a slot opened up.
|
|
125
|
+
scheduler.raise_signal(@push_signal) if @push_signal
|
|
126
|
+
return item
|
|
127
|
+
end
|
|
128
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
129
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
130
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Cooperative push for DeterministicScheduler context with a bounded queue.
|
|
135
|
+
# Suspends the current Fiber via a scheduler signal when the queue is full,
|
|
136
|
+
# rather than blocking the OS thread.
|
|
137
|
+
# @api private
|
|
138
|
+
# @param scheduler [Runtime::Scheduler]
|
|
139
|
+
# @param item [Object]
|
|
140
|
+
# @return [void]
|
|
141
|
+
def _push_cooperative(scheduler, item)
|
|
142
|
+
@push_signal ||= scheduler.new_signal
|
|
143
|
+
|
|
144
|
+
loop do
|
|
145
|
+
unless @queue.size >= @max_size
|
|
146
|
+
@queue.push(item)
|
|
147
|
+
# Notify any pop-waiter that an item is now available.
|
|
148
|
+
scheduler.raise_signal(@coop_signal) if @coop_signal
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
scheduler.wait_for_signal(@push_signal)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|