phronomy 0.7.1 → 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/README.md +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -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/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -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 +1 -1
- 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 +19 -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/{runtime → concurrency}/gate_registry.rb +1 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- 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/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +1 -0
- metadata +44 -42
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- 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/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- 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/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- 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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Centralises tool execution routing based on {Tool::Base.execution_mode}.
|
|
6
|
+
#
|
|
7
|
+
# This is the single place in the framework that decides *how* a tool call is
|
|
8
|
+
# dispatched:
|
|
9
|
+
#
|
|
10
|
+
# - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
|
|
11
|
+
# scheduler. Under the +:fiber+ backend this avoids an
|
|
12
|
+
# extra OS thread; under the +:thread+ backend it is
|
|
13
|
+
# backed by +ThreadScheduler+ (one thread per task).
|
|
14
|
+
# - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
|
|
15
|
+
# provides a pool; falls back to +Runtime#spawn+ otherwise.
|
|
16
|
+
# - +:cpu_bound+ — emits a deprecation-style warning then falls back to
|
|
17
|
+
# +:blocking_io+ routing (no process pool available yet).
|
|
18
|
+
# - +:external_process+ — falls back to +:blocking_io+ routing (no process
|
|
19
|
+
# manager available yet).
|
|
20
|
+
#
|
|
21
|
+
# All paths return an object that responds to +#await+ (+Phronomy::Task+ or
|
|
22
|
+
# +BlockingAdapterPool::PendingOperation+), so callers can collect results
|
|
23
|
+
# uniformly.
|
|
24
|
+
#
|
|
25
|
+
# @note Non-goals
|
|
26
|
+
# ToolExecutor deliberately does NOT provide:
|
|
27
|
+
# - A CPU-bound process pool. CPU-intensive tool work must be handled at the
|
|
28
|
+
# application layer (e.g., fork, Sidekiq, separate OS processes). The
|
|
29
|
+
# framework will not add a +ProcessPoolExecutor+ equivalent.
|
|
30
|
+
# - An external process manager. Spawning or supervising subprocesses is
|
|
31
|
+
# out of scope for this module.
|
|
32
|
+
# - Additional core execution routes beyond scheduler-backed cooperative
|
|
33
|
+
# execution and BlockingAdapterPool-backed blocking I/O isolation.
|
|
34
|
+
# The +:cpu_bound+ and +:external_process+ modes are accepted for
|
|
35
|
+
# compatibility but both fall back to +:blocking_io+ routing with a
|
|
36
|
+
# one-time warning. If a genuinely new core execution route is needed,
|
|
37
|
+
# a new ADR is required.
|
|
38
|
+
# These non-goals follow from the cooperative-first, non-preemptive
|
|
39
|
+
# concurrency model (ADR-010): framework components must not assume the
|
|
40
|
+
# caller's concurrency model, and CPU/process management belongs to the
|
|
41
|
+
# application layer.
|
|
42
|
+
#
|
|
43
|
+
# @api private
|
|
44
|
+
module ToolExecutor
|
|
45
|
+
# Tracks tool classes that have already emitted an execution_mode warning so
|
|
46
|
+
# that the same warning is only logged once per process lifetime.
|
|
47
|
+
WARNED_MODES = Set.new
|
|
48
|
+
WARNED_MODES_MUTEX = Mutex.new
|
|
49
|
+
private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
|
|
50
|
+
|
|
51
|
+
# Dispatches a single tool call asynchronously according to its
|
|
52
|
+
# +execution_mode+ and returns an awaitable.
|
|
53
|
+
#
|
|
54
|
+
# @param tool [Phronomy::Tool::Base] the tool instance to invoke
|
|
55
|
+
# @param args [Hash] argument hash to pass to {Tool::Base#call}
|
|
56
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
57
|
+
# @param runtime [Phronomy::Runtime] runtime to use for spawning
|
|
58
|
+
# (defaults to {Runtime.instance}; injectable for tests)
|
|
59
|
+
# @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
|
|
60
|
+
# @api private
|
|
61
|
+
def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
|
|
62
|
+
ct = cancellation_token
|
|
63
|
+
mode = tool.class.execution_mode
|
|
64
|
+
|
|
65
|
+
# Warn and normalise unsupported modes to :blocking_io.
|
|
66
|
+
# Each (tool class, mode) pair emits the warning at most once per process
|
|
67
|
+
# lifetime to avoid log flooding in high-throughput scenarios.
|
|
68
|
+
if mode == :cpu_bound || mode == :external_process
|
|
69
|
+
warn_key = [tool.class.name, mode]
|
|
70
|
+
newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
|
|
71
|
+
if newly_warned
|
|
72
|
+
msg = if mode == :cpu_bound
|
|
73
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
|
|
74
|
+
"which has no dedicated executor. " \
|
|
75
|
+
"Falling back to blocking_io (BlockingAdapterPool). " \
|
|
76
|
+
"Use :blocking_io explicitly to suppress this warning."
|
|
77
|
+
else
|
|
78
|
+
"[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
|
|
79
|
+
"which has no dedicated process manager. " \
|
|
80
|
+
"Falling back to blocking_io (BlockingAdapterPool)."
|
|
81
|
+
end
|
|
82
|
+
if Phronomy.configuration.logger
|
|
83
|
+
Phronomy.configuration.logger.warn(msg)
|
|
84
|
+
else
|
|
85
|
+
warn msg
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
mode = :blocking_io
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
pool = begin
|
|
92
|
+
runtime&.blocking_io
|
|
93
|
+
rescue
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
if mode == :cooperative || pool.nil?
|
|
98
|
+
runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
|
|
99
|
+
tool.call(args, cancellation_token: ct)
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
# Submit directly to pool — no wrapping Task thread required.
|
|
103
|
+
pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# A thread-safe FIFO queue for passing values between concurrent tasks.
|
|
6
|
+
#
|
|
7
|
+
# Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
|
|
8
|
+
# standard-library type directly. A future implementation may replace the
|
|
9
|
+
# backing primitive without changing call sites.
|
|
10
|
+
#
|
|
11
|
+
# @example Producer / consumer
|
|
12
|
+
# queue = Phronomy::Concurrency::AsyncQueue.new
|
|
13
|
+
# Runtime.instance.spawn { queue.push(expensive_io()) }
|
|
14
|
+
# value = queue.pop # blocks until the producer pushes
|
|
15
|
+
# @api private
|
|
16
|
+
class AsyncQueue
|
|
17
|
+
# @param max_size [Integer, nil] optional upper bound on queue depth.
|
|
18
|
+
# When set, {#push} blocks the caller until a slot is available.
|
|
19
|
+
# @api private
|
|
20
|
+
def initialize(max_size: nil)
|
|
21
|
+
@queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
|
|
22
|
+
@max_size = max_size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Enqueues +item+.
|
|
26
|
+
# In a cooperative scheduler context with a bounded queue (max_size:), suspends
|
|
27
|
+
# the current Fiber via a scheduler signal when the queue is full rather than
|
|
28
|
+
# blocking the OS thread. Without a scheduler, falls back to the standard
|
|
29
|
+
# SizedQueue blocking behaviour.
|
|
30
|
+
# @param item [Object] value to enqueue
|
|
31
|
+
# @return [self]
|
|
32
|
+
# @api private
|
|
33
|
+
def push(item)
|
|
34
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
35
|
+
if scheduler && @max_size
|
|
36
|
+
_push_cooperative(scheduler, item)
|
|
37
|
+
else
|
|
38
|
+
@queue.push(item)
|
|
39
|
+
scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Dequeues and returns the next item.
|
|
45
|
+
# In a cooperative scheduler context, suspends the current Fiber (yielding
|
|
46
|
+
# control back to the scheduler) rather than blocking the OS thread.
|
|
47
|
+
#
|
|
48
|
+
# When +timeout+ is given the semantics depend on the active backend:
|
|
49
|
+
#
|
|
50
|
+
# * **Thread backend** (`:thread`) — uses real wall-clock time via
|
|
51
|
+
# +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
|
|
52
|
+
# Returns +nil+ if no item arrives within the specified number of real seconds.
|
|
53
|
+
# * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
|
|
54
|
+
# *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
|
|
55
|
+
# the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
|
|
56
|
+
# In tests this means the timeout is fully deterministic and does not depend on
|
|
57
|
+
# actual elapsed wall time. However, in production `:fiber` mode the timeout
|
|
58
|
+
# may never expire unless the scheduler explicitly advances virtual time.
|
|
59
|
+
#
|
|
60
|
+
# @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
|
|
61
|
+
# in production workloads is not guaranteed and may differ from wall-clock
|
|
62
|
+
# expectations.
|
|
63
|
+
# @note **Cooperative timeout limitation**: on the cooperative path, the
|
|
64
|
+
# deadline is re-checked *after* a wake-up signal arrives. If virtual time
|
|
65
|
+
# has already passed the deadline when the consumer is woken by a producer
|
|
66
|
+
# push, the consumer returns +nil+ rather than the pushed item. Without any
|
|
67
|
+
# wake-up signal the waiting Fiber remains suspended even after
|
|
68
|
+
# +scheduler.advance+ — the timeout does not self-fire.
|
|
69
|
+
# @param timeout [Numeric, nil] seconds to wait before returning +nil+.
|
|
70
|
+
# Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
|
|
71
|
+
# @return [Object, nil] the next item, or +nil+ when timeout expires
|
|
72
|
+
# @api private
|
|
73
|
+
def pop(timeout: nil)
|
|
74
|
+
scheduler = Phronomy::Runtime::Scheduler.current
|
|
75
|
+
if scheduler
|
|
76
|
+
_pop_cooperative(scheduler, timeout: timeout)
|
|
77
|
+
elsif timeout
|
|
78
|
+
@queue.pop(timeout: timeout)
|
|
79
|
+
else
|
|
80
|
+
@queue.pop
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Returns the current number of items in the queue.
|
|
85
|
+
# @return [Integer]
|
|
86
|
+
# @api private
|
|
87
|
+
def size
|
|
88
|
+
@queue.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns +true+ when the queue contains no items.
|
|
92
|
+
# @return [Boolean]
|
|
93
|
+
# @api private
|
|
94
|
+
def empty?
|
|
95
|
+
@queue.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
|
|
99
|
+
# @return [self]
|
|
100
|
+
# @api private
|
|
101
|
+
def close
|
|
102
|
+
@queue.close
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
# Cooperative pop for DeterministicScheduler context.
|
|
109
|
+
# Suspends the current Fiber via the scheduler's signal mechanism rather than
|
|
110
|
+
# blocking the OS thread. Because cooperative mode is single-threaded, the
|
|
111
|
+
# empty?/pop pair is race-free (no other Fiber can run between the two calls).
|
|
112
|
+
# After dequeuing, notifies any push-waiter so that a backpressure-suspended
|
|
113
|
+
# producer can be unblocked.
|
|
114
|
+
# @api private
|
|
115
|
+
# @param scheduler [Runtime::Scheduler]
|
|
116
|
+
# @param timeout [Numeric, nil]
|
|
117
|
+
# @return [Object, nil]
|
|
118
|
+
def _pop_cooperative(scheduler, timeout:)
|
|
119
|
+
@coop_signal ||= scheduler.new_signal
|
|
120
|
+
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
121
|
+
|
|
122
|
+
loop do
|
|
123
|
+
unless @queue.empty?
|
|
124
|
+
item = @queue.pop(timeout: 0)
|
|
125
|
+
# Notify a push-waiter (bounded queue) that a slot opened up.
|
|
126
|
+
scheduler.raise_signal(@push_signal) if @push_signal
|
|
127
|
+
return item
|
|
128
|
+
end
|
|
129
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
130
|
+
scheduler.wait_for_signal(@coop_signal)
|
|
131
|
+
return nil if deadline && scheduler.virtual_time >= deadline
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Cooperative push for DeterministicScheduler context with a bounded queue.
|
|
136
|
+
# Suspends the current Fiber via a scheduler signal when the queue is full,
|
|
137
|
+
# rather than blocking the OS thread.
|
|
138
|
+
# @api private
|
|
139
|
+
# @param scheduler [Runtime::Scheduler]
|
|
140
|
+
# @param item [Object]
|
|
141
|
+
# @return [void]
|
|
142
|
+
def _push_cooperative(scheduler, item)
|
|
143
|
+
@push_signal ||= scheduler.new_signal
|
|
144
|
+
|
|
145
|
+
loop do
|
|
146
|
+
unless @queue.size >= @max_size
|
|
147
|
+
@queue.push(item)
|
|
148
|
+
# Notify any pop-waiter that an item is now available.
|
|
149
|
+
scheduler.raise_signal(@coop_signal) if @coop_signal
|
|
150
|
+
return
|
|
151
|
+
end
|
|
152
|
+
scheduler.wait_for_signal(@push_signal)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|