phronomy 0.16.0 → 0.17.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/.mutant.yml +8 -9
- data/CHANGELOG.md +54 -0
- data/CONTRIBUTING.md +28 -16
- data/README.md +124 -92
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +1 -1
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/base.rb +135 -314
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +0 -1
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +15 -158
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
- data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +3 -120
- data/scripts/api_snapshot.rb +1 -12
- metadata +3 -9
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
module Context
|
|
6
|
-
module Knowledge
|
|
7
|
-
# Abstract base class for all KnowledgeSource implementations.
|
|
8
|
-
#
|
|
9
|
-
# Subclasses must implement #fetch(query:) and return an Array of chunk Hashes.
|
|
10
|
-
# Each chunk Hash must contain:
|
|
11
|
-
# :content [String] the text to inject into the context
|
|
12
|
-
# :type [Symbol] semantic tag (e.g. :static, :rag, :entity)
|
|
13
|
-
class Base
|
|
14
|
-
# Retrieve knowledge chunks relevant to the given query.
|
|
15
|
-
#
|
|
16
|
-
# @param query [String, nil] the current user input used to select relevant chunks
|
|
17
|
-
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional token; raises CancellationError when cancelled
|
|
18
|
-
# @return [Array<Hash>] array of { content: String, type: Symbol }
|
|
19
|
-
# @api public
|
|
20
|
-
def fetch(query: nil, cancellation_token: nil)
|
|
21
|
-
cancellation_token&.raise_if_cancelled!
|
|
22
|
-
raise NotImplementedError, "#{self.class}#fetch is not implemented"
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Submits a {#fetch} call to {BlockingAdapterPool} and returns a
|
|
26
|
-
# {BlockingAdapterPool::PendingOperation}.
|
|
27
|
-
# Callers can fan out multiple fetches in parallel and await them all.
|
|
28
|
-
#
|
|
29
|
-
# @param query [String, nil]
|
|
30
|
-
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
31
|
-
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
32
|
-
# @return [BlockingAdapterPool::PendingOperation]
|
|
33
|
-
# @api public
|
|
34
|
-
def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
|
|
35
|
-
Phronomy::Runtime.instance.blocking_io.submit(
|
|
36
|
-
timeout: timeout,
|
|
37
|
-
cancellation_token: cancellation_token
|
|
38
|
-
) do
|
|
39
|
-
fetch(query: query, cancellation_token: cancellation_token)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Returns true when this source's content is considered static (i.e. does
|
|
44
|
-
# not change between agent invocations). Static sources are eligible for
|
|
45
|
-
# fingerprint-based caching in ContextVersionCache.
|
|
46
|
-
#
|
|
47
|
-
# Override in subclasses that return fixed content.
|
|
48
|
-
#
|
|
49
|
-
# @return [Boolean]
|
|
50
|
-
# @api public
|
|
51
|
-
def static?
|
|
52
|
-
false
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
module Context
|
|
6
|
-
module Knowledge
|
|
7
|
-
# A KnowledgeSource that extracts named-entity facts from conversation history.
|
|
8
|
-
#
|
|
9
|
-
# This is the knowledge-injection counterpart of the old EntityMemory.
|
|
10
|
-
# It scans saved user messages with a regex heuristic (no LLM call) and
|
|
11
|
-
# returns the discovered facts as a single knowledge chunk tagged :entity.
|
|
12
|
-
#
|
|
13
|
-
# EntityKnowledge is stateful: it accumulates extracted facts via #update(messages:)
|
|
14
|
-
# which should be called each time new messages are saved.
|
|
15
|
-
#
|
|
16
|
-
# Supported extraction patterns (case-insensitive):
|
|
17
|
-
# "my name is Alice" → { name: "Alice" }
|
|
18
|
-
# "I am Alice" → { identity: "Alice" }
|
|
19
|
-
# "I'm a software engineer" → { occupation: "software engineer" }
|
|
20
|
-
# "I work at / for Acme" → { workplace: "Acme" }
|
|
21
|
-
# "I live in Tokyo" → { location: "Tokyo" }
|
|
22
|
-
# "I'm from Tokyo" → { location: "Tokyo" }
|
|
23
|
-
# "I like / love Ruby" → { preference: "Ruby" }
|
|
24
|
-
#
|
|
25
|
-
# @example
|
|
26
|
-
# ks = Phronomy::Agent::Context::Knowledge::EntityKnowledge.new
|
|
27
|
-
# ks.update(messages: chat_messages)
|
|
28
|
-
# agent = MyAgent.new
|
|
29
|
-
# agent.add_knowledge_source(ks)
|
|
30
|
-
# agent.invoke("What is my name?")
|
|
31
|
-
class EntityKnowledge < Base
|
|
32
|
-
PATTERNS = [
|
|
33
|
-
[:name, /\bmy name is\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
34
|
-
[:identity, /\bI\s+am\s+([A-Z][A-Za-z0-9 \-']+)/],
|
|
35
|
-
[:occupation, /\bI(?:'m| am) a(?:n)?\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
36
|
-
[:workplace, /\bI (?:work|worked) (?:at|for|in)\s+([A-Za-z0-9][A-Za-z0-9 \-'.&,]*)/i],
|
|
37
|
-
[:location, /\bI live in\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
38
|
-
[:location, /\bI(?:'m| am) from\s+([A-Za-z][A-Za-z0-9 \-']*)/i],
|
|
39
|
-
[:preference, /\bI (?:like|love|enjoy)\s+([A-Za-z][A-Za-z0-9 \-']*)/i]
|
|
40
|
-
].freeze
|
|
41
|
-
|
|
42
|
-
def initialize
|
|
43
|
-
@entities = {}
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Scan messages and accumulate entity facts.
|
|
47
|
-
# Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
|
|
48
|
-
#
|
|
49
|
-
# @param messages [Array] message objects responding to #role and #content
|
|
50
|
-
# @api public
|
|
51
|
-
def update(messages:)
|
|
52
|
-
messages.each do |msg|
|
|
53
|
-
next unless msg.role.to_sym == :user
|
|
54
|
-
|
|
55
|
-
extract(msg.content.to_s).each { |key, value| @entities[key] = value }
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Returns a single chunk containing all known entity facts in XML context format.
|
|
60
|
-
# Returns an empty array when no entities have been discovered.
|
|
61
|
-
#
|
|
62
|
-
# @param query [String, nil] unused — entity knowledge is always fully injected
|
|
63
|
-
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
64
|
-
# @return [Array<Hash>]
|
|
65
|
-
# @api public
|
|
66
|
-
def fetch(query: nil, cancellation_token: nil)
|
|
67
|
-
cancellation_token&.raise_if_cancelled!
|
|
68
|
-
return [] if @entities.empty?
|
|
69
|
-
|
|
70
|
-
lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
|
|
71
|
-
content = <<~CONTENT.chomp
|
|
72
|
-
Known facts about the user:
|
|
73
|
-
#{lines}
|
|
74
|
-
CONTENT
|
|
75
|
-
[{content: content, type: :entity}]
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# Returns the current entity store (primarily for testing).
|
|
79
|
-
#
|
|
80
|
-
# @return [Hash]
|
|
81
|
-
# @api public
|
|
82
|
-
def entities
|
|
83
|
-
@entities.dup
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
private
|
|
87
|
-
|
|
88
|
-
def extract(text)
|
|
89
|
-
found = {}
|
|
90
|
-
PATTERNS.each do |key, pattern|
|
|
91
|
-
if (match = text.match(pattern))
|
|
92
|
-
value = match[1].strip.sub(/[.!?]\s+.*$/, "").gsub(/[.,;!?]+$/, "")
|
|
93
|
-
found[key] = value unless value.empty?
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
found
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
module Context
|
|
6
|
-
module Knowledge
|
|
7
|
-
# A KnowledgeSource backed by fixed text provided at construction time.
|
|
8
|
-
#
|
|
9
|
-
# Useful for injecting static documents, policy files, or configuration
|
|
10
|
-
# knowledge that does not change per request.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# ks = Phronomy::Agent::Context::Knowledge::StaticKnowledge.new(
|
|
14
|
-
# "Our refund policy: ...",
|
|
15
|
-
# type: :policy
|
|
16
|
-
# )
|
|
17
|
-
# agent = MyAgent.new
|
|
18
|
-
# agent.add_knowledge_source(ks)
|
|
19
|
-
# agent.invoke("What is the refund policy?")
|
|
20
|
-
class StaticKnowledge < Base
|
|
21
|
-
# @param text [String] the static knowledge text to inject
|
|
22
|
-
# @param type [Symbol] semantic tag for the chunk (default :static)
|
|
23
|
-
# @param source [String, nil] label identifying where this knowledge came from
|
|
24
|
-
# (e.g. a filename). Included in the context XML tag and exposed to the LLM
|
|
25
|
-
# so that agents can produce grounded citations.
|
|
26
|
-
# @api public
|
|
27
|
-
def initialize(text, type: :static, source: nil)
|
|
28
|
-
@text = text.to_s
|
|
29
|
-
@type = type
|
|
30
|
-
@source = source
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Returns the fixed text as a single chunk, regardless of query.
|
|
34
|
-
#
|
|
35
|
-
# @param query [String, nil] ignored for static knowledge
|
|
36
|
-
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
37
|
-
# @return [Array<Hash>]
|
|
38
|
-
# @api public
|
|
39
|
-
def fetch(query: nil, cancellation_token: nil)
|
|
40
|
-
cancellation_token&.raise_if_cancelled!
|
|
41
|
-
return [] if @text.empty?
|
|
42
|
-
|
|
43
|
-
chunk = {content: @text, type: @type}
|
|
44
|
-
chunk[:source] = @source if @source
|
|
45
|
-
[chunk]
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# Static knowledge content never changes between invocations.
|
|
49
|
-
# @return [true]
|
|
50
|
-
# @api public
|
|
51
|
-
def static?
|
|
52
|
-
true
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Agent
|
|
5
|
-
# Adapts the current AgentInvocation FSM to the ideal manifest-first model.
|
|
6
|
-
# Every LLM Call receives a freshly fixed Manifest. Persistence and
|
|
7
|
-
# projection preparation run on BlockingAdapterPool, never on EventLoop.
|
|
8
|
-
module FsmRuntimeAdapter
|
|
9
|
-
private
|
|
10
|
-
|
|
11
|
-
def filtering_input_action(_agent, invocation)
|
|
12
|
-
invocation.input = invocation.config.fetch(:phronomy_filtered_input)
|
|
13
|
-
invocation
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def building_context_action(agent, invocation)
|
|
17
|
-
projection = invocation.config.fetch(:phronomy_runtime_projection)
|
|
18
|
-
invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
|
|
19
|
-
agent.send(
|
|
20
|
-
:_apply_context_to_chat,
|
|
21
|
-
invocation.chat,
|
|
22
|
-
{
|
|
23
|
-
system: projection.system,
|
|
24
|
-
messages: projection.messages,
|
|
25
|
-
tool_classes: projection.tool_classes,
|
|
26
|
-
model_config: projection.model_config
|
|
27
|
-
}
|
|
28
|
-
)
|
|
29
|
-
install_tool_interceptors(invocation.chat, invocation)
|
|
30
|
-
invocation
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def calling_llm_action(agent, runtime, invocation)
|
|
34
|
-
prepare_and_start_llm_call(
|
|
35
|
-
agent,
|
|
36
|
-
runtime,
|
|
37
|
-
invocation,
|
|
38
|
-
streaming: false
|
|
39
|
-
)
|
|
40
|
-
invocation
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def calling_llm_stream_action(agent, runtime, invocation)
|
|
44
|
-
prepare_and_start_llm_call(
|
|
45
|
-
agent,
|
|
46
|
-
runtime,
|
|
47
|
-
invocation,
|
|
48
|
-
streaming: true
|
|
49
|
-
)
|
|
50
|
-
invocation
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
|
|
54
|
-
activation = invocation.config.fetch(:phronomy_activation)
|
|
55
|
-
if invocation.user_message_sent
|
|
56
|
-
preparation = runtime.blocking_io.submit do
|
|
57
|
-
activation.coordinator.prepare_next_llm_call(activation)
|
|
58
|
-
end
|
|
59
|
-
preparation.on_complete do |projection, error|
|
|
60
|
-
if error
|
|
61
|
-
post_preparation_failure(runtime, invocation, error, streaming: streaming)
|
|
62
|
-
else
|
|
63
|
-
start_provider_call(
|
|
64
|
-
agent,
|
|
65
|
-
runtime,
|
|
66
|
-
invocation,
|
|
67
|
-
activation,
|
|
68
|
-
projection,
|
|
69
|
-
streaming: streaming,
|
|
70
|
-
replace_messages: true
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
else
|
|
75
|
-
start_provider_call(
|
|
76
|
-
agent,
|
|
77
|
-
runtime,
|
|
78
|
-
invocation,
|
|
79
|
-
activation,
|
|
80
|
-
activation.runtime_projection,
|
|
81
|
-
streaming: streaming,
|
|
82
|
-
replace_messages: false
|
|
83
|
-
)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def start_provider_call(
|
|
88
|
-
agent,
|
|
89
|
-
runtime,
|
|
90
|
-
invocation,
|
|
91
|
-
activation,
|
|
92
|
-
projection,
|
|
93
|
-
streaming:,
|
|
94
|
-
replace_messages:
|
|
95
|
-
)
|
|
96
|
-
call_started = false
|
|
97
|
-
agent.send(
|
|
98
|
-
:check_cancellation!,
|
|
99
|
-
invocation.config,
|
|
100
|
-
"invocation cancelled before LLM call"
|
|
101
|
-
)
|
|
102
|
-
if replace_messages
|
|
103
|
-
invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
|
|
104
|
-
agent.send(
|
|
105
|
-
:_apply_context_to_chat,
|
|
106
|
-
invocation.chat,
|
|
107
|
-
{
|
|
108
|
-
system: projection.system,
|
|
109
|
-
messages: projection.messages,
|
|
110
|
-
tool_classes: projection.tool_classes,
|
|
111
|
-
model_config: projection.model_config
|
|
112
|
-
}
|
|
113
|
-
)
|
|
114
|
-
install_tool_interceptors(invocation.chat, invocation)
|
|
115
|
-
invocation.config[:phronomy_runtime_projection] = projection
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
call_context = activation.begin_llm_call(projection)
|
|
119
|
-
call_started = true
|
|
120
|
-
invocation.begin_llm_call!(call_context.fetch(:llm_call_id))
|
|
121
|
-
message = projection.ask_message
|
|
122
|
-
|
|
123
|
-
operation = if streaming
|
|
124
|
-
Phronomy.configuration.llm_adapter.stream_async(
|
|
125
|
-
invocation.chat,
|
|
126
|
-
message,
|
|
127
|
-
config: invocation.config
|
|
128
|
-
) do |chunk|
|
|
129
|
-
agent.send(
|
|
130
|
-
:check_cancellation!,
|
|
131
|
-
invocation.config,
|
|
132
|
-
"invocation cancelled during streaming"
|
|
133
|
-
)
|
|
134
|
-
post_to_invocation!(
|
|
135
|
-
runtime,
|
|
136
|
-
invocation.id,
|
|
137
|
-
:llm_stream_chunk,
|
|
138
|
-
{content: chunk.content}
|
|
139
|
-
)
|
|
140
|
-
end
|
|
141
|
-
else
|
|
142
|
-
Phronomy.configuration.llm_adapter.complete_async(
|
|
143
|
-
invocation.chat,
|
|
144
|
-
message,
|
|
145
|
-
config: invocation.config
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
observe_manifest_call(
|
|
149
|
-
operation,
|
|
150
|
-
activation,
|
|
151
|
-
invocation,
|
|
152
|
-
runtime: runtime,
|
|
153
|
-
streaming: streaming
|
|
154
|
-
)
|
|
155
|
-
rescue => error
|
|
156
|
-
if call_started
|
|
157
|
-
activation.record_llm_result(
|
|
158
|
-
response: canonical_response_for(nil, error),
|
|
159
|
-
error: error,
|
|
160
|
-
streaming: streaming
|
|
161
|
-
)
|
|
162
|
-
end
|
|
163
|
-
post_llm_result(runtime, invocation, nil, error, streaming: streaming)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def observe_manifest_call(operation, activation, invocation, runtime:, streaming:)
|
|
167
|
-
operation.on_complete do |response, error|
|
|
168
|
-
activation.record_llm_result(
|
|
169
|
-
response: canonical_response_for(response, error),
|
|
170
|
-
error: error,
|
|
171
|
-
streaming: streaming
|
|
172
|
-
)
|
|
173
|
-
post_llm_result(
|
|
174
|
-
runtime,
|
|
175
|
-
invocation,
|
|
176
|
-
response,
|
|
177
|
-
error,
|
|
178
|
-
streaming: streaming
|
|
179
|
-
)
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def canonical_response_for(response, error)
|
|
184
|
-
if error.is_a?(ToolCallIntercepted)
|
|
185
|
-
error.assistant_outcome || ProviderCallOutcome.capture(response)
|
|
186
|
-
else
|
|
187
|
-
ProviderCallOutcome.capture(response)
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def post_preparation_failure(runtime, invocation, error, streaming:)
|
|
192
|
-
post_llm_result(runtime, invocation, nil, error, streaming: streaming)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def post_llm_result(runtime, invocation, response, error, streaming:)
|
|
196
|
-
result = LLMOperationResult.new(
|
|
197
|
-
response: response,
|
|
198
|
-
error: error,
|
|
199
|
-
streaming: streaming
|
|
200
|
-
)
|
|
201
|
-
event_type = if error && !error.is_a?(ToolCallIntercepted)
|
|
202
|
-
:llm_failed
|
|
203
|
-
else
|
|
204
|
-
:llm_completed
|
|
205
|
-
end
|
|
206
|
-
post_to_invocation!(runtime, invocation.id, event_type, result)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# KnowledgeSource provides the interface for supplying context region 3 (Knowledge)
|
|
5
|
-
# to the Context::Assembler.
|
|
6
|
-
#
|
|
7
|
-
# Each implementation returns an array of knowledge chunks via #fetch(query:).
|
|
8
|
-
# Each chunk is a Hash with :content (String) and :type (Symbol) keys.
|
|
9
|
-
# The Assembler wraps each chunk in an XML context tag before injecting it.
|
|
10
|
-
module KnowledgeSource
|
|
11
|
-
end
|
|
12
|
-
end
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "cgi"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module LlmContextWindow
|
|
7
|
-
# Assembler collects all four context regions and produces the final
|
|
8
|
-
# {system:, messages:, tool_classes:} hash consumed by Agent::Base.
|
|
9
|
-
#
|
|
10
|
-
# Regions:
|
|
11
|
-
# 1. Instruction — system prompt text set via #add_instruction
|
|
12
|
-
# 2. Capability — tool classes registered via #add_capability
|
|
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, Assembler validates that the supplied conversation
|
|
18
|
-
# messages fit after instruction, knowledge, and capability costs are
|
|
19
|
-
# accounted for. It does not prune messages; Manifest-first Context Policy
|
|
20
|
-
# is responsible for context selection.
|
|
21
|
-
# Knowledge chunks are always included in full (they are assumed to be
|
|
22
|
-
# pre-screened by the caller). When no budget is given all messages are
|
|
23
|
-
# passed through unchanged.
|
|
24
|
-
#
|
|
25
|
-
# @example
|
|
26
|
-
# assembler = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
|
|
27
|
-
# assembler.add_instruction("You are a helpful assistant.")
|
|
28
|
-
# assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
|
|
29
|
-
# assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
|
|
30
|
-
# context = assembler.build
|
|
31
|
-
# # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
|
|
32
|
-
class Assembler
|
|
33
|
-
# Builds a single XML context tag string.
|
|
34
|
-
# Exposed as a class method so callers (e.g. Agent::Base) can build
|
|
35
|
-
# static knowledge XML tags independently of an Assembler instance.
|
|
36
|
-
#
|
|
37
|
-
# @param text [String]
|
|
38
|
-
# @param type [Symbol, String]
|
|
39
|
-
# @param trusted [Boolean]
|
|
40
|
-
# @return [String]
|
|
41
|
-
# @api private
|
|
42
|
-
# mutant:disable - text.to_str and plain text (no to_s) are genuine equivalents when text is a String; type.to_str is genuine equivalent when type is a String
|
|
43
|
-
def self.xml_tag(text, type:, trusted: false)
|
|
44
|
-
"<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
48
|
-
# when nil no budget validation is performed
|
|
49
|
-
# @api private
|
|
50
|
-
# mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
|
|
51
|
-
def initialize(budget: nil)
|
|
52
|
-
@budget = budget
|
|
53
|
-
@instruction = nil
|
|
54
|
-
@tool_classes = []
|
|
55
|
-
@knowledge_chunks = []
|
|
56
|
-
@messages = []
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Register tool classes (Region 2).
|
|
60
|
-
# Estimates their token cost and deducts it from the budget so that
|
|
61
|
-
# budget estimation accounts for tool definition overhead.
|
|
62
|
-
#
|
|
63
|
-
# @param tool_classes [Array<Class, Object>] tool classes or instances
|
|
64
|
-
# @return [self]
|
|
65
|
-
# @api private
|
|
66
|
-
def add_capability(tool_classes)
|
|
67
|
-
@tool_classes = Array(tool_classes)
|
|
68
|
-
self
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Set the system instruction text (Region 1).
|
|
72
|
-
# Calling this multiple times replaces the previous value.
|
|
73
|
-
#
|
|
74
|
-
# @param text [String]
|
|
75
|
-
# @return [self]
|
|
76
|
-
# @api private
|
|
77
|
-
# mutant:disable - text.to_str and plain text (no .to_s) are genuine equivalents when callers always pass a String
|
|
78
|
-
def add_instruction(text)
|
|
79
|
-
@instruction = text.to_s
|
|
80
|
-
self
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Append a knowledge chunk (Region 3).
|
|
84
|
-
# The chunk is wrapped in an XML context tag automatically.
|
|
85
|
-
#
|
|
86
|
-
# @param text [String]
|
|
87
|
-
# @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
|
|
88
|
-
# @param trusted [Boolean] false (default) indicates externally sourced data
|
|
89
|
-
# @param source [String, nil] optional source label (e.g. filename); included in the
|
|
90
|
-
# XML tag so the LLM can produce grounded citations. Omitted when nil.
|
|
91
|
-
# @return [self]
|
|
92
|
-
# @api private
|
|
93
|
-
# mutant:disable - {text:} (shorthand, no .to_s) and text.to_str are genuine equivalents when text is a String; {type:} shorthand is genuine equivalent because xml_context_tag always calls .to_s on chunk[:type]
|
|
94
|
-
def add_knowledge(text, type:, trusted: false, source: nil)
|
|
95
|
-
@knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
|
|
96
|
-
self
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# Set conversation messages (Region 4). Replaces any previously set messages.
|
|
100
|
-
#
|
|
101
|
-
# @param messages [Array] message-like objects with #role and #content
|
|
102
|
-
# @return [self]
|
|
103
|
-
# @api private
|
|
104
|
-
# mutant:disable - @messages = messages (no Array()) is a genuine equivalent when callers always pass an Array
|
|
105
|
-
def add_messages(messages)
|
|
106
|
-
@messages = Array(messages)
|
|
107
|
-
self
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Returns the number of tokens available for conversation messages after
|
|
111
|
-
# accounting for instruction, knowledge, and capability overhead.
|
|
112
|
-
# Returns +nil+ when no budget is configured.
|
|
113
|
-
#
|
|
114
|
-
# @return [Integer, nil]
|
|
115
|
-
# @api private
|
|
116
|
-
def available_for_messages
|
|
117
|
-
return nil unless @budget
|
|
118
|
-
knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
|
|
119
|
-
system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
|
|
120
|
-
system_text = system_parts.join("\n\n")
|
|
121
|
-
used = TokenEstimator.estimate(system_text) + estimate_capability_tokens
|
|
122
|
-
@budget.available(used: used)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Assemble the context.
|
|
126
|
-
#
|
|
127
|
-
# @return [Hash{Symbol => Object}]
|
|
128
|
-
# :system [String, nil] combined system prompt (instruction + knowledge XML tags)
|
|
129
|
-
# :messages [Array] conversation messages, trimmed to budget if set
|
|
130
|
-
# :tool_classes [Array] tool classes/instances to register with the chat
|
|
131
|
-
# @api private
|
|
132
|
-
# Raises {Phronomy::ContextLengthError} when a budget is set and the
|
|
133
|
-
# conversation messages do not fit within the remaining token allowance.
|
|
134
|
-
# No automatic trimming is performed — callers must pre-process messages
|
|
135
|
-
# before passing them to the Assembler.
|
|
136
|
-
#
|
|
137
|
-
# mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") → map{} is genuine; `unless knowledge_text.empty?` vs ternary is genuine; `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
|
|
138
|
-
def build
|
|
139
|
-
knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
|
|
140
|
-
system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
|
|
141
|
-
system_text = system_parts.join("\n\n")
|
|
142
|
-
|
|
143
|
-
if @budget && @messages.any?
|
|
144
|
-
capability_tokens = estimate_capability_tokens
|
|
145
|
-
used = TokenEstimator.estimate(system_text) + capability_tokens
|
|
146
|
-
remaining = @budget.available(used: used)
|
|
147
|
-
msg_tokens = @messages.sum { |m| TokenEstimator.estimate(m.content.to_s) }
|
|
148
|
-
if msg_tokens > remaining
|
|
149
|
-
raise Phronomy::ContextLengthError,
|
|
150
|
-
"Context exceeds token budget: messages require #{msg_tokens} tokens but " \
|
|
151
|
-
"only #{remaining} available (context_window=#{@budget.context_window}, " \
|
|
152
|
-
"used_by_system=#{used}). Use the Context Policy path to manage message budget."
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
{
|
|
157
|
-
system: system_text.empty? ? nil : system_text,
|
|
158
|
-
messages: @messages,
|
|
159
|
-
tool_classes: @tool_classes
|
|
160
|
-
}
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
private
|
|
164
|
-
|
|
165
|
-
# Estimates the token cost of all registered tool classes.
|
|
166
|
-
# Uses each tool's description and parameter names as a proxy for its
|
|
167
|
-
# JSON Schema size. This is a deliberate simplification — exact token
|
|
168
|
-
# counts require provider-specific schema serialization which lives in
|
|
169
|
-
# RubyLLM. The estimate errs on the side of being slightly conservative
|
|
170
|
-
# so that the conversation budget is not over-allocated.
|
|
171
|
-
def estimate_capability_tokens
|
|
172
|
-
@tool_classes.sum do |tc|
|
|
173
|
-
# Instantiated tool objects (e.g. Phronomy::Tools::Mcp instances) may not be a Class.
|
|
174
|
-
next 0 unless tc.is_a?(Class) && tc.respond_to?(:description)
|
|
175
|
-
|
|
176
|
-
text = [tc.description.to_s]
|
|
177
|
-
if tc.respond_to?(:parameters)
|
|
178
|
-
tc.parameters.each_key { |k| text << k.to_s }
|
|
179
|
-
end
|
|
180
|
-
TokenEstimator.estimate(text.join(" "))
|
|
181
|
-
end
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
# mutant:disable - multiple genuine equivalent mutations: chunk.fetch(key) vs chunk[key] (key always present); chunk[:text] no .to_s / .to_str are genuine (stored as String); chunk[:type] no .to_s / .to_str are genuine (stored as String); chunk[:source] no .to_s / .to_str are genuine (truthy branch, always String); src_attr chunk.fetch(:source) is genuine (source key always present)
|
|
185
|
-
def xml_context_tag(chunk)
|
|
186
|
-
src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
|
|
187
|
-
"<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module LlmContextWindow
|
|
5
|
-
# Caches the assembled static system prompt text keyed by a SHA-256
|
|
6
|
-
# fingerprint of the agent's instructions + static knowledge content.
|
|
7
|
-
# Each instance is owned by one thread (stored in +Thread.current+).
|
|
8
|
-
class ContextVersionCache
|
|
9
|
-
# @return [String, nil] last stored fingerprint
|
|
10
|
-
attr_reader :fingerprint
|
|
11
|
-
|
|
12
|
-
# @return [String, nil] cached system prompt text
|
|
13
|
-
attr_reader :system_text
|
|
14
|
-
|
|
15
|
-
# @return [Integer] estimated token count of #system_text
|
|
16
|
-
attr_reader :system_tokens
|
|
17
|
-
|
|
18
|
-
def initialize
|
|
19
|
-
@fingerprint = nil
|
|
20
|
-
@system_text = nil
|
|
21
|
-
@system_tokens = 0
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Returns true when the given fingerprint matches the stored one.
|
|
25
|
-
#
|
|
26
|
-
# @param fingerprint [String] SHA-256 hex digest to compare
|
|
27
|
-
# @return [Boolean]
|
|
28
|
-
# @api private
|
|
29
|
-
def valid?(fingerprint)
|
|
30
|
-
!@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Update the cache with a new fingerprint and system text.
|
|
34
|
-
#
|
|
35
|
-
# @param fingerprint [String] new SHA-256 hex digest
|
|
36
|
-
# @param system_text [String] fully assembled system prompt text
|
|
37
|
-
# @api private
|
|
38
|
-
def update(fingerprint:, system_text:)
|
|
39
|
-
@fingerprint = fingerprint
|
|
40
|
-
@system_text = system_text.to_s
|
|
41
|
-
@system_tokens = TokenEstimator.estimate(@system_text)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Clear all cached values (used for testing and forced invalidation).
|
|
45
|
-
def reset
|
|
46
|
-
@fingerprint = nil
|
|
47
|
-
@system_text = nil
|
|
48
|
-
@system_tokens = 0
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|