phronomy 0.7.1 → 0.9.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 +35 -45
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +11 -3
- data/benchmark/bench_regression.rb +11 -11
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +2 -2
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +268 -403
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
- data/lib/phronomy/agent/context/capability/base.rb +689 -0
- data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +108 -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 +43 -37
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/shared_state.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 -2
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/context.rb +2 -8
- 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/llm_context_window/assembler.rb +191 -0
- 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/{agent → multi_agent}/handoff.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +20 -6
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store/async_backend.rb +4 -4
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
- data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
- data/lib/phronomy/vector_store/in_memory.rb +12 -2
- data/lib/phronomy/vector_store/loader/base.rb +27 -0
- data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
- data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
- data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
- data/lib/phronomy/vector_store/pgvector.rb +2 -2
- data/lib/phronomy/vector_store/redis_search.rb +2 -2
- data/lib/phronomy/vector_store/splitter/base.rb +49 -0
- data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
- data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
- data/lib/phronomy/vector_store.rb +14 -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 +2 -0
- data/scripts/api_snapshot.rb +11 -9
- metadata +44 -46
- 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/assembler.rb +0 -143
- 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/embeddings.rb +0 -11
- 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/loader.rb +0 -13
- 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/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -644
- data/lib/phronomy/tool/scope_policy.rb +0 -50
- data/lib/phronomy/tool_executor.rb +0 -106
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Represents a bounded execution scope that owns a {CancellationToken} and
|
|
5
|
-
# optionally a {Deadline}.
|
|
6
|
-
#
|
|
7
|
-
# +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
|
|
8
|
-
# tool code. All work performed within a scope should observe the scope's
|
|
9
|
-
# token; when the scope is cancelled (explicitly or by deadline expiry) the
|
|
10
|
-
# token is cancelled and all child tasks that check it will stop.
|
|
11
|
-
#
|
|
12
|
-
# @example Time-bounded invocation
|
|
13
|
-
# scope = Phronomy::CancellationScope.new.deadline_in(30)
|
|
14
|
-
# result = scope.pop_queue(completion_queue) do
|
|
15
|
-
# raise Phronomy::TimeoutError, "timed out"
|
|
16
|
-
# end
|
|
17
|
-
#
|
|
18
|
-
# @example Explicit cancellation
|
|
19
|
-
# scope = Phronomy::CancellationScope.new
|
|
20
|
-
# Phronomy::Runtime.instance.spawn(name: "worker") do
|
|
21
|
-
# scope.token.raise_if_cancelled!
|
|
22
|
-
# # ... do work ...
|
|
23
|
-
# end
|
|
24
|
-
# scope.cancel! if some_condition
|
|
25
|
-
class CancellationScope
|
|
26
|
-
# @return [CancellationToken] the token owned by this scope
|
|
27
|
-
attr_reader :token
|
|
28
|
-
|
|
29
|
-
# @return [Deadline, nil] the deadline attached to this scope, if any
|
|
30
|
-
attr_reader :deadline
|
|
31
|
-
|
|
32
|
-
# @param parent_token [CancellationToken, nil] when provided, cancellation of
|
|
33
|
-
# the parent token is propagated to this scope's token via a callback
|
|
34
|
-
# (for explicit cancel) and/or the Runtime timer queue (for monotonic
|
|
35
|
-
# deadline expiry). No polling thread is spawned.
|
|
36
|
-
# @api private
|
|
37
|
-
def initialize(parent_token: nil)
|
|
38
|
-
@token = Phronomy::CancellationToken.new
|
|
39
|
-
@deadline = nil
|
|
40
|
-
|
|
41
|
-
if parent_token
|
|
42
|
-
# Propagate explicit cancel() from parent to child via callback.
|
|
43
|
-
parent_token.on_cancel { @token.cancel! }
|
|
44
|
-
|
|
45
|
-
# Propagate monotonic-deadline expiry from parent to child via the
|
|
46
|
-
# timer queue (avoids a polling thread).
|
|
47
|
-
remaining = parent_token.remaining_monotonic_seconds
|
|
48
|
-
if !remaining.nil?
|
|
49
|
-
if remaining <= 0
|
|
50
|
-
@token.cancel!
|
|
51
|
-
else
|
|
52
|
-
Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
|
|
53
|
-
@token.cancel!
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Attaches a deadline that will cancel this scope after +seconds+.
|
|
61
|
-
#
|
|
62
|
-
# @param seconds [Numeric] timeout duration
|
|
63
|
-
# @return [self]
|
|
64
|
-
# @api private
|
|
65
|
-
def deadline_in(seconds)
|
|
66
|
-
@deadline = Phronomy::Deadline.in(seconds)
|
|
67
|
-
@deadline.attach_to(@token)
|
|
68
|
-
self
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Cancels this scope immediately.
|
|
72
|
-
# @return [void]
|
|
73
|
-
# @api private
|
|
74
|
-
def cancel!
|
|
75
|
-
@token.cancel!
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Returns +true+ if this scope has been cancelled.
|
|
79
|
-
# @return [Boolean]
|
|
80
|
-
# @api private
|
|
81
|
-
def cancelled?
|
|
82
|
-
@token.cancelled?
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Returns the remaining time in seconds before the deadline expires,
|
|
86
|
-
# or +nil+ when no deadline is set.
|
|
87
|
-
# @return [Float, nil]
|
|
88
|
-
# @api private
|
|
89
|
-
def remaining_seconds
|
|
90
|
-
@deadline&.remaining_seconds
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Pops from +queue+ with a timeout derived from the attached deadline (or
|
|
94
|
-
# +fallback_timeout+ seconds when no deadline is set). If the pop times out,
|
|
95
|
-
# the scope is cancelled and the block is called (or a {TimeoutError} raised).
|
|
96
|
-
#
|
|
97
|
-
# @param queue [Phronomy::AsyncQueue] the queue to pop from
|
|
98
|
-
# @param fallback_timeout [Numeric, nil] used when no deadline is attached
|
|
99
|
-
# @yield called when the operation times out
|
|
100
|
-
# @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
|
|
101
|
-
# @return [Object] the popped value
|
|
102
|
-
# @api private
|
|
103
|
-
def pop_queue(queue, fallback_timeout: nil)
|
|
104
|
-
timeout = @deadline&.remaining_seconds || fallback_timeout
|
|
105
|
-
result = if timeout
|
|
106
|
-
queue.pop(timeout: timeout)
|
|
107
|
-
else
|
|
108
|
-
queue.pop
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
if result.nil?
|
|
112
|
-
cancel!
|
|
113
|
-
if block_given?
|
|
114
|
-
yield
|
|
115
|
-
else
|
|
116
|
-
raise Phronomy::TimeoutError, "CancellationScope timed out"
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
result
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Provides cooperative cancellation for agent invocations.
|
|
5
|
-
#
|
|
6
|
-
# Pass a token to an agent via +config: { cancellation_token: token }+.
|
|
7
|
-
# The agent checks the token before each LLM call and raises
|
|
8
|
-
# {Phronomy::CancellationError} when the token is cancelled or the
|
|
9
|
-
# optional deadline has passed.
|
|
10
|
-
#
|
|
11
|
-
# A token may be shared across multiple agent invocations and across threads;
|
|
12
|
-
# all access to internal state is protected by a Mutex.
|
|
13
|
-
#
|
|
14
|
-
# @example Explicit cancel from another thread
|
|
15
|
-
# token = Phronomy::CancellationToken.new
|
|
16
|
-
# Thread.new { sleep 5; token.cancel! }
|
|
17
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
18
|
-
#
|
|
19
|
-
# @example Hard deadline via monotonic clock (recommended)
|
|
20
|
-
# token = Phronomy::CancellationToken.timeout_after(30)
|
|
21
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
22
|
-
#
|
|
23
|
-
# @example Hard deadline via wall-clock (legacy)
|
|
24
|
-
# token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
|
|
25
|
-
# result = agent.invoke("...", config: { cancellation_token: token })
|
|
26
|
-
#
|
|
27
|
-
# @example Propagate to parallel workers
|
|
28
|
-
# token = Phronomy::CancellationToken.new
|
|
29
|
-
# orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
|
|
30
|
-
class CancellationToken
|
|
31
|
-
# Returns a new token that will expire after +seconds+ seconds, measured
|
|
32
|
-
# with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
|
|
33
|
-
# a token with +deadline: Time.now + seconds+, this factory is immune to NTP
|
|
34
|
-
# adjustments and DST transitions.
|
|
35
|
-
#
|
|
36
|
-
# @param seconds [Numeric] duration in seconds until the token expires.
|
|
37
|
-
# @return [CancellationToken]
|
|
38
|
-
# @api public
|
|
39
|
-
def self.timeout_after(seconds)
|
|
40
|
-
monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
|
|
41
|
-
new(monotonic_deadline: monotonic_deadline)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# @param deadline [Time, nil] optional wall-clock deadline; the token reports
|
|
45
|
-
# +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
|
|
46
|
-
# {.timeout_after} for duration-based cancellation.
|
|
47
|
-
# @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
|
|
48
|
-
# {.timeout_after}; prefer that factory method over passing this directly.
|
|
49
|
-
# @api public
|
|
50
|
-
def initialize(deadline: nil, monotonic_deadline: nil)
|
|
51
|
-
@cancelled = false
|
|
52
|
-
@deadline = deadline
|
|
53
|
-
@monotonic_deadline = monotonic_deadline
|
|
54
|
-
@mutex = Mutex.new
|
|
55
|
-
@cancel_callbacks = []
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
|
|
59
|
-
attr_reader :deadline
|
|
60
|
-
|
|
61
|
-
# Returns the remaining seconds until the monotonic deadline fires, or +nil+
|
|
62
|
-
# when no monotonic deadline is set. Returns 0.0 if already past.
|
|
63
|
-
# @return [Float, nil]
|
|
64
|
-
# @api public
|
|
65
|
-
def remaining_monotonic_seconds
|
|
66
|
-
return nil if @monotonic_deadline.nil?
|
|
67
|
-
remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
68
|
-
[remaining, 0.0].max
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Registers a one-shot callback invoked when this token is explicitly
|
|
72
|
-
# cancelled via {#cancel!}. If the token is already cancelled, the block
|
|
73
|
-
# is called immediately (still within the caller's thread).
|
|
74
|
-
#
|
|
75
|
-
# Callbacks are NOT fired for deadline-based cancellation (i.e. when
|
|
76
|
-
# {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
|
|
77
|
-
# {Runtime#timer_queue} to schedule deadline callbacks.
|
|
78
|
-
#
|
|
79
|
-
# @yield called with no arguments when (or if) the token is cancelled
|
|
80
|
-
# @return [self]
|
|
81
|
-
# @api public
|
|
82
|
-
def on_cancel(&block)
|
|
83
|
-
already_cancelled = @mutex.synchronize do
|
|
84
|
-
if @cancelled
|
|
85
|
-
true
|
|
86
|
-
else
|
|
87
|
-
@cancel_callbacks << block
|
|
88
|
-
false
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
block.call if already_cancelled
|
|
92
|
-
self
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
|
|
96
|
-
# Thread-safe; idempotent — calling multiple times has no additional effect.
|
|
97
|
-
# @return [self]
|
|
98
|
-
# @api public
|
|
99
|
-
def cancel!
|
|
100
|
-
callbacks = @mutex.synchronize do
|
|
101
|
-
return self if @cancelled
|
|
102
|
-
@cancelled = true
|
|
103
|
-
@cancel_callbacks.dup
|
|
104
|
-
end
|
|
105
|
-
callbacks.each(&:call)
|
|
106
|
-
self
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
# Returns +true+ when the token has been explicitly cancelled via {#cancel!},
|
|
110
|
-
# when the wall-clock deadline has passed, or when the monotonic deadline
|
|
111
|
-
# (set by {.timeout_after}) has elapsed. Thread-safe.
|
|
112
|
-
# @return [Boolean]
|
|
113
|
-
# @api public
|
|
114
|
-
def cancelled?
|
|
115
|
-
return true if @mutex.synchronize { @cancelled }
|
|
116
|
-
return true if !@deadline.nil? && Time.now >= @deadline
|
|
117
|
-
!@monotonic_deadline.nil? &&
|
|
118
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Raises {Phronomy::CancellationError} if the token is cancelled.
|
|
122
|
-
# A convenience method for cooperative cancellation checks inside tools,
|
|
123
|
-
# RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
|
|
124
|
-
#
|
|
125
|
-
# @param message [String] optional error message
|
|
126
|
-
# @return [nil] when the token is not cancelled
|
|
127
|
-
# @raise [Phronomy::CancellationError] when the token is cancelled
|
|
128
|
-
# @api public
|
|
129
|
-
def raise_if_cancelled!(message = "invocation cancelled")
|
|
130
|
-
raise Phronomy::CancellationError, message if cancelled?
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
end
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# A counting semaphore that enforces a concurrency cap across a named
|
|
5
|
-
# resource category (e.g. agent tasks, tool tasks, LLM calls).
|
|
6
|
-
#
|
|
7
|
-
# When +max_concurrent+ is +nil+ the gate is a no-op and all callers
|
|
8
|
-
# pass through immediately without acquiring a slot.
|
|
9
|
-
#
|
|
10
|
-
# Backpressure behaviour when the gate is full is controlled by the
|
|
11
|
-
# +on_full:+ keyword:
|
|
12
|
-
# +:reject+ — raise {Phronomy::BackpressureError} immediately
|
|
13
|
-
# +:wait+ — block the calling fiber/thread until a slot is free
|
|
14
|
-
# +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
|
|
15
|
-
# after +timeout:+ seconds if no slot becomes available
|
|
16
|
-
#
|
|
17
|
-
# @example
|
|
18
|
-
# gate = Phronomy::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
|
|
19
|
-
# gate.acquire(on_full: :reject) do
|
|
20
|
-
# run_agent_task
|
|
21
|
-
# end
|
|
22
|
-
class ConcurrencyGate
|
|
23
|
-
# @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
|
|
24
|
-
# @param name [Symbol, String, nil] human-readable label used in error messages
|
|
25
|
-
# @api private
|
|
26
|
-
def initialize(max_concurrent:, name: nil)
|
|
27
|
-
@max = max_concurrent
|
|
28
|
-
@name = name
|
|
29
|
-
@mutex = Mutex.new
|
|
30
|
-
@cond = ConditionVariable.new
|
|
31
|
-
@count = 0
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Returns the configured cap (or nil when unlimited).
|
|
35
|
-
attr_reader :max
|
|
36
|
-
|
|
37
|
-
# Returns the name label.
|
|
38
|
-
attr_reader :name
|
|
39
|
-
|
|
40
|
-
# Returns the number of slots currently in use.
|
|
41
|
-
def current_count
|
|
42
|
-
@mutex.synchronize { @count }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Acquires a slot, executes +block+, then releases the slot.
|
|
46
|
-
# When the gate is unlimited (max is nil) the block runs directly.
|
|
47
|
-
#
|
|
48
|
-
# @param on_full [:reject, :wait, :timeout] backpressure strategy
|
|
49
|
-
# @param timeout [Numeric, nil] seconds before +:timeout+ gives up
|
|
50
|
-
# @yield
|
|
51
|
-
# @return block return value
|
|
52
|
-
# @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
|
|
53
|
-
# @api private
|
|
54
|
-
def acquire(on_full: :wait, timeout: nil, &block)
|
|
55
|
-
return block.call if @max.nil?
|
|
56
|
-
|
|
57
|
-
_acquire_slot(on_full: on_full, timeout: timeout)
|
|
58
|
-
begin
|
|
59
|
-
block.call
|
|
60
|
-
ensure
|
|
61
|
-
_release_slot
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
private
|
|
66
|
-
|
|
67
|
-
def _acquire_slot(on_full:, timeout:)
|
|
68
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
69
|
-
if scheduler
|
|
70
|
-
_acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
|
|
71
|
-
else
|
|
72
|
-
_acquire_slot_threaded(on_full: on_full, timeout: timeout)
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def _acquire_slot_coop(scheduler, on_full:, timeout:)
|
|
77
|
-
# In cooperative mode all tasks run on the same thread, so no mutex needed.
|
|
78
|
-
deadline = timeout ? (scheduler.virtual_time + timeout) : nil
|
|
79
|
-
@coop_signal ||= scheduler.new_signal
|
|
80
|
-
|
|
81
|
-
loop do
|
|
82
|
-
if @count < @max
|
|
83
|
-
@count += 1
|
|
84
|
-
return
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
case on_full
|
|
88
|
-
when :reject
|
|
89
|
-
raise Phronomy::BackpressureError,
|
|
90
|
-
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
91
|
-
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
92
|
-
when :timeout
|
|
93
|
-
if deadline && scheduler.virtual_time >= deadline
|
|
94
|
-
raise Phronomy::BackpressureError,
|
|
95
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
96
|
-
end
|
|
97
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
98
|
-
if deadline && scheduler.virtual_time >= deadline
|
|
99
|
-
raise Phronomy::BackpressureError,
|
|
100
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
101
|
-
end
|
|
102
|
-
else # :wait
|
|
103
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def _acquire_slot_threaded(on_full:, timeout:)
|
|
109
|
-
deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
|
|
110
|
-
|
|
111
|
-
@mutex.synchronize do
|
|
112
|
-
loop do
|
|
113
|
-
if @count < @max
|
|
114
|
-
@count += 1
|
|
115
|
-
return
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
case on_full
|
|
119
|
-
when :reject
|
|
120
|
-
raise Phronomy::BackpressureError,
|
|
121
|
-
"ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
|
|
122
|
-
"increase max_concurrent_#{@name}_tasks or retry later"
|
|
123
|
-
when :timeout
|
|
124
|
-
remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
|
|
125
|
-
if remaining && remaining <= 0
|
|
126
|
-
raise Phronomy::BackpressureError,
|
|
127
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
128
|
-
end
|
|
129
|
-
@cond.wait(@mutex, remaining || nil)
|
|
130
|
-
# re-check deadline after wakeup
|
|
131
|
-
if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
132
|
-
raise Phronomy::BackpressureError,
|
|
133
|
-
"ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
|
|
134
|
-
end
|
|
135
|
-
else # :wait
|
|
136
|
-
@cond.wait(@mutex)
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def _release_slot
|
|
143
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
144
|
-
if scheduler && @coop_signal
|
|
145
|
-
@count -= 1
|
|
146
|
-
scheduler.raise_signal(@coop_signal)
|
|
147
|
-
else
|
|
148
|
-
@mutex.synchronize do
|
|
149
|
-
@count -= 1
|
|
150
|
-
@cond.signal
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module Context
|
|
7
|
-
# Assembler collects all four context regions and produces the final
|
|
8
|
-
# {system:, messages:} hash consumed by Agent::Base.
|
|
9
|
-
#
|
|
10
|
-
# Regions:
|
|
11
|
-
# 1. Instruction — system prompt text set via #add_instruction
|
|
12
|
-
# 2. Capability — tool definitions (handled by RubyLLM, not here)
|
|
13
|
-
# 3. Knowledge — external facts injected via #add_knowledge (generates XML tags)
|
|
14
|
-
# 4. Conversation — historical messages added via #add_messages
|
|
15
|
-
#
|
|
16
|
-
# Token budgeting:
|
|
17
|
-
# When a budget is given, conversation messages are trimmed from oldest to
|
|
18
|
-
# newest until they fit. Knowledge chunks are always included in full (they
|
|
19
|
-
# are assumed to be pre-screened by the caller). When no budget is given all
|
|
20
|
-
# messages are passed through unchanged.
|
|
21
|
-
#
|
|
22
|
-
# @example
|
|
23
|
-
# assembler = Phronomy::Context::Assembler.new(budget: budget)
|
|
24
|
-
# assembler.add_instruction("You are a helpful assistant.")
|
|
25
|
-
# assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
|
|
26
|
-
# assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
|
|
27
|
-
# context = assembler.build
|
|
28
|
-
# # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
|
|
29
|
-
class Assembler
|
|
30
|
-
# Builds a single XML context tag string.
|
|
31
|
-
# Exposed as a class method so callers (e.g. Agent::Base) can build
|
|
32
|
-
# static knowledge XML tags independently of an Assembler instance.
|
|
33
|
-
#
|
|
34
|
-
# @param text [String]
|
|
35
|
-
# @param type [Symbol, String]
|
|
36
|
-
# @param trusted [Boolean]
|
|
37
|
-
# @return [String]
|
|
38
|
-
# @api private
|
|
39
|
-
def self.xml_tag(text, type:, trusted: false)
|
|
40
|
-
"<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# @param budget [Phronomy::Context::TokenBudget, nil]
|
|
44
|
-
# when nil no token trimming is performed
|
|
45
|
-
# @api private
|
|
46
|
-
def initialize(budget: nil)
|
|
47
|
-
@budget = budget
|
|
48
|
-
@instruction = nil
|
|
49
|
-
@knowledge_chunks = []
|
|
50
|
-
@messages = []
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Set the system instruction text (Region 1).
|
|
54
|
-
# Calling this multiple times replaces the previous value.
|
|
55
|
-
#
|
|
56
|
-
# @param text [String]
|
|
57
|
-
# @return [self]
|
|
58
|
-
# @api private
|
|
59
|
-
def add_instruction(text)
|
|
60
|
-
@instruction = text.to_s
|
|
61
|
-
self
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Append a knowledge chunk (Region 3).
|
|
65
|
-
# The chunk is wrapped in an XML context tag automatically.
|
|
66
|
-
#
|
|
67
|
-
# @param text [String]
|
|
68
|
-
# @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
|
|
69
|
-
# @param trusted [Boolean] false (default) indicates externally sourced data
|
|
70
|
-
# @param source [String, nil] optional source label (e.g. filename); included in the
|
|
71
|
-
# XML tag so the LLM can produce grounded citations. Omitted when nil.
|
|
72
|
-
# @return [self]
|
|
73
|
-
# @api private
|
|
74
|
-
def add_knowledge(text, type:, trusted: false, source: nil)
|
|
75
|
-
@knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
|
|
76
|
-
self
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Set conversation messages (Region 4). Replaces any previously set messages.
|
|
80
|
-
#
|
|
81
|
-
# @param messages [Array] message-like objects with #role and #content
|
|
82
|
-
# @return [self]
|
|
83
|
-
# @api private
|
|
84
|
-
def add_messages(messages)
|
|
85
|
-
@messages = Array(messages)
|
|
86
|
-
self
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Assemble the context.
|
|
90
|
-
#
|
|
91
|
-
# @return [Hash{Symbol => Object}]
|
|
92
|
-
# :system [String, nil] combined system prompt (instruction + knowledge XML tags)
|
|
93
|
-
# :messages [Array] conversation messages, trimmed to budget if set
|
|
94
|
-
# @api private
|
|
95
|
-
def build
|
|
96
|
-
knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
|
|
97
|
-
system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
|
|
98
|
-
system_text = system_parts.join("\n\n")
|
|
99
|
-
|
|
100
|
-
messages = if @budget
|
|
101
|
-
trim_messages_to_budget(@messages, system_text)
|
|
102
|
-
else
|
|
103
|
-
@messages
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
{
|
|
107
|
-
system: system_text.empty? ? nil : system_text,
|
|
108
|
-
messages: messages
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
private
|
|
113
|
-
|
|
114
|
-
def xml_context_tag(chunk)
|
|
115
|
-
src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
|
|
116
|
-
"<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def trim_messages_to_budget(messages, system_text)
|
|
120
|
-
used = TokenEstimator.estimate(system_text)
|
|
121
|
-
remaining = @budget.available(used: used)
|
|
122
|
-
return messages if remaining <= 0 && messages.empty?
|
|
123
|
-
|
|
124
|
-
accumulated = 0
|
|
125
|
-
result = []
|
|
126
|
-
messages.reverse_each do |msg|
|
|
127
|
-
tokens = TokenEstimator.estimate(msg.content.to_s)
|
|
128
|
-
break if accumulated + tokens > remaining
|
|
129
|
-
|
|
130
|
-
accumulated += tokens
|
|
131
|
-
result.push(msg)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
if result.empty? && messages.any?
|
|
135
|
-
warn "[Phronomy::Assembler] All #{messages.length} conversation message(s) dropped: " \
|
|
136
|
-
"token budget exhausted by system context (budget=#{@budget.context_window}, used_by_system=#{used})"
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
result.reverse
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
end
|
|
143
|
-
end
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Context
|
|
5
|
-
# Context object passed to the +on_compact+ callback registered on an agent.
|
|
6
|
-
#
|
|
7
|
-
# The callback calls #compact one or more times to specify which ranges of
|
|
8
|
-
# messages to replace with a summary. Each call:
|
|
9
|
-
# 1. Yields the selected message elements to the block.
|
|
10
|
-
# 2. Receives the block's return value as the summary text.
|
|
11
|
-
# 3. Persists a compaction record to the memory store (if available).
|
|
12
|
-
# 4. Updates #result_messages so that the compacted range is replaced
|
|
13
|
-
# by a single +:system+ summary message.
|
|
14
|
-
#
|
|
15
|
-
# The agent reads #result_messages after the callback returns and uses it
|
|
16
|
-
# as the new message list for this invocation.
|
|
17
|
-
#
|
|
18
|
-
# @example Summarise the oldest half of the conversation
|
|
19
|
-
# on_compact do |ctx|
|
|
20
|
-
# half = ctx.message_elements.length / 2
|
|
21
|
-
# ctx.compact(0...half) do |elements|
|
|
22
|
-
# texts = elements.map { |e| "#{e[:role]}: #{e[:message].content}" }.join("\n")
|
|
23
|
-
# "Summary of earlier conversation:\n#{texts}"
|
|
24
|
-
# end
|
|
25
|
-
# end
|
|
26
|
-
class CompactionContext
|
|
27
|
-
# @return [Array<Hash>] message elements at compaction time
|
|
28
|
-
attr_reader :message_elements
|
|
29
|
-
|
|
30
|
-
# @return [Phronomy::Context::TokenBudget, nil]
|
|
31
|
-
attr_reader :budget
|
|
32
|
-
|
|
33
|
-
# @return [Integer] total estimated token count before compaction
|
|
34
|
-
attr_reader :total_tokens
|
|
35
|
-
|
|
36
|
-
# The current message list to be used after all compact calls have been made.
|
|
37
|
-
# Updated by each call to #compact.
|
|
38
|
-
#
|
|
39
|
-
# @return [Array]
|
|
40
|
-
attr_reader :result_messages
|
|
41
|
-
|
|
42
|
-
# @param message_elements [Array<Hash>]
|
|
43
|
-
# each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
|
|
44
|
-
# @param budget [Phronomy::Context::TokenBudget, nil]
|
|
45
|
-
# @param thread_id [String, nil] used when saving compaction records
|
|
46
|
-
# @param memory [Object, nil] memory object; must respond to #save_compaction
|
|
47
|
-
# for compaction records to be persisted
|
|
48
|
-
# @api private
|
|
49
|
-
def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
|
|
50
|
-
@message_elements = message_elements.dup
|
|
51
|
-
@budget = budget
|
|
52
|
-
@total_tokens = message_elements.sum { |e| e[:tokens] }
|
|
53
|
-
@thread_id = thread_id
|
|
54
|
-
@memory = memory
|
|
55
|
-
@result_messages = @message_elements.map { |e| e[:message] }
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Replace a range of messages with a summary produced by the block.
|
|
59
|
-
#
|
|
60
|
-
# The block receives the selected Array<Hash> elements and must return a
|
|
61
|
-
# String that serves as the summary text. After the call, #result_messages
|
|
62
|
-
# reflects the replacement.
|
|
63
|
-
#
|
|
64
|
-
# If the memory object responds to #save_compaction, a compaction record
|
|
65
|
-
# { start_seq:, end_seq:, summary_text: } is persisted for auditability.
|
|
66
|
-
#
|
|
67
|
-
# @param range [Range, Integer] index range into message_elements (0-based)
|
|
68
|
-
# @yieldparam elements [Array<Hash>] the selected message elements
|
|
69
|
-
# @yieldreturn [String] summary text to replace the selected messages
|
|
70
|
-
# @return [Array] the updated result_messages array
|
|
71
|
-
# @api private
|
|
72
|
-
def compact(range)
|
|
73
|
-
# Normalise: Integer index → single-element Array; Range → Array slice.
|
|
74
|
-
raw = @message_elements[range]
|
|
75
|
-
elements = if raw.is_a?(Array)
|
|
76
|
-
raw
|
|
77
|
-
elsif raw.nil?
|
|
78
|
-
[]
|
|
79
|
-
else
|
|
80
|
-
[raw]
|
|
81
|
-
end
|
|
82
|
-
return @result_messages if elements.empty?
|
|
83
|
-
|
|
84
|
-
summary_text = yield(elements).to_s
|
|
85
|
-
|
|
86
|
-
start_seq = elements.first[:seq]
|
|
87
|
-
end_seq = elements.last[:seq]
|
|
88
|
-
|
|
89
|
-
if @memory && @thread_id && @memory.respond_to?(:save_compaction)
|
|
90
|
-
@memory.save_compaction(
|
|
91
|
-
thread_id: @thread_id,
|
|
92
|
-
start_seq: start_seq,
|
|
93
|
-
end_seq: end_seq,
|
|
94
|
-
summary_text: summary_text
|
|
95
|
-
)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Compute the last included index in the original @message_elements array.
|
|
99
|
-
last_idx = if range.is_a?(Range)
|
|
100
|
-
range.exclude_end? ? range.last - 1 : range.last
|
|
101
|
-
else
|
|
102
|
-
range.to_i
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
remaining = (@message_elements[(last_idx + 1)..] || []).map { |e| e[:message] }
|
|
106
|
-
summary_msg = RubyLLM::Message.new(role: :system, content: summary_text)
|
|
107
|
-
@result_messages = [summary_msg] + remaining
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|