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,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Carries all per-invocation context values through the call stack.
|
|
5
|
+
#
|
|
6
|
+
# +InvocationContext+ is a plain value object (struct-like, frozen on
|
|
7
|
+
# creation) that replaces ad-hoc +Thread.current[...]+ propagation.
|
|
8
|
+
# Pass it explicitly wherever context needs to cross a method boundary
|
|
9
|
+
# or be handed to a child {Task} / {TaskGroup}.
|
|
10
|
+
#
|
|
11
|
+
# @example Build a context for a new agent invocation
|
|
12
|
+
# ctx = Phronomy::InvocationContext.new(
|
|
13
|
+
# thread_id: "conv-123",
|
|
14
|
+
# cancellation_token: Phronomy::CancellationToken.timeout_after(30),
|
|
15
|
+
# max_parallel_tools: 5
|
|
16
|
+
# )
|
|
17
|
+
# agent.invoke("Hello", invocation_context: ctx)
|
|
18
|
+
class InvocationContext
|
|
19
|
+
# @return [String, nil] conversation / workflow thread identifier
|
|
20
|
+
attr_reader :thread_id
|
|
21
|
+
|
|
22
|
+
# @return [String, nil] session identifier (e.g. Rails session id)
|
|
23
|
+
attr_reader :session_id
|
|
24
|
+
|
|
25
|
+
# @return [String, nil] end-user identifier for tracing / audit
|
|
26
|
+
attr_reader :user_id
|
|
27
|
+
|
|
28
|
+
# @return [CancellationToken, nil]
|
|
29
|
+
attr_reader :cancellation_token
|
|
30
|
+
|
|
31
|
+
# @return [Deadline, nil]
|
|
32
|
+
attr_reader :deadline
|
|
33
|
+
|
|
34
|
+
# @return [Object, nil] OpenTelemetry / tracing span
|
|
35
|
+
attr_reader :tracer_span
|
|
36
|
+
|
|
37
|
+
# @return [Integer, nil] max tokens the agent may consume this invocation
|
|
38
|
+
attr_reader :token_budget
|
|
39
|
+
|
|
40
|
+
# @return [Integer] maximum simultaneous tool calls (default: 10)
|
|
41
|
+
attr_reader :max_parallel_tools
|
|
42
|
+
|
|
43
|
+
# @return [Object, nil] approval policy applied before write-scope tools
|
|
44
|
+
attr_reader :approval_policy
|
|
45
|
+
|
|
46
|
+
# @return [Object, nil] redaction policy applied to tool args / results
|
|
47
|
+
attr_reader :redaction_policy
|
|
48
|
+
|
|
49
|
+
# @return [Hash, nil] per-provider concurrency / rate-limit overrides
|
|
50
|
+
attr_reader :provider_limits
|
|
51
|
+
|
|
52
|
+
# @return [String, nil] unique identifier for this task in the trace tree
|
|
53
|
+
attr_reader :task_id
|
|
54
|
+
|
|
55
|
+
# @return [String, nil] task_id of the parent span / task
|
|
56
|
+
attr_reader :parent_task_id
|
|
57
|
+
|
|
58
|
+
# @param thread_id [String, nil]
|
|
59
|
+
# @param session_id [String, nil]
|
|
60
|
+
# @param user_id [String, nil]
|
|
61
|
+
# @param cancellation_token [CancellationToken, nil]
|
|
62
|
+
# @param deadline [Deadline, nil]
|
|
63
|
+
# @param tracer_span [Object, nil]
|
|
64
|
+
# @param token_budget [Integer, nil]
|
|
65
|
+
# @param max_parallel_tools [Integer]
|
|
66
|
+
# @param approval_policy [Object, nil]
|
|
67
|
+
# @param redaction_policy [Object, nil]
|
|
68
|
+
# @param provider_limits [Hash, nil]
|
|
69
|
+
# @param task_id [String, nil]
|
|
70
|
+
# @param parent_task_id [String, nil]
|
|
71
|
+
# @api private
|
|
72
|
+
def initialize(
|
|
73
|
+
thread_id: nil,
|
|
74
|
+
session_id: nil,
|
|
75
|
+
user_id: nil,
|
|
76
|
+
cancellation_token: nil,
|
|
77
|
+
deadline: nil,
|
|
78
|
+
tracer_span: nil,
|
|
79
|
+
token_budget: nil,
|
|
80
|
+
max_parallel_tools: 10,
|
|
81
|
+
approval_policy: nil,
|
|
82
|
+
redaction_policy: nil,
|
|
83
|
+
provider_limits: nil,
|
|
84
|
+
task_id: nil,
|
|
85
|
+
parent_task_id: nil
|
|
86
|
+
)
|
|
87
|
+
@thread_id = thread_id
|
|
88
|
+
@session_id = session_id
|
|
89
|
+
@user_id = user_id
|
|
90
|
+
@cancellation_token = cancellation_token
|
|
91
|
+
@deadline = deadline
|
|
92
|
+
@tracer_span = tracer_span
|
|
93
|
+
@token_budget = token_budget
|
|
94
|
+
@max_parallel_tools = max_parallel_tools
|
|
95
|
+
@approval_policy = approval_policy
|
|
96
|
+
@redaction_policy = redaction_policy
|
|
97
|
+
@provider_limits = provider_limits
|
|
98
|
+
@task_id = task_id
|
|
99
|
+
@parent_task_id = parent_task_id
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns a new +InvocationContext+ with the given attributes merged in.
|
|
103
|
+
# All other attributes are carried over unchanged.
|
|
104
|
+
#
|
|
105
|
+
# @param overrides [Hash] keyword arguments to override
|
|
106
|
+
# @return [InvocationContext]
|
|
107
|
+
# @api private
|
|
108
|
+
def merge(**overrides)
|
|
109
|
+
InvocationContext.new(
|
|
110
|
+
thread_id: overrides.fetch(:thread_id, @thread_id),
|
|
111
|
+
session_id: overrides.fetch(:session_id, @session_id),
|
|
112
|
+
user_id: overrides.fetch(:user_id, @user_id),
|
|
113
|
+
cancellation_token: overrides.fetch(:cancellation_token, @cancellation_token),
|
|
114
|
+
deadline: overrides.fetch(:deadline, @deadline),
|
|
115
|
+
tracer_span: overrides.fetch(:tracer_span, @tracer_span),
|
|
116
|
+
token_budget: overrides.fetch(:token_budget, @token_budget),
|
|
117
|
+
max_parallel_tools: overrides.fetch(:max_parallel_tools, @max_parallel_tools),
|
|
118
|
+
approval_policy: overrides.fetch(:approval_policy, @approval_policy),
|
|
119
|
+
redaction_policy: overrides.fetch(:redaction_policy, @redaction_policy),
|
|
120
|
+
provider_limits: overrides.fetch(:provider_limits, @provider_limits),
|
|
121
|
+
task_id: overrides.fetch(:task_id, @task_id),
|
|
122
|
+
parent_task_id: overrides.fetch(:parent_task_id, @parent_task_id)
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Convenience: returns the cancellation token or a new never-cancelled token.
|
|
127
|
+
# @return [CancellationToken]
|
|
128
|
+
# @api private
|
|
129
|
+
def effective_cancellation_token
|
|
130
|
+
@cancellation_token || CancellationToken.new
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns the cancellation token to use for an invocation, taking both the
|
|
134
|
+
# explicit +cancellation_token+ and the +deadline+ into account.
|
|
135
|
+
#
|
|
136
|
+
# - When +cancellation_token+ is set, it is returned unchanged.
|
|
137
|
+
# - When only +deadline+ is set, a new {CancellationToken} is created and
|
|
138
|
+
# the deadline is attached to it via {Deadline#attach_to}.
|
|
139
|
+
# - When neither is set, returns +nil+.
|
|
140
|
+
#
|
|
141
|
+
# @return [CancellationToken, nil]
|
|
142
|
+
# @api private
|
|
143
|
+
def effective_timeout_token
|
|
144
|
+
return @cancellation_token if @cancellation_token
|
|
145
|
+
return nil if @deadline.nil?
|
|
146
|
+
|
|
147
|
+
token = CancellationToken.new
|
|
148
|
+
@deadline.attach_to(token)
|
|
149
|
+
token
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -11,12 +11,33 @@ module Phronomy
|
|
|
11
11
|
class Base
|
|
12
12
|
# Retrieve knowledge chunks relevant to the given query.
|
|
13
13
|
#
|
|
14
|
-
# @param query
|
|
14
|
+
# @param query [String, nil] the current user input used to select relevant chunks
|
|
15
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional token; raises CancellationError when cancelled
|
|
15
16
|
# @return [Array<Hash>] array of { content: String, type: Symbol }
|
|
16
|
-
|
|
17
|
+
# @api public
|
|
18
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
19
|
+
cancellation_token&.raise_if_cancelled!
|
|
17
20
|
raise NotImplementedError, "#{self.class}#fetch is not implemented"
|
|
18
21
|
end
|
|
19
22
|
|
|
23
|
+
# Submits a {#fetch} call to {BlockingAdapterPool} and returns a
|
|
24
|
+
# {BlockingAdapterPool::PendingOperation}.
|
|
25
|
+
# Callers can fan out multiple fetches in parallel and await them all.
|
|
26
|
+
#
|
|
27
|
+
# @param query [String, nil]
|
|
28
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
29
|
+
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
30
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
31
|
+
# @api public
|
|
32
|
+
def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
|
|
33
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
34
|
+
timeout: timeout,
|
|
35
|
+
cancellation_token: cancellation_token
|
|
36
|
+
) do
|
|
37
|
+
fetch(query: query, cancellation_token: cancellation_token)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
20
41
|
# Returns true when this source's content is considered static (i.e. does
|
|
21
42
|
# not change between agent invocations). Static sources are eligible for
|
|
22
43
|
# fingerprint-based caching in ContextVersionCache.
|
|
@@ -24,6 +45,7 @@ module Phronomy
|
|
|
24
45
|
# Override in subclasses that return fixed content.
|
|
25
46
|
#
|
|
26
47
|
# @return [Boolean]
|
|
48
|
+
# @api public
|
|
27
49
|
def static?
|
|
28
50
|
false
|
|
29
51
|
end
|
|
@@ -43,6 +43,7 @@ module Phronomy
|
|
|
43
43
|
# Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
|
|
44
44
|
#
|
|
45
45
|
# @param messages [Array] message objects responding to #role and #content
|
|
46
|
+
# @api public
|
|
46
47
|
def update(messages:)
|
|
47
48
|
messages.each do |msg|
|
|
48
49
|
next unless msg.role.to_sym == :user
|
|
@@ -54,9 +55,12 @@ module Phronomy
|
|
|
54
55
|
# Returns a single chunk containing all known entity facts in XML context format.
|
|
55
56
|
# Returns an empty array when no entities have been discovered.
|
|
56
57
|
#
|
|
57
|
-
# @param query
|
|
58
|
+
# @param query [String, nil] unused — entity knowledge is always fully injected
|
|
59
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
58
60
|
# @return [Array<Hash>]
|
|
59
|
-
|
|
61
|
+
# @api public
|
|
62
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
63
|
+
cancellation_token&.raise_if_cancelled!
|
|
60
64
|
return [] if @entities.empty?
|
|
61
65
|
|
|
62
66
|
lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
|
|
@@ -70,6 +74,7 @@ module Phronomy
|
|
|
70
74
|
# Returns the current entity store (primarily for testing).
|
|
71
75
|
#
|
|
72
76
|
# @return [Hash]
|
|
77
|
+
# @api public
|
|
73
78
|
def entities
|
|
74
79
|
@entities.dup
|
|
75
80
|
end
|
|
@@ -22,6 +22,7 @@ module Phronomy
|
|
|
22
22
|
# @param type [Symbol] semantic tag (default :rag)
|
|
23
23
|
# @param source [String, nil] default source label; falls back to
|
|
24
24
|
# each document's :source metadata when nil
|
|
25
|
+
# @api public
|
|
25
26
|
def initialize(store:, embeddings:, k: 5, type: :rag, source: nil)
|
|
26
27
|
@store = store
|
|
27
28
|
@embeddings = embeddings
|
|
@@ -34,13 +35,16 @@ module Phronomy
|
|
|
34
35
|
#
|
|
35
36
|
# Returns an empty array when query is nil or blank.
|
|
36
37
|
#
|
|
37
|
-
# @param query
|
|
38
|
+
# @param query [String, nil]
|
|
39
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
38
40
|
# @return [Array<Hash>]
|
|
39
|
-
|
|
41
|
+
# @api public
|
|
42
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
43
|
+
cancellation_token&.raise_if_cancelled!
|
|
40
44
|
return [] if query.nil? || query.strip.empty?
|
|
41
45
|
|
|
42
|
-
vector = @embeddings.embed(query)
|
|
43
|
-
results = @store.search(query_embedding: vector, k: @k)
|
|
46
|
+
vector = @embeddings.embed(query, cancellation_token)
|
|
47
|
+
results = @store.search(query_embedding: vector, k: @k, cancellation_token: cancellation_token)
|
|
44
48
|
results.map do |doc|
|
|
45
49
|
chunk = {content: doc[:metadata][:content], type: @type}
|
|
46
50
|
src = @source || doc[:metadata][:source]
|
|
@@ -19,6 +19,7 @@ module Phronomy
|
|
|
19
19
|
# @param source [String, nil] label identifying where this knowledge came from
|
|
20
20
|
# (e.g. a filename). Included in the context XML tag and exposed to the LLM
|
|
21
21
|
# so that agents can produce grounded citations.
|
|
22
|
+
# @api public
|
|
22
23
|
def initialize(text, type: :static, source: nil)
|
|
23
24
|
@text = text.to_s
|
|
24
25
|
@type = type
|
|
@@ -27,9 +28,12 @@ module Phronomy
|
|
|
27
28
|
|
|
28
29
|
# Returns the fixed text as a single chunk, regardless of query.
|
|
29
30
|
#
|
|
30
|
-
# @param query
|
|
31
|
+
# @param query [String, nil] ignored for static knowledge
|
|
32
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
31
33
|
# @return [Array<Hash>]
|
|
32
|
-
|
|
34
|
+
# @api public
|
|
35
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
36
|
+
cancellation_token&.raise_if_cancelled!
|
|
33
37
|
return [] if @text.empty?
|
|
34
38
|
|
|
35
39
|
chunk = {content: @text, type: @type}
|
|
@@ -39,6 +43,7 @@ module Phronomy
|
|
|
39
43
|
|
|
40
44
|
# Static knowledge content never changes between invocations.
|
|
41
45
|
# @return [true]
|
|
46
|
+
# @api public
|
|
42
47
|
def static?
|
|
43
48
|
true
|
|
44
49
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# Abstract base class for LLM adapters.
|
|
6
|
+
#
|
|
7
|
+
# Subclasses must implement {#complete} and {#stream}.
|
|
8
|
+
# The agent pipeline calls {#complete_async} / {#stream_async} which wrap
|
|
9
|
+
# those methods in a {BlockingAdapterPool} submission.
|
|
10
|
+
class Base
|
|
11
|
+
# Performs a blocking (non-streaming) LLM completion.
|
|
12
|
+
# Implementors must call +chat.ask(message)+ (or equivalent) and
|
|
13
|
+
# return the response object.
|
|
14
|
+
#
|
|
15
|
+
# @param chat [Object] the configured chat session object
|
|
16
|
+
# @param message [String] the user message
|
|
17
|
+
# @param config [Hash] the invocation config (e.g. +:cancellation_token+)
|
|
18
|
+
# @return [Object] LLM response object
|
|
19
|
+
# @raise [NotImplementedError]
|
|
20
|
+
# @api private
|
|
21
|
+
def complete(chat, message, config: {})
|
|
22
|
+
raise NotImplementedError, "#{self.class}#complete is not implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Performs a blocking streaming LLM completion.
|
|
26
|
+
# Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
|
|
27
|
+
# (or equivalent) and return the response object.
|
|
28
|
+
#
|
|
29
|
+
# @param chat [Object] the configured chat session object
|
|
30
|
+
# @param message [String] the user message
|
|
31
|
+
# @param config [Hash] the invocation config
|
|
32
|
+
# @yield [chunk] streaming chunk from the LLM
|
|
33
|
+
# @return [Object] LLM response object
|
|
34
|
+
# @raise [NotImplementedError]
|
|
35
|
+
# @api private
|
|
36
|
+
def stream(chat, message, config: {}, &block)
|
|
37
|
+
raise NotImplementedError, "#{self.class}#stream is not implemented"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
|
|
41
|
+
# a {BlockingAdapterPool::PendingOperation}.
|
|
42
|
+
#
|
|
43
|
+
# @param chat [Object] configured chat session
|
|
44
|
+
# @param message [String] user message
|
|
45
|
+
# @param config [Hash] invocation config
|
|
46
|
+
# @param pool [BlockingAdapterPool] pool to submit to
|
|
47
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
48
|
+
# @api private
|
|
49
|
+
def complete_async(chat, message, config: {}, pool: default_pool)
|
|
50
|
+
token = config[:cancellation_token]
|
|
51
|
+
timeout = config[:llm_timeout]
|
|
52
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
53
|
+
complete(chat, message, config: config)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Submits a streaming LLM call to {BlockingAdapterPool} and returns
|
|
58
|
+
# a {BlockingAdapterPool::PendingOperation}.
|
|
59
|
+
#
|
|
60
|
+
# When +enqueue_to:+ is given, streaming chunks are pushed into that
|
|
61
|
+
# {AsyncQueue} from the worker thread instead of being passed directly
|
|
62
|
+
# to the caller's block. The queue is closed (via +ensure+) after the
|
|
63
|
+
# LLM call finishes so the consumer's drain loop terminates naturally.
|
|
64
|
+
# This keeps user-supplied blocks off the blocking-pool worker thread.
|
|
65
|
+
#
|
|
66
|
+
# When +enqueue_to:+ is nil and a block is given, the block is invoked
|
|
67
|
+
# directly from the worker thread (legacy behaviour, preserved for
|
|
68
|
+
# backward compatibility).
|
|
69
|
+
#
|
|
70
|
+
# @param chat [Object] configured chat session
|
|
71
|
+
# @param message [String] user message
|
|
72
|
+
# @param config [Hash] invocation config
|
|
73
|
+
# @param pool [BlockingAdapterPool] pool to submit to
|
|
74
|
+
# @param enqueue_to [AsyncQueue, nil] when set, push chunks here instead of
|
|
75
|
+
# calling the block on the worker thread
|
|
76
|
+
# @yield [chunk] streaming chunk — only used when +enqueue_to:+ is nil
|
|
77
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
78
|
+
# @api private
|
|
79
|
+
def stream_async(chat, message, config: {}, pool: default_pool, enqueue_to: nil, &block)
|
|
80
|
+
token = config[:cancellation_token]
|
|
81
|
+
timeout = config[:llm_timeout]
|
|
82
|
+
if enqueue_to
|
|
83
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
84
|
+
stream(chat, message, config: config) do |chunk|
|
|
85
|
+
enqueue_to.push(chunk)
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
enqueue_to.close
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
92
|
+
stream(chat, message, config: config, &block)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def default_pool
|
|
100
|
+
Phronomy::Runtime.instance.blocking_io
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# LLM adapter that delegates to the RubyLLM blocking client.
|
|
6
|
+
#
|
|
7
|
+
# This is the default adapter used by Phronomy agents. It wraps
|
|
8
|
+
# +chat.ask+ (and its streaming variant) so that the blocking HTTP
|
|
9
|
+
# call runs inside {BlockingAdapterPool} rather than on the EventLoop
|
|
10
|
+
# thread or the caller's thread directly.
|
|
11
|
+
#
|
|
12
|
+
# @example Explicitly configuring this adapter
|
|
13
|
+
# Phronomy.configure do |c|
|
|
14
|
+
# c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
15
|
+
# end
|
|
16
|
+
class RubyLLM < Base
|
|
17
|
+
# Delegates to +chat.ask(message)+.
|
|
18
|
+
#
|
|
19
|
+
# @param chat [Object] RubyLLM chat session
|
|
20
|
+
# @param message [String] user message
|
|
21
|
+
# @param config [Hash] invocation config (not used directly by this impl)
|
|
22
|
+
# @return [Object] RubyLLM response
|
|
23
|
+
# @api private
|
|
24
|
+
def complete(chat, message, config: {})
|
|
25
|
+
chat.ask(message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+.
|
|
29
|
+
#
|
|
30
|
+
# @param chat [Object] RubyLLM chat session
|
|
31
|
+
# @param message [String] user message
|
|
32
|
+
# @param config [Hash] invocation config
|
|
33
|
+
# @yield [chunk] streaming chunk forwarded from +chat.ask+
|
|
34
|
+
# @return [Object] RubyLLM response
|
|
35
|
+
# @api private
|
|
36
|
+
def stream(chat, message, config: {}, &block)
|
|
37
|
+
chat.ask(message, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Namespace for LLM adapter implementations.
|
|
5
|
+
#
|
|
6
|
+
# An LLMAdapter decouples Phronomy's agent pipeline from direct
|
|
7
|
+
# dependency on the RubyLLM blocking client. All LLM calls in
|
|
8
|
+
# {Agent::Base} are routed through the adapter so that:
|
|
9
|
+
#
|
|
10
|
+
# - Blocking HTTP can be submitted to {BlockingAdapterPool} for bounded
|
|
11
|
+
# concurrency and per-operation timeouts.
|
|
12
|
+
# - Alternative LLM clients can be swapped in without changing agent code.
|
|
13
|
+
#
|
|
14
|
+
# @example Configuring a custom adapter
|
|
15
|
+
# Phronomy.configure do |c|
|
|
16
|
+
# c.llm_adapter = MyCustomAdapter.new
|
|
17
|
+
# end
|
|
18
|
+
module LLMAdapter
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/phronomy/loader/base.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Phronomy
|
|
|
16
16
|
# @param source [String] file path, URL, or other source identifier
|
|
17
17
|
# @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
|
|
18
18
|
# @raise [NotImplementedError] when not overridden by a subclass
|
|
19
|
+
# @api public
|
|
19
20
|
def load(source)
|
|
20
21
|
raise NotImplementedError, "#{self.class}#load is not implemented"
|
|
21
22
|
end
|
|
@@ -20,6 +20,7 @@ module Phronomy
|
|
|
20
20
|
class CsvLoader < Base
|
|
21
21
|
# @param headers [Boolean] treat the first row as headers (default: true)
|
|
22
22
|
# @param text_column [String, nil] if set, use only this column as the document text
|
|
23
|
+
# @api public
|
|
23
24
|
def initialize(headers: true, text_column: nil)
|
|
24
25
|
@headers = headers
|
|
25
26
|
@text_column = text_column
|
|
@@ -28,6 +29,7 @@ module Phronomy
|
|
|
28
29
|
# @param source [String] path to a CSV file
|
|
29
30
|
# @return [Array<Hash>]
|
|
30
31
|
# @raise [Errno::ENOENT] if the file does not exist
|
|
32
|
+
# @api public
|
|
31
33
|
def load(source)
|
|
32
34
|
rows = CSV.read(source, headers: @headers, encoding: "UTF-8")
|
|
33
35
|
|
|
@@ -24,6 +24,7 @@ module Phronomy
|
|
|
24
24
|
HEADING_RE = /^(\#{1,6})\s+(.+)$/
|
|
25
25
|
|
|
26
26
|
# @param split_on_headings [Boolean] split on H1–H6 boundaries (default: true)
|
|
27
|
+
# @api public
|
|
27
28
|
def initialize(split_on_headings: true)
|
|
28
29
|
@split_on_headings = split_on_headings
|
|
29
30
|
end
|
|
@@ -31,6 +32,7 @@ module Phronomy
|
|
|
31
32
|
# @param source [String] path to a Markdown file
|
|
32
33
|
# @return [Array<Hash>]
|
|
33
34
|
# @raise [Errno::ENOENT] if the file does not exist
|
|
35
|
+
# @api public
|
|
34
36
|
def load(source)
|
|
35
37
|
content = File.read(source, encoding: "UTF-8")
|
|
36
38
|
return [{text: content, metadata: {source: source}}] unless @split_on_headings
|
|
@@ -12,6 +12,7 @@ module Phronomy
|
|
|
12
12
|
# @param source [String] absolute or relative path to a text file
|
|
13
13
|
# @return [Array<Hash>] single-element array with the file contents
|
|
14
14
|
# @raise [Errno::ENOENT] if the file does not exist
|
|
15
|
+
# @api public
|
|
15
16
|
def load(source)
|
|
16
17
|
text = File.read(source, encoding: "UTF-8")
|
|
17
18
|
[{text: text, metadata: {source: source}}]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Task-centric observability snapshot (Issue #276, extended in #307).
|
|
5
|
+
#
|
|
6
|
+
# Collects live metrics from the shared Runtime components
|
|
7
|
+
# (BlockingAdapterPool, EventLoop, and Runtime task registry) and returns
|
|
8
|
+
# them as a plain Hash so they can be forwarded to any monitoring backend
|
|
9
|
+
# (Prometheus, OpenTelemetry, StatsD, etc.).
|
|
10
|
+
#
|
|
11
|
+
# All metrics are read at the moment {.snapshot} is called; no
|
|
12
|
+
# persistent state is held here.
|
|
13
|
+
#
|
|
14
|
+
# @example Exporting to a metrics endpoint
|
|
15
|
+
# data = Phronomy::Metrics.snapshot
|
|
16
|
+
# # => { blocking_pool_active: 2, active_agent_tasks: 1, ... }
|
|
17
|
+
module Metrics
|
|
18
|
+
# Returns a Hash of current observability metrics.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash{Symbol => Numeric}]
|
|
21
|
+
# @api public
|
|
22
|
+
def self.snapshot
|
|
23
|
+
pool = Runtime.instance.blocking_io
|
|
24
|
+
el = EventLoop.instance
|
|
25
|
+
task_snap = Runtime.instance.task_snapshot
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
blocking_pool_active: pool.active_count,
|
|
29
|
+
blocking_pool_queue_length: pool.queue_depth,
|
|
30
|
+
blocking_pool_abandoned_total: pool.abandoned_count,
|
|
31
|
+
blocking_pool_size: pool.pool_size,
|
|
32
|
+
event_loop_lag_last_ms: (el.last_lag_seconds * 1000).round(3),
|
|
33
|
+
event_loop_lag_max_ms: (el.max_lag_seconds * 1000).round(3),
|
|
34
|
+
event_loop_lag_average_ms: (el.average_lag_seconds * 1000).round(3)
|
|
35
|
+
}.merge(task_snap)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -10,6 +10,7 @@ module Phronomy
|
|
|
10
10
|
# @param text [String]
|
|
11
11
|
# @return [Hash, Array] result parsed with symbolize_names: true
|
|
12
12
|
# @raise [Phronomy::ParseError] raised when JSON parsing fails
|
|
13
|
+
# @api public
|
|
13
14
|
def parse(text)
|
|
14
15
|
json_str = extract_json(text)
|
|
15
16
|
JSON.parse(json_str, symbolize_names: true)
|
|
@@ -19,10 +20,28 @@ module Phronomy
|
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
# Extracts
|
|
23
|
-
#
|
|
23
|
+
# Extracts a JSON string from the LLM response text.
|
|
24
|
+
#
|
|
25
|
+
# Strategy (in order):
|
|
26
|
+
# 1. Try each ```json ... ``` or ``` ... ``` code fence in document order,
|
|
27
|
+
# returning the content of the first one that parses as valid JSON.
|
|
28
|
+
# 2. Try the raw text stripped of leading/trailing whitespace.
|
|
29
|
+
#
|
|
30
|
+
# This handles:
|
|
31
|
+
# - Single JSON code fence (common case)
|
|
32
|
+
# - Multiple code fences — the first parseable JSON block wins
|
|
33
|
+
# - No fence — LLM omitted the backticks but returned valid JSON
|
|
24
34
|
def extract_json(text)
|
|
25
|
-
text.
|
|
35
|
+
text.scan(/```(?:json)?\s*\n?(.*?)\n?```/m).each do |captures|
|
|
36
|
+
candidate = captures.first.strip
|
|
37
|
+
JSON.parse(candidate)
|
|
38
|
+
return candidate
|
|
39
|
+
rescue JSON::ParserError
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fallback: no valid fence found — try the raw text
|
|
44
|
+
text.strip
|
|
26
45
|
end
|
|
27
46
|
end
|
|
28
47
|
end
|
|
@@ -9,6 +9,7 @@ module Phronomy
|
|
|
9
9
|
# parser.parse('{"name":"Alice","age":30}') #=> #<struct PersonSchema name="Alice", age=30>
|
|
10
10
|
class StructuredParser < Base
|
|
11
11
|
# @param schema_class [Class] Struct with keyword_init: true or equivalent
|
|
12
|
+
# @api public
|
|
12
13
|
def initialize(schema_class)
|
|
13
14
|
@schema_class = schema_class
|
|
14
15
|
end
|
|
@@ -16,6 +17,7 @@ module Phronomy
|
|
|
16
17
|
# @param text [String]
|
|
17
18
|
# @return [Object] instance of schema_class
|
|
18
19
|
# @raise [Phronomy::ParseError] raised when JSON parsing or schema instantiation fails
|
|
20
|
+
# @api public
|
|
19
21
|
def parse(text)
|
|
20
22
|
data = JsonParser.new.parse(text)
|
|
21
23
|
@schema_class.new(**data)
|
|
@@ -27,6 +27,7 @@ module Phronomy
|
|
|
27
27
|
|
|
28
28
|
# @param template [String] human message template with {{var}} placeholders
|
|
29
29
|
# @param system_template [String, nil] optional system message template
|
|
30
|
+
# @api public
|
|
30
31
|
def initialize(template:, system_template: nil)
|
|
31
32
|
@template = template
|
|
32
33
|
@system_template = system_template
|
|
@@ -36,6 +37,7 @@ module Phronomy
|
|
|
36
37
|
#
|
|
37
38
|
# @param variables [Hash{Symbol => String}]
|
|
38
39
|
# @return [String]
|
|
40
|
+
# @api public
|
|
39
41
|
def format(**variables)
|
|
40
42
|
substitute(@template, variables)
|
|
41
43
|
end
|
|
@@ -45,6 +47,7 @@ module Phronomy
|
|
|
45
47
|
#
|
|
46
48
|
# @param variables [Hash{Symbol => String}]
|
|
47
49
|
# @return [String, nil]
|
|
50
|
+
# @api public
|
|
48
51
|
def format_system(**variables)
|
|
49
52
|
@system_template && substitute(@system_template, variables)
|
|
50
53
|
end
|
|
@@ -54,6 +57,7 @@ module Phronomy
|
|
|
54
57
|
#
|
|
55
58
|
# @param input [Hash{Symbol => String}]
|
|
56
59
|
# @return [Hash]
|
|
60
|
+
# @api public
|
|
57
61
|
def invoke(input, config: {})
|
|
58
62
|
vars = normalize_input(input)
|
|
59
63
|
result = {prompt: format(**vars)}
|
|
@@ -65,6 +69,7 @@ module Phronomy
|
|
|
65
69
|
# Returns the list of placeholder names found in both templates.
|
|
66
70
|
#
|
|
67
71
|
# @return [Array<Symbol>]
|
|
72
|
+
# @api public
|
|
68
73
|
def variables
|
|
69
74
|
names = @template.scan(PLACEHOLDER).flatten
|
|
70
75
|
names += @system_template.scan(PLACEHOLDER).flatten if @system_template
|