phronomy 0.6.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 +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- 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 +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- 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 +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- 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/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- 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/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -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/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
|
@@ -0,0 +1,123 @@
|
|
|
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
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
|
@@ -33,16 +33,182 @@ module Phronomy
|
|
|
33
33
|
# @see Phronomy::EventLoop
|
|
34
34
|
attr_accessor :event_loop
|
|
35
35
|
|
|
36
|
-
# When true
|
|
36
|
+
# When true, user input and LLM output are recorded in trace spans.
|
|
37
|
+
# Defaults to false; set to true only in environments where PII capture is acceptable.
|
|
37
38
|
# Set to false in privacy-sensitive environments to prevent PII from reaching
|
|
38
39
|
# the tracing backend (OTel, Langfuse, etc.).
|
|
39
40
|
attr_accessor :trace_pii
|
|
40
41
|
|
|
42
|
+
# Optional logger for framework diagnostic messages (e.g. unreachable-state warnings).
|
|
43
|
+
# Must respond to +#warn(message)+. When nil (default), messages are written to +$stderr+
|
|
44
|
+
# via +Kernel#warn+.
|
|
45
|
+
# @example
|
|
46
|
+
# Phronomy.configure { |c| c.logger = Rails.logger }
|
|
47
|
+
attr_accessor :logger
|
|
48
|
+
|
|
49
|
+
# Grace period (in seconds) before the EventLoop background thread is force-killed
|
|
50
|
+
# after a cooperative stop request. Applies both to the overall thread join
|
|
51
|
+
# and to the drain-and-cancel phase when +stop(drain: true)+ is used.
|
|
52
|
+
# Default: 5 seconds.
|
|
53
|
+
# @see Phronomy::EventLoop#stop
|
|
54
|
+
attr_accessor :event_loop_stop_grace_seconds
|
|
55
|
+
|
|
56
|
+
# Global state store for workflow persistence.
|
|
57
|
+
# When set, WorkflowRunner routes all state reads and writes through this store.
|
|
58
|
+
# Must be an instance of a class that inherits from Phronomy::StateStore::Base.
|
|
59
|
+
# Defaults to +nil+ (no persistence — state lives only for the duration of invoke).
|
|
60
|
+
# @example
|
|
61
|
+
# Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
|
|
62
|
+
attr_accessor :state_store
|
|
63
|
+
|
|
64
|
+
# Maximum byte length of a tool result returned to the LLM.
|
|
65
|
+
# When a tool returns a String longer than this limit, the string is truncated
|
|
66
|
+
# and a warning is logged. Set to +nil+ (default) to disable truncation.
|
|
67
|
+
# @example
|
|
68
|
+
# Phronomy.configure { |c| c.tool_result_max_size = 8192 }
|
|
69
|
+
attr_accessor :tool_result_max_size
|
|
70
|
+
|
|
71
|
+
# LLM adapter used by Agent::Base to perform LLM calls.
|
|
72
|
+
# Must be an instance of a class that inherits from
|
|
73
|
+
# {Phronomy::LLMAdapter::Base}. Defaults to
|
|
74
|
+
# {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
|
|
75
|
+
# {BlockingAdapterPool}.
|
|
76
|
+
# Set to a custom adapter to swap in an alternative LLM client without
|
|
77
|
+
# changing any agent code.
|
|
78
|
+
# @example
|
|
79
|
+
# Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
|
|
80
|
+
attr_accessor :llm_adapter
|
|
81
|
+
|
|
82
|
+
# Default backpressure strategy for {BlockingAdapterPool#submit} when the
|
|
83
|
+
# queue is full. One of +:wait+ (block until a slot is available),
|
|
84
|
+
# +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
|
|
85
|
+
# {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
|
|
86
|
+
# @return [:wait, :raise, :timeout]
|
|
87
|
+
attr_accessor :backpressure
|
|
88
|
+
|
|
89
|
+
# Seconds to wait before raising {Phronomy::TimeoutError} when
|
|
90
|
+
# +backpressure+ is +:timeout+.
|
|
91
|
+
# @return [Numeric, nil]
|
|
92
|
+
attr_accessor :backpressure_timeout
|
|
93
|
+
|
|
94
|
+
# Warn when an event spends longer than this many seconds waiting in the
|
|
95
|
+
# EventLoop queue before being dispatched (starvation detection).
|
|
96
|
+
# Set to +nil+ to disable the warning.
|
|
97
|
+
# @return [Numeric, nil]
|
|
98
|
+
attr_accessor :event_loop_starvation_threshold_seconds
|
|
99
|
+
|
|
100
|
+
# Warn when processing a single event on the EventLoop thread takes longer
|
|
101
|
+
# than this many seconds (long-running task / blocking-on-loop detection).
|
|
102
|
+
# Set to +nil+ to disable the warning.
|
|
103
|
+
# @return [Numeric, nil]
|
|
104
|
+
attr_accessor :event_loop_dispatch_threshold_seconds
|
|
105
|
+
|
|
106
|
+
# When true, enables all blocking operation diagnostics (Issue #279).
|
|
107
|
+
# Equivalent to setting all diagnostic thresholds to their defaults.
|
|
108
|
+
# @return [Boolean]
|
|
109
|
+
attr_accessor :scheduler_debug
|
|
110
|
+
|
|
111
|
+
# Wall-clock threshold (milliseconds) after which a task that has not
|
|
112
|
+
# yielded the scheduler emits a warning log. nil disables the check.
|
|
113
|
+
# @return [Float, nil]
|
|
114
|
+
attr_accessor :blocking_detect_threshold_ms
|
|
115
|
+
|
|
116
|
+
# Maximum number of concurrent agent tasks (invoke_async calls in-flight).
|
|
117
|
+
# nil = unlimited (default). When at capacity, behaviour is controlled by
|
|
118
|
+
# +backpressure+ (:wait, :raise/:reject, :timeout).
|
|
119
|
+
# @return [Integer, nil]
|
|
120
|
+
attr_accessor :max_concurrent_agent_tasks
|
|
121
|
+
|
|
122
|
+
# Maximum number of concurrent tool tasks (parallel tool calls in-flight).
|
|
123
|
+
# nil = unlimited (default).
|
|
124
|
+
# @return [Integer, nil]
|
|
125
|
+
attr_accessor :max_concurrent_tool_tasks
|
|
126
|
+
|
|
127
|
+
# Maximum number of concurrent workflow tasks.
|
|
128
|
+
# nil = unlimited (default).
|
|
129
|
+
# @return [Integer, nil]
|
|
130
|
+
attr_accessor :max_concurrent_workflow_tasks
|
|
131
|
+
|
|
132
|
+
# Maximum number of concurrent LLM calls in-flight.
|
|
133
|
+
# nil = unlimited (default).
|
|
134
|
+
# @return [Integer, nil]
|
|
135
|
+
attr_accessor :max_concurrent_llm_calls
|
|
136
|
+
|
|
137
|
+
# Upper bound on the number of streaming token chunks that may be buffered
|
|
138
|
+
# in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
|
|
139
|
+
# throttled. When nil (default), the queue is unbounded.
|
|
140
|
+
# @return [Integer, nil]
|
|
141
|
+
attr_accessor :stream_queue_max_size
|
|
142
|
+
|
|
143
|
+
# Maximum number of concurrent RAG knowledge-source fetches in-flight.
|
|
144
|
+
# nil = unlimited (default).
|
|
145
|
+
# @return [Integer, nil]
|
|
146
|
+
attr_accessor :max_concurrent_rag_fetches
|
|
147
|
+
|
|
148
|
+
# Maximum number of concurrent vector-store searches in-flight.
|
|
149
|
+
# nil = unlimited (default).
|
|
150
|
+
# @return [Integer, nil]
|
|
151
|
+
attr_accessor :max_concurrent_vector_searches
|
|
152
|
+
|
|
153
|
+
# Scheduler starvation threshold (milliseconds).
|
|
154
|
+
# When a task waits more than this many milliseconds after calling
|
|
155
|
+
# +runtime.yield+ before being resumed, the wait is counted as a starvation
|
|
156
|
+
# event. Used by the fairness regression test and by the
|
|
157
|
+
# +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
|
|
158
|
+
# Default: 50ms.
|
|
159
|
+
# @return [Numeric]
|
|
160
|
+
attr_accessor :starvation_threshold_ms
|
|
161
|
+
|
|
162
|
+
# Scheduler backend to use for new {Phronomy::Runtime} instances.
|
|
163
|
+
#
|
|
164
|
+
# | Value | Scheduler | Typical use |
|
|
165
|
+
# |-------|-----------|-------------|
|
|
166
|
+
# | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
|
|
167
|
+
# | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
|
|
168
|
+
# | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
|
|
169
|
+
# | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
|
|
170
|
+
#
|
|
171
|
+
# The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
|
|
172
|
+
# it will not become the default until integration test coverage is production grade
|
|
173
|
+
# and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
|
|
174
|
+
#
|
|
175
|
+
# When this setting is changed, the change only takes effect on the NEXT
|
|
176
|
+
# call to {Runtime.instance} that auto-creates a new instance (i.e. after the
|
|
177
|
+
# previous instance has been replaced or reset). To replace the current
|
|
178
|
+
# instance immediately call +Phronomy::Runtime.instance = nil+ first.
|
|
179
|
+
#
|
|
180
|
+
# @return [:thread, :immediate, :fiber]
|
|
181
|
+
attr_accessor :runtime_backend
|
|
182
|
+
|
|
183
|
+
# When +true+, calling {Agent#invoke} from inside a scheduler task
|
|
184
|
+
# raises {SchedulerReentrancyError}. When +false+ (default), a warning
|
|
185
|
+
# is logged instead so that existing callers have time to migrate.
|
|
186
|
+
# @return [Boolean]
|
|
187
|
+
attr_accessor :strict_runtime_guards
|
|
188
|
+
|
|
41
189
|
def initialize
|
|
42
190
|
@recursion_limit = 25
|
|
43
191
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
44
|
-
@trace_pii =
|
|
192
|
+
@trace_pii = false
|
|
45
193
|
@event_loop = false
|
|
194
|
+
@event_loop_stop_grace_seconds = 5
|
|
195
|
+
@llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
196
|
+
@backpressure = :wait
|
|
197
|
+
@backpressure_timeout = nil
|
|
198
|
+
@event_loop_starvation_threshold_seconds = nil
|
|
199
|
+
@event_loop_dispatch_threshold_seconds = nil
|
|
200
|
+
@scheduler_debug = false
|
|
201
|
+
@blocking_detect_threshold_ms = nil
|
|
202
|
+
@max_concurrent_agent_tasks = nil
|
|
203
|
+
@max_concurrent_tool_tasks = nil
|
|
204
|
+
@max_concurrent_workflow_tasks = nil
|
|
205
|
+
@max_concurrent_llm_calls = nil
|
|
206
|
+
@stream_queue_max_size = nil
|
|
207
|
+
@max_concurrent_rag_fetches = nil
|
|
208
|
+
@max_concurrent_vector_searches = nil
|
|
209
|
+
@starvation_threshold_ms = 50
|
|
210
|
+
@runtime_backend = :thread
|
|
211
|
+
@strict_runtime_guards = false
|
|
46
212
|
end
|
|
47
213
|
end
|
|
48
214
|
end
|
|
@@ -35,12 +35,14 @@ module Phronomy
|
|
|
35
35
|
# @param type [Symbol, String]
|
|
36
36
|
# @param trusted [Boolean]
|
|
37
37
|
# @return [String]
|
|
38
|
+
# @api private
|
|
38
39
|
def self.xml_tag(text, type:, trusted: false)
|
|
39
40
|
"<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
# @param budget [Phronomy::Context::TokenBudget, nil]
|
|
43
44
|
# when nil no token trimming is performed
|
|
45
|
+
# @api private
|
|
44
46
|
def initialize(budget: nil)
|
|
45
47
|
@budget = budget
|
|
46
48
|
@instruction = nil
|
|
@@ -53,6 +55,7 @@ module Phronomy
|
|
|
53
55
|
#
|
|
54
56
|
# @param text [String]
|
|
55
57
|
# @return [self]
|
|
58
|
+
# @api private
|
|
56
59
|
def add_instruction(text)
|
|
57
60
|
@instruction = text.to_s
|
|
58
61
|
self
|
|
@@ -67,6 +70,7 @@ module Phronomy
|
|
|
67
70
|
# @param source [String, nil] optional source label (e.g. filename); included in the
|
|
68
71
|
# XML tag so the LLM can produce grounded citations. Omitted when nil.
|
|
69
72
|
# @return [self]
|
|
73
|
+
# @api private
|
|
70
74
|
def add_knowledge(text, type:, trusted: false, source: nil)
|
|
71
75
|
@knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
|
|
72
76
|
self
|
|
@@ -76,6 +80,7 @@ module Phronomy
|
|
|
76
80
|
#
|
|
77
81
|
# @param messages [Array] message-like objects with #role and #content
|
|
78
82
|
# @return [self]
|
|
83
|
+
# @api private
|
|
79
84
|
def add_messages(messages)
|
|
80
85
|
@messages = Array(messages)
|
|
81
86
|
self
|
|
@@ -86,6 +91,7 @@ module Phronomy
|
|
|
86
91
|
# @return [Hash{Symbol => Object}]
|
|
87
92
|
# :system [String, nil] combined system prompt (instruction + knowledge XML tags)
|
|
88
93
|
# :messages [Array] conversation messages, trimmed to budget if set
|
|
94
|
+
# @api private
|
|
89
95
|
def build
|
|
90
96
|
knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
|
|
91
97
|
system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
|
|
@@ -45,6 +45,7 @@ module Phronomy
|
|
|
45
45
|
# @param thread_id [String, nil] used when saving compaction records
|
|
46
46
|
# @param memory [Object, nil] memory object; must respond to #save_compaction
|
|
47
47
|
# for compaction records to be persisted
|
|
48
|
+
# @api private
|
|
48
49
|
def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
|
|
49
50
|
@message_elements = message_elements.dup
|
|
50
51
|
@budget = budget
|
|
@@ -67,6 +68,7 @@ module Phronomy
|
|
|
67
68
|
# @yieldparam elements [Array<Hash>] the selected message elements
|
|
68
69
|
# @yieldreturn [String] summary text to replace the selected messages
|
|
69
70
|
# @return [Array] the updated result_messages array
|
|
71
|
+
# @api private
|
|
70
72
|
def compact(range)
|
|
71
73
|
# Normalise: Integer index → single-element Array; Range → Array slice.
|
|
72
74
|
raw = @message_elements[range]
|
|
@@ -25,6 +25,7 @@ module Phronomy
|
|
|
25
25
|
#
|
|
26
26
|
# @param fingerprint [String] SHA-256 hex digest to compare
|
|
27
27
|
# @return [Boolean]
|
|
28
|
+
# @api private
|
|
28
29
|
def valid?(fingerprint)
|
|
29
30
|
!@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
|
|
30
31
|
end
|
|
@@ -33,6 +34,7 @@ module Phronomy
|
|
|
33
34
|
#
|
|
34
35
|
# @param fingerprint [String] new SHA-256 hex digest
|
|
35
36
|
# @param system_text [String] fully assembled system prompt text
|
|
37
|
+
# @api private
|
|
36
38
|
def update(fingerprint:, system_text:)
|
|
37
39
|
@fingerprint = fingerprint
|
|
38
40
|
@system_text = system_text.to_s
|