phronomy 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -41
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +9 -1
  6. data/benchmark/bench_regression.rb +8 -8
  7. data/benchmark/bench_tool_schema.rb +2 -2
  8. data/benchmark/bench_vector_store.rb +1 -1
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +253 -351
  11. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  12. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  13. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  14. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  15. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  17. data/lib/phronomy/agent/invocation_pipeline.rb +10 -1
  18. data/lib/phronomy/agent/react_agent.rb +24 -23
  19. data/lib/phronomy/agent/shared_state.rb +2 -2
  20. data/lib/phronomy/agent/tool_executor.rb +1 -1
  21. data/lib/phronomy/concurrency/gate_registry.rb +0 -1
  22. data/lib/phronomy/configuration.rb +0 -6
  23. data/lib/phronomy/llm_context_window/assembler.rb +77 -44
  24. data/lib/phronomy/multi_agent/handoff.rb +4 -4
  25. data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
  26. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
  27. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  28. data/lib/phronomy/runtime.rb +1 -2
  29. data/lib/phronomy/tool.rb +3 -4
  30. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  31. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  32. data/lib/phronomy/tools/vector_search.rb +70 -0
  33. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  34. data/lib/phronomy/vector_store/base.rb +89 -0
  35. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  36. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  37. data/lib/phronomy/vector_store/in_memory.rb +103 -0
  38. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  39. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  40. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  41. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  42. data/lib/phronomy/vector_store/pgvector.rb +127 -0
  43. data/lib/phronomy/vector_store/redis_search.rb +192 -0
  44. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  45. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  46. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  47. data/lib/phronomy/vector_store.rb +16 -4
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy.rb +2 -1
  50. data/scripts/api_snapshot.rb +11 -9
  51. metadata +28 -32
  52. data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
  53. data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
  54. data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
  55. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
  56. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
  57. data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
  58. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
  59. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
  60. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
  61. data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
  62. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
  63. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
  64. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
  65. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
  66. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
  67. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
  68. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
  69. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
  70. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
  71. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
  72. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
  73. data/lib/phronomy/embeddings.rb +0 -11
  74. data/lib/phronomy/loader.rb +0 -13
  75. data/lib/phronomy/splitter.rb +0 -12
  76. data/lib/phronomy/tool/base.rb +0 -685
  77. data/lib/phronomy/tool/scope_policy.rb +0 -50
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Context
6
+ module Capability
7
+ # Evaluates whether a tool with a given scope may execute.
8
+ #
9
+ # A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
10
+ # returns one of:
11
+ # +:allow+ — proceed immediately without an approval gate.
12
+ # +:reject+ — block execution; the tool returns a denial message.
13
+ # +:approve+ — delegate to the agent's approval handler (if registered);
14
+ # when no handler is registered the call is rejected.
15
+ #
16
+ # The {Default} instance is used automatically when no custom policy is
17
+ # configured on an agent.
18
+ #
19
+ # @example Custom policy that allows everything
20
+ # agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
21
+ #
22
+ # @example Strict policy that rejects all write scopes
23
+ # agent.scope_policy = ->(_tc, scope, _agent) {
24
+ # scope == :write ? :reject : :allow
25
+ # }
26
+ class ScopePolicy
27
+ # Scopes that must go through an approval gate before execution.
28
+ APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
29
+
30
+ # Scopes that are always permitted without approval.
31
+ ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
32
+
33
+ # Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
34
+ # scopes, and +:allow+ for anything else (including +nil+).
35
+ #
36
+ # @param _tool_class [Class]
37
+ # @param scope [Symbol, nil]
38
+ # @param _agent [Object]
39
+ # @return [:allow, :approve, :reject]
40
+ # @api private
41
+ def call(_tool_class, scope, _agent)
42
+ return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
43
+ return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
44
+
45
+ :allow
46
+ end
47
+
48
+ # Shared singleton used when no custom policy is configured.
49
+ DEFAULT = new.freeze
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,58 @@
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
@@ -0,0 +1,102 @@
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
@@ -0,0 +1,58 @@
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
@@ -43,8 +43,17 @@ module Phronomy
43
43
 
44
44
  # Assemble context (system prompt + history). Override #build_context to
45
45
  # inject custom context editing logic at the Agent subclass level.
46
- context = build_context(inp, messages: msgs, thread_id: tid, config: cfg)
46
+ context = build_context(
47
+ inp,
48
+ messages: msgs,
49
+ thread_id: tid,
50
+ config: cfg,
51
+ budget: build_token_budget,
52
+ instruction: build_instructions(inp),
53
+ tools: self.class.tools + _handoff_tools
54
+ )
47
55
  apply_instructions(chat, context[:system]) if context[:system]
56
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
48
57
  context[:messages].each { |msg| chat.messages << msg }
49
58
 
50
59
  # Run before_completion hooks (global → class → instance) before the LLM call.
@@ -117,20 +117,18 @@ module Phronomy
117
117
  def step(messages, initial_input, user_asked: false, thread_id: nil, config: {})
118
118
  chat = build_chat
119
119
 
120
- if user_asked
121
- # Subsequent loop iteration — messages already contains the full conversation
122
- # (including the user's original input from the first step); apply system
123
- # instructions and replay the accumulated history, then let the LLM continue.
124
- system_text = build_cached_system_text(initial_input)
125
- apply_instructions(chat, system_text) if system_text
126
- messages.each { |m| chat.add_message(m) }
127
- else
128
- # First iteration — assemble context (system + history) via build_context so
129
- # that trimming, compaction, and knowledge sources are applied consistently.
130
- context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
131
- apply_instructions(chat, context[:system]) if context[:system]
132
- context[:messages].each { |m| chat.messages << m }
133
- end
120
+ context = build_context(
121
+ initial_input,
122
+ messages: messages,
123
+ thread_id: thread_id,
124
+ config: config,
125
+ budget: build_token_budget,
126
+ instruction: build_instructions(initial_input),
127
+ tools: self.class.tools + _handoff_tools
128
+ )
129
+ apply_instructions(chat, context[:system]) if context[:system]
130
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
131
+ context[:messages].each { |m| chat.add_message(m) }
134
132
 
135
133
  # Run before_completion hooks before each LLM call in the ReAct loop.
136
134
  run_before_completion_hooks!(chat, config)
@@ -155,15 +153,18 @@ module Phronomy
155
153
  def stream_step(messages, initial_input, user_asked: false, thread_id: nil, config: {}, &block)
156
154
  chat = build_chat
157
155
 
158
- if user_asked
159
- system_text = build_cached_system_text(initial_input)
160
- apply_instructions(chat, system_text) if system_text
161
- messages.each { |m| chat.add_message(m) }
162
- else
163
- context = build_context(initial_input, messages: messages, thread_id: thread_id, config: config)
164
- apply_instructions(chat, context[:system]) if context[:system]
165
- context[:messages].each { |m| chat.messages << m }
166
- end
156
+ context = build_context(
157
+ initial_input,
158
+ messages: messages,
159
+ thread_id: thread_id,
160
+ config: config,
161
+ budget: build_token_budget,
162
+ instruction: build_instructions(initial_input),
163
+ tools: self.class.tools + _handoff_tools
164
+ )
165
+ apply_instructions(chat, context[:system]) if context[:system]
166
+ (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
167
+ context[:messages].each { |m| chat.add_message(m) }
167
168
 
168
169
  current_tool_call = nil
169
170
  chat.on_tool_call do |tc|
@@ -239,7 +239,7 @@ module Phronomy
239
239
  def build_instrumented_researcher(researcher_class, store, cycle)
240
240
  agent_key = researcher_class.name&.to_sym || researcher_class.object_id.to_s.to_sym
241
241
 
242
- read_tool = Class.new(Phronomy::Tool::Base) do
242
+ read_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
243
243
  tool_name "read_store"
244
244
  description "Read all current findings from the shared knowledge store. " \
245
245
  "Call this to see what other researchers have discovered."
@@ -247,7 +247,7 @@ module Phronomy
247
247
  define_method(:execute) { store.read_all.to_json }
248
248
  end
249
249
 
250
- write_tool = Class.new(Phronomy::Tool::Base) do
250
+ write_tool = Class.new(Phronomy::Agent::Context::Capability::Base) do
251
251
  tool_name "write_finding"
252
252
  description "Record a new finding into the shared knowledge store so " \
253
253
  "that other researchers can build on your discovery."
@@ -51,7 +51,7 @@ module Phronomy
51
51
  # Dispatches a single tool call asynchronously according to its
52
52
  # +execution_mode+ and returns an awaitable.
53
53
  #
54
- # @param tool [Phronomy::Tool::Base] the tool instance to invoke
54
+ # @param tool [Phronomy::Agent::Context::Capability::Base] the tool instance to invoke
55
55
  # @param args [Hash] argument hash to pass to {Tool::Base#call}
56
56
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
57
57
  # @param runtime [Phronomy::Runtime] runtime to use for spawning
@@ -14,7 +14,6 @@ module Phronomy
14
14
  tool: :max_concurrent_tool_tasks,
15
15
  workflow: :max_concurrent_workflow_tasks,
16
16
  llm: :max_concurrent_llm_calls,
17
- rag: :max_concurrent_rag_fetches,
18
17
  vector: :max_concurrent_vector_searches
19
18
  }.freeze
20
19
  private_constant :GATE_CONFIG_MAP
@@ -140,11 +140,6 @@ module Phronomy
140
140
  # @return [Integer, nil]
141
141
  attr_accessor :stream_queue_max_size
142
142
 
143
- # Maximum number of concurrent RAG knowledge-source fetches in-flight.
144
- # nil = unlimited (default).
145
- # @return [Integer, nil]
146
- attr_accessor :max_concurrent_rag_fetches
147
-
148
143
  # Maximum number of concurrent vector-store searches in-flight.
149
144
  # nil = unlimited (default).
150
145
  # @return [Integer, nil]
@@ -204,7 +199,6 @@ module Phronomy
204
199
  @max_concurrent_workflow_tasks = nil
205
200
  @max_concurrent_llm_calls = nil
206
201
  @stream_queue_max_size = nil
207
- @max_concurrent_rag_fetches = nil
208
202
  @max_concurrent_vector_searches = nil
209
203
  @starvation_threshold_ms = 50
210
204
  @runtime_backend = :thread
@@ -5,19 +5,21 @@ require "cgi"
5
5
  module Phronomy
6
6
  module LlmContextWindow
7
7
  # Assembler collects all four context regions and produces the final
8
- # {system:, messages:} hash consumed by Agent::Base.
8
+ # {system:, messages:, tool_classes:} hash consumed by Agent::Base.
9
9
  #
10
10
  # Regions:
11
11
  # 1. Instruction — system prompt text set via #add_instruction
12
- # 2. Capability — tool definitions (handled by RubyLLM, not here)
12
+ # 2. Capability — tool classes registered via #add_capability
13
13
  # 3. Knowledge — external facts injected via #add_knowledge (generates XML tags)
14
14
  # 4. Conversation — historical messages added via #add_messages
15
15
  #
16
16
  # Token budgeting:
17
17
  # When a budget is given, conversation messages are trimmed from oldest to
18
- # newest until they fit. Knowledge chunks are always included in full (they
19
- # are assumed to be pre-screened by the caller). When no budget is given all
20
- # messages are passed through unchanged.
18
+ # newest until they fit. Capability token cost is estimated and deducted
19
+ # from the budget before conversation trimming so the reserve is accurate.
20
+ # Knowledge chunks are always included in full (they are assumed to be
21
+ # pre-screened by the caller). When no budget is given all messages are
22
+ # passed through unchanged.
21
23
  #
22
24
  # @example
23
25
  # assembler = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
@@ -48,10 +50,23 @@ module Phronomy
48
50
  def initialize(budget: nil)
49
51
  @budget = budget
50
52
  @instruction = nil
53
+ @tool_classes = []
51
54
  @knowledge_chunks = []
52
55
  @messages = []
53
56
  end
54
57
 
58
+ # Register tool classes (Region 2).
59
+ # Estimates their token cost and deducts it from the budget so that
60
+ # conversation trimming accounts for tool definition overhead.
61
+ #
62
+ # @param tool_classes [Array<Class, Object>] tool classes or instances
63
+ # @return [self]
64
+ # @api private
65
+ def add_capability(tool_classes)
66
+ @tool_classes = Array(tool_classes)
67
+ self
68
+ end
69
+
55
70
  # Set the system instruction text (Region 1).
56
71
  # Calling this multiple times replaces the previous value.
57
72
  #
@@ -91,68 +106,86 @@ module Phronomy
91
106
  self
92
107
  end
93
108
 
109
+ # Returns the number of tokens available for conversation messages after
110
+ # accounting for instruction, knowledge, and capability overhead.
111
+ # Returns +nil+ when no budget is configured.
112
+ #
113
+ # @return [Integer, nil]
114
+ # @api private
115
+ def available_for_messages
116
+ return nil unless @budget
117
+ knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
118
+ system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
119
+ system_text = system_parts.join("\n\n")
120
+ used = TokenEstimator.estimate(system_text) + estimate_capability_tokens
121
+ @budget.available(used: used)
122
+ end
123
+
94
124
  # Assemble the context.
95
125
  #
96
126
  # @return [Hash{Symbol => Object}]
97
- # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
98
- # :messages [Array] conversation messages, trimmed to budget if set
127
+ # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
128
+ # :messages [Array] conversation messages, trimmed to budget if set
129
+ # :tool_classes [Array] tool classes/instances to register with the chat
99
130
  # @api private
100
- # mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") map{} is genuine because Ruby Array#join recursively joins nested arrays with the same separator (so [outer_array].join("\n\n") == original String); `unless knowledge_text.empty?` vs ternary is genuine (same conditional logic); `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
131
+ # Raises {Phronomy::ContextLengthError} when a budget is set and the
132
+ # conversation messages do not fit within the remaining token allowance.
133
+ # No automatic trimming is performed — callers must pre-process messages
134
+ # (e.g. via Agent::Base#trim_messages or #compact_messages) before
135
+ # 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
101
138
  def build
102
139
  knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
103
140
  system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
104
141
  system_text = system_parts.join("\n\n")
105
142
 
106
- messages = if @budget
107
- trim_messages_to_budget(@messages, system_text)
108
- else
109
- @messages
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}). Override build_context to trim or compact messages."
153
+ end
110
154
  end
111
155
 
112
156
  {
113
157
  system: system_text.empty? ? nil : system_text,
114
- messages: messages
158
+ messages: @messages,
159
+ tool_classes: @tool_classes
115
160
  }
116
161
  end
117
162
 
118
163
  private
119
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
+
120
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)
121
185
  def xml_context_tag(chunk)
122
186
  src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
123
187
  "<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
124
188
  end
125
-
126
- # mutant:disable - multiple genuine equivalent mutations on the early-return guard:
127
- # `remaining <= 0 && false/nil`, `if false`, `if nil`, `if remaining && messages.empty?`,
128
- # `if remaining < 0 && messages.empty?`, `if remaining <= -1 && messages.empty?`,
129
- # `if remaining <= 1 && messages.empty?`, `if remaining == 0 && messages.empty?`,
130
- # `if remaining.eql?(0) && messages.empty?`, `if remaining.equal?(0) && messages.empty?`,
131
- # `if 0 && messages.empty?`, `if nil && messages.empty?` —
132
- # all are genuine equivalents because when messages.empty? the loop produces [] anyway,
133
- # and remaining is always >= 0 (clamp(0..)) so `remaining < 0` / `<= -1` are never true.
134
- def trim_messages_to_budget(messages, system_text)
135
- used = TokenEstimator.estimate(system_text)
136
- remaining = @budget.available(used: used)
137
- return messages if remaining <= 0 && messages.empty?
138
-
139
- accumulated = 0
140
- result = []
141
- messages.reverse_each do |msg|
142
- tokens = TokenEstimator.estimate(msg.content.to_s)
143
- break if accumulated + tokens > remaining
144
-
145
- accumulated += tokens
146
- result.push(msg)
147
- end
148
-
149
- if result.empty? && messages.any?
150
- warn "[Phronomy::Assembler] All #{messages.length} conversation message(s) dropped: " \
151
- "token budget exhausted by system context (budget=#{@budget.context_window}, used_by_system=#{used})"
152
- end
153
-
154
- result.reverse
155
- end
156
189
  end
157
190
  end
158
191
  end
@@ -5,7 +5,7 @@ require "securerandom"
5
5
  module Phronomy
6
6
  module MultiAgent
7
7
  # Represents a transfer edge from one agent to another.
8
- # Creates an anonymous Phronomy::Tool::Base subclass that the source agent
8
+ # Creates an anonymous Phronomy::Agent::Context::Capability::Base subclass that the source agent
9
9
  # exposes to the LLM as a +transfer_to_<name>+ function.
10
10
  # The tool's execute method returns a sentinel string that Runner uses to
11
11
  # detect which target agent to route to next.
@@ -32,14 +32,14 @@ module Phronomy
32
32
  @description = description || "Transfer the conversation to #{klass_name}."
33
33
  end
34
34
 
35
- # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
36
- # @return [Class<Phronomy::Tool::Base>]
35
+ # Builds an anonymous Phronomy::Agent::Context::Capability::Base subclass for this handoff.
36
+ # @return [Class<Phronomy::Agent::Context::Capability::Base>]
37
37
  # @api public
38
38
  def to_tool_class
39
39
  sentinel_value = sentinel
40
40
  tn = tool_name
41
41
  desc = description
42
- Class.new(Phronomy::Tool::Base) do
42
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
43
43
  tool_name tn
44
44
  description desc
45
45
  define_method(:execute) { sentinel_value }
@@ -57,7 +57,7 @@ module Phronomy
57
57
  # proceed
58
58
  # @api public
59
59
  def self.subagent(name, agent_class, on_error: :raise)
60
- tool_class = Class.new(Phronomy::Tool::Base) do
60
+ tool_class = Class.new(Phronomy::Agent::Context::Capability::Base) do
61
61
  tool_name "dispatch_to_#{name}"
62
62
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
63
63
  param :input, type: :string, desc: "The task or question for the subagent"
@@ -265,7 +265,7 @@ module Phronomy
265
265
 
266
266
  # Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
267
267
  def build_enqueue_tool(task_queue)
268
- Class.new(Phronomy::Tool::Base) do
268
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
269
269
  tool_name "enqueue_task"
270
270
  description "Add a task to the worker queue."
271
271
  param :description, type: :string, desc: "What the worker agent should do"
@@ -282,7 +282,7 @@ module Phronomy
282
282
  # Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
283
283
  # have been enqueued; returns a confirmation string.
284
284
  def build_finalize_tool(task_queue)
285
- Class.new(Phronomy::Tool::Base) do
285
+ Class.new(Phronomy::Agent::Context::Capability::Base) do
286
286
  tool_name "finalize"
287
287
  description "Signal that task generation is complete. Call this after all tasks have been enqueued."
288
288
  param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
@@ -90,7 +90,6 @@ module Phronomy
90
90
  active_agent_tasks: active[:agent].to_i,
91
91
  active_tool_tasks: active[:tool].to_i,
92
92
  active_workflow_tasks: active[:workflow].to_i,
93
- active_rag_tasks: active[:rag].to_i,
94
93
  active_llm_tasks: active[:llm].to_i,
95
94
  task_wait_time_p50_ms: _percentile(wait, 50),
96
95
  task_wait_time_p95_ms: _percentile(wait, 95),