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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. 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