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.
- checksums.yaml +4 -4
- data/README.md +31 -41
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +9 -1
- data/benchmark/bench_regression.rb +8 -8
- data/benchmark/bench_tool_schema.rb +2 -2
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +253 -351
- data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
- data/lib/phronomy/agent/context/capability/base.rb +689 -0
- data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
- data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/invocation_pipeline.rb +10 -1
- data/lib/phronomy/agent/react_agent.rb +24 -23
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +1 -1
- data/lib/phronomy/concurrency/gate_registry.rb +0 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/llm_context_window/assembler.rb +77 -44
- data/lib/phronomy/multi_agent/handoff.rb +4 -4
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +1 -2
- data/lib/phronomy/tool.rb +3 -4
- data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
- data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
- data/lib/phronomy/tools/vector_search.rb +70 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +89 -0
- data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
- data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
- data/lib/phronomy/vector_store/in_memory.rb +103 -0
- data/lib/phronomy/vector_store/loader/base.rb +27 -0
- data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
- data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
- data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
- data/lib/phronomy/vector_store/pgvector.rb +127 -0
- data/lib/phronomy/vector_store/redis_search.rb +192 -0
- data/lib/phronomy/vector_store/splitter/base.rb +49 -0
- data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
- data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
- data/lib/phronomy/vector_store.rb +16 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +2 -1
- data/scripts/api_snapshot.rb +11 -9
- metadata +28 -32
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
- data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
- data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -685
- 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(
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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::
|
|
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::
|
|
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::
|
|
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
|
|
@@ -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
|
|
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.
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
|
98
|
-
# :messages
|
|
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
|
-
#
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
@
|
|
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::
|
|
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::
|
|
36
|
-
# @return [Class<Phronomy::
|
|
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::
|
|
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::
|
|
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::
|
|
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::
|
|
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),
|