phronomy 0.7.1 → 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 +35 -45
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_agent_invoke.rb +1 -1
- data/benchmark/bench_context_assembler.rb +11 -3
- data/benchmark/bench_regression.rb +11 -11
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +2 -2
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
- data/lib/phronomy/agent/base.rb +268 -403
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- 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/instruction/prompt_template.rb +102 -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/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +43 -37
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/shared_state.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/configuration.rb +0 -6
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/eval/runner.rb +4 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +7 -7
- data/lib/phronomy/invocation_context.rb +3 -3
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
- data/lib/phronomy/llm_context_window/assembler.rb +191 -0
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
- data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
- data/lib/phronomy/runtime.rb +20 -6
- data/lib/phronomy/task_group.rb +1 -1
- 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/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store/async_backend.rb +4 -4
- data/lib/phronomy/vector_store/base.rb +2 -2
- 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 +12 -2
- 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 +2 -2
- data/lib/phronomy/vector_store/redis_search.rb +2 -2
- 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 +14 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_context.rb +8 -0
- data/lib/phronomy/workflow_runner.rb +11 -131
- data/lib/phronomy.rb +2 -0
- data/scripts/api_snapshot.rb +11 -9
- metadata +44 -46
- data/lib/phronomy/async_queue.rb +0 -155
- data/lib/phronomy/blocking_adapter_pool.rb +0 -435
- data/lib/phronomy/cancellation_scope.rb +0 -123
- data/lib/phronomy/cancellation_token.rb +0 -133
- data/lib/phronomy/concurrency_gate.rb +0 -155
- data/lib/phronomy/context/assembler.rb +0 -143
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/deadline.rb +0 -63
- data/lib/phronomy/embeddings/base.rb +0 -39
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/embeddings.rb +0 -11
- data/lib/phronomy/fsm_session.rb +0 -247
- data/lib/phronomy/knowledge_source/base.rb +0 -54
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/loader.rb +0 -13
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/splitter.rb +0 -12
- data/lib/phronomy/tool/base.rb +0 -644
- data/lib/phronomy/tool/scope_policy.rb +0 -50
- data/lib/phronomy/tool_executor.rb +0 -106
|
@@ -0,0 +1,191 @@
|
|
|
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, conversation messages are trimmed from oldest to
|
|
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.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# assembler = Phronomy::LlmContextWindow::Assembler.new(budget: budget)
|
|
26
|
+
# assembler.add_instruction("You are a helpful assistant.")
|
|
27
|
+
# assembler.add_knowledge("The user lives in Tokyo.", type: :entity, trusted: false)
|
|
28
|
+
# assembler.add_messages(manager.load(thread_id: "t1", query: user_input))
|
|
29
|
+
# context = assembler.build
|
|
30
|
+
# # => { system: "You are ...\n<context ...>...</context>", messages: [...] }
|
|
31
|
+
class Assembler
|
|
32
|
+
# Builds a single XML context tag string.
|
|
33
|
+
# Exposed as a class method so callers (e.g. Agent::Base) can build
|
|
34
|
+
# static knowledge XML tags independently of an Assembler instance.
|
|
35
|
+
#
|
|
36
|
+
# @param text [String]
|
|
37
|
+
# @param type [Symbol, String]
|
|
38
|
+
# @param trusted [Boolean]
|
|
39
|
+
# @return [String]
|
|
40
|
+
# @api private
|
|
41
|
+
# 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
|
|
42
|
+
def self.xml_tag(text, type:, trusted: false)
|
|
43
|
+
"<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
47
|
+
# when nil no token trimming is performed
|
|
48
|
+
# @api private
|
|
49
|
+
# mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
|
|
50
|
+
def initialize(budget: nil)
|
|
51
|
+
@budget = budget
|
|
52
|
+
@instruction = nil
|
|
53
|
+
@tool_classes = []
|
|
54
|
+
@knowledge_chunks = []
|
|
55
|
+
@messages = []
|
|
56
|
+
end
|
|
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
|
+
|
|
70
|
+
# Set the system instruction text (Region 1).
|
|
71
|
+
# Calling this multiple times replaces the previous value.
|
|
72
|
+
#
|
|
73
|
+
# @param text [String]
|
|
74
|
+
# @return [self]
|
|
75
|
+
# @api private
|
|
76
|
+
# mutant:disable - text.to_str and plain text (no .to_s) are genuine equivalents when callers always pass a String
|
|
77
|
+
def add_instruction(text)
|
|
78
|
+
@instruction = text.to_s
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Append a knowledge chunk (Region 3).
|
|
83
|
+
# The chunk is wrapped in an XML context tag automatically.
|
|
84
|
+
#
|
|
85
|
+
# @param text [String]
|
|
86
|
+
# @param type [Symbol, String] semantic label for the context tag (e.g. :entity, :rag, :static)
|
|
87
|
+
# @param trusted [Boolean] false (default) indicates externally sourced data
|
|
88
|
+
# @param source [String, nil] optional source label (e.g. filename); included in the
|
|
89
|
+
# XML tag so the LLM can produce grounded citations. Omitted when nil.
|
|
90
|
+
# @return [self]
|
|
91
|
+
# @api private
|
|
92
|
+
# 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]
|
|
93
|
+
def add_knowledge(text, type:, trusted: false, source: nil)
|
|
94
|
+
@knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Set conversation messages (Region 4). Replaces any previously set messages.
|
|
99
|
+
#
|
|
100
|
+
# @param messages [Array] message-like objects with #role and #content
|
|
101
|
+
# @return [self]
|
|
102
|
+
# @api private
|
|
103
|
+
# mutant:disable - @messages = messages (no Array()) is a genuine equivalent when callers always pass an Array
|
|
104
|
+
def add_messages(messages)
|
|
105
|
+
@messages = Array(messages)
|
|
106
|
+
self
|
|
107
|
+
end
|
|
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
|
+
|
|
124
|
+
# Assemble the context.
|
|
125
|
+
#
|
|
126
|
+
# @return [Hash{Symbol => Object}]
|
|
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
|
|
130
|
+
# @api private
|
|
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
|
|
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}). Override build_context to trim or compact messages."
|
|
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,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module LlmContextWindow
|
|
5
5
|
# Caches the assembled static system prompt text keyed by a SHA-256
|
|
6
6
|
# fingerprint of the agent's instructions + static knowledge content.
|
|
7
7
|
# Each instance is owned by one thread (stored in +Thread.current+).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module LlmContextWindow
|
|
5
5
|
# Raised when a model name is not found in the RubyLLM model registry and
|
|
6
6
|
# no explicit context_window was provided.
|
|
7
7
|
class UnknownModelError < Phronomy::Error; end
|
|
@@ -17,16 +17,16 @@ module Phronomy
|
|
|
17
17
|
# └─ effective_input_limit (available for memory + knowledge)
|
|
18
18
|
#
|
|
19
19
|
# @example Auto-derive from RubyLLM model registry
|
|
20
|
-
# budget = Phronomy::
|
|
20
|
+
# budget = Phronomy::LlmContextWindow::TokenBudget.new(model: "claude-3-5-sonnet-20241022")
|
|
21
21
|
#
|
|
22
22
|
# @example Explicit values (useful for local / unknown models)
|
|
23
|
-
# budget = Phronomy::
|
|
23
|
+
# budget = Phronomy::LlmContextWindow::TokenBudget.new(
|
|
24
24
|
# context_window: 32_768,
|
|
25
25
|
# max_output_tokens: 4_096
|
|
26
26
|
# )
|
|
27
27
|
#
|
|
28
28
|
# @example With overhead for instructions + tool definitions
|
|
29
|
-
# budget = Phronomy::
|
|
29
|
+
# budget = Phronomy::LlmContextWindow::TokenBudget.new(
|
|
30
30
|
# model: "gpt-4o",
|
|
31
31
|
# overhead: 800
|
|
32
32
|
# )
|
|
@@ -46,6 +46,7 @@ module Phronomy
|
|
|
46
46
|
# and model is given, uses max_output_tokens
|
|
47
47
|
# @param overhead [Integer] tokens reserved for instructions/tools
|
|
48
48
|
# @api private
|
|
49
|
+
# mutant:disable - multiple genuine equivalent mutations: overhead/context_window/max_output_tokens .to_i vs .to_int vs Integer() vs omitted are equivalent for Integer inputs; (max_output_tokens||0).to_i vs (max_output_tokens).to_i and (||nil).to_i are genuine because nil.to_i==0; overhead:nil default is genuine because nil.to_i==0
|
|
49
50
|
def initialize(model: nil, context_window: nil, max_output_tokens: nil, overhead: 0)
|
|
50
51
|
@overhead = overhead.to_i
|
|
51
52
|
|
|
@@ -76,12 +77,14 @@ module Phronomy
|
|
|
76
77
|
# @param used [Integer] tokens already committed (e.g. from knowledge injection)
|
|
77
78
|
# @return [Integer] remaining tokens (always >= 0)
|
|
78
79
|
# @api private
|
|
80
|
+
# mutant:disable - used.to_i vs used vs used.to_int vs Integer(used) are genuine equivalents when used is an Integer; used:nil default is genuine because nil.to_i==0==default 0
|
|
79
81
|
def available(used: 0)
|
|
80
82
|
[effective_input_limit - used.to_i, 0].max
|
|
81
83
|
end
|
|
82
84
|
|
|
83
85
|
private
|
|
84
86
|
|
|
87
|
+
# mutant:disable - raise(UnknownModelError) and raise(UnknownModelError,nil) and raise(UnknownModelError,"Model '#{nil}' not found") in both branches are genuine equivalents (spec checks exception class only, not message text)
|
|
85
88
|
def lookup_model!(model_name)
|
|
86
89
|
found = RubyLLM.models.find(model_name)
|
|
87
90
|
raise UnknownModelError, "Model '#{model_name}' not found in RubyLLM registry" unless found
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module LlmContextWindow
|
|
5
5
|
# Central, stateless token estimation utility.
|
|
6
6
|
#
|
|
7
7
|
# All token counting in the framework passes through this module so that the
|
|
@@ -21,10 +21,10 @@ module Phronomy
|
|
|
21
21
|
# @example Use tiktoken_ruby for accurate GPT token counts
|
|
22
22
|
# require "tiktoken_ruby"
|
|
23
23
|
# enc = Tiktoken.encoding_for_model("gpt-4o")
|
|
24
|
-
# Phronomy::
|
|
24
|
+
# Phronomy::LlmContextWindow::TokenEstimator.tokenizer = ->(text) { enc.encode(text).length }
|
|
25
25
|
#
|
|
26
26
|
# @example Reset to built-in heuristic
|
|
27
|
-
# Phronomy::
|
|
27
|
+
# Phronomy::LlmContextWindow::TokenEstimator.tokenizer = nil
|
|
28
28
|
module TokenEstimator
|
|
29
29
|
@tokenizer = nil
|
|
30
30
|
@tokenizer_mutex = Mutex.new
|
|
@@ -3,16 +3,16 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
|
-
module
|
|
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.
|
|
12
12
|
#
|
|
13
13
|
# @example
|
|
14
14
|
# billing = BillingAgent.new
|
|
15
|
-
# handoff = Phronomy::
|
|
15
|
+
# handoff = Phronomy::MultiAgent::Handoff.new(target_agent: billing)
|
|
16
16
|
# tool_class = handoff.to_tool_class
|
|
17
17
|
class Handoff
|
|
18
18
|
# Prefix embedded in tool results so Runner can detect handoffs.
|
|
@@ -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 }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module MultiAgent
|
|
5
5
|
# Base class for orchestrator agents that coordinate multiple subagents.
|
|
6
6
|
# Implements the Orchestrator-Subagent multi-agent coordination pattern
|
|
7
7
|
# (Anthropic blog, Pattern 2).
|
|
@@ -16,7 +16,7 @@ module Phronomy
|
|
|
16
16
|
# - +fan_out+ for parallel invocation of the same agent across multiple inputs.
|
|
17
17
|
#
|
|
18
18
|
# @example Declarative DSL
|
|
19
|
-
# class ResearchOrchestrator < Phronomy::
|
|
19
|
+
# class ResearchOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
20
20
|
# model "gpt-4o"
|
|
21
21
|
# instructions "You coordinate research tasks."
|
|
22
22
|
# subagent :searcher, SearchAgent
|
|
@@ -26,7 +26,7 @@ module Phronomy
|
|
|
26
26
|
# result = ResearchOrchestrator.new.invoke("Research the latest AI news.")
|
|
27
27
|
#
|
|
28
28
|
# @example Programmatic parallel dispatch
|
|
29
|
-
# class MyOrchestrator < Phronomy::
|
|
29
|
+
# class MyOrchestrator < Phronomy::MultiAgent::Orchestrator
|
|
30
30
|
# model "gpt-4o"
|
|
31
31
|
# instructions "Dispatch tasks in parallel."
|
|
32
32
|
#
|
|
@@ -41,7 +41,7 @@ module Phronomy
|
|
|
41
41
|
#
|
|
42
42
|
# @example Fan-out (same agent, multiple inputs)
|
|
43
43
|
# results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
|
|
44
|
-
class Orchestrator < Base
|
|
44
|
+
class Orchestrator < Agent::Base
|
|
45
45
|
# Declares a named subagent and registers it as a tool accessible to the
|
|
46
46
|
# LLM during an +invoke+ call.
|
|
47
47
|
#
|
|
@@ -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"
|
|
@@ -142,7 +142,7 @@ module Phronomy
|
|
|
142
142
|
# nil means wait indefinitely. When the deadline is exceeded,
|
|
143
143
|
# {Phronomy::TimeoutError} is raised and all surviving tasks are cancelled
|
|
144
144
|
# cooperatively.
|
|
145
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil] when provided, the
|
|
145
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] when provided, the
|
|
146
146
|
# token is merged into each task's config (unless the task already sets one) so
|
|
147
147
|
# that every child agent checks it before making LLM calls.
|
|
148
148
|
# @param invocation_context [Phronomy::InvocationContext, nil] when provided,
|
|
@@ -313,7 +313,7 @@ module Phronomy
|
|
|
313
313
|
end
|
|
314
314
|
|
|
315
315
|
if timeout
|
|
316
|
-
deadline = Phronomy::Deadline.in(timeout)
|
|
316
|
+
deadline = Phronomy::Concurrency::Deadline.in(timeout)
|
|
317
317
|
spawned.each { |t| t.join([deadline.remaining_seconds, 0].max) }
|
|
318
318
|
|
|
319
319
|
alive = spawned.select(&:alive?)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module MultiAgent
|
|
5
5
|
# RubyLLM::Chat subclass that executes multiple tool calls concurrently.
|
|
6
6
|
#
|
|
7
7
|
# When the LLM returns more than one tool call in a single response, each
|
|
@@ -25,7 +25,7 @@ module Phronomy
|
|
|
25
25
|
# @api private
|
|
26
26
|
class ParallelToolChat < RubyLLM::Chat
|
|
27
27
|
# @param max_parallel_tools [Integer] maximum simultaneous tool executions
|
|
28
|
-
# @param cancellation_token [Phronomy::CancellationToken, nil] token observed before each batch
|
|
28
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] token observed before each batch
|
|
29
29
|
# @param opts [Hash] remaining kwargs forwarded to RubyLLM::Chat
|
|
30
30
|
# @api private
|
|
31
31
|
def initialize(max_parallel_tools: 10, cancellation_token: nil, **opts)
|
|
@@ -95,7 +95,7 @@ module Phronomy
|
|
|
95
95
|
}}
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
-
awaitable = Phronomy::ToolExecutor.call_async(
|
|
98
|
+
awaitable = Phronomy::Agent::ToolExecutor.call_async(
|
|
99
99
|
tool: tool,
|
|
100
100
|
args: tc.arguments,
|
|
101
101
|
cancellation_token: ct
|
|
@@ -138,7 +138,7 @@ module Phronomy
|
|
|
138
138
|
}
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
Phronomy::ToolExecutor.call_async(
|
|
141
|
+
Phronomy::Agent::ToolExecutor.call_async(
|
|
142
142
|
tool: tool,
|
|
143
143
|
args: tool_call.arguments,
|
|
144
144
|
cancellation_token: @cancellation_token
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module MultiAgent
|
|
5
5
|
# Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
|
|
6
6
|
#
|
|
7
7
|
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
@@ -24,7 +24,7 @@ module Phronomy
|
|
|
24
24
|
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
25
25
|
#
|
|
26
26
|
# @example Basic usage
|
|
27
|
-
# class MigrationTeam < Phronomy::
|
|
27
|
+
# class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
|
|
28
28
|
# coordinator_model "claude-3-5-sonnet-20241022"
|
|
29
29
|
# coordinator_instructions <<~INST
|
|
30
30
|
# Analyze the request and enqueue one migration task per service.
|
|
@@ -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),
|
data/lib/phronomy/runtime.rb
CHANGED
|
@@ -8,8 +8,6 @@ require_relative "runtime/timer_queue"
|
|
|
8
8
|
require_relative "runtime/scheduler_timer_adapter"
|
|
9
9
|
require_relative "runtime/task_registry"
|
|
10
10
|
require_relative "runtime/runtime_metrics"
|
|
11
|
-
require_relative "runtime/gate_registry"
|
|
12
|
-
require_relative "runtime/pool_registry"
|
|
13
11
|
require_relative "runtime/timer_service"
|
|
14
12
|
|
|
15
13
|
module Phronomy
|
|
@@ -99,6 +97,23 @@ module Phronomy
|
|
|
99
97
|
!Task.current.nil?
|
|
100
98
|
end
|
|
101
99
|
|
|
100
|
+
# Executes +block+ and returns +[result, elapsed_ms]+ where +elapsed_ms+
|
|
101
|
+
# is the wall-clock duration in milliseconds (Integer, rounded).
|
|
102
|
+
#
|
|
103
|
+
# Isolates all direct references to +Process.clock_gettime+ /
|
|
104
|
+
# +Process::CLOCK_MONOTONIC+ in one place so that callers stay at the
|
|
105
|
+
# framework abstraction level.
|
|
106
|
+
#
|
|
107
|
+
# @yield block to time
|
|
108
|
+
# @return [Array(Object, Integer)] +[block_return_value, elapsed_ms]+
|
|
109
|
+
# @api private
|
|
110
|
+
def self.measure_ms
|
|
111
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
result = yield
|
|
113
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
114
|
+
[result, elapsed_ms]
|
|
115
|
+
end
|
|
116
|
+
|
|
102
117
|
# The scheduler backing this runtime instance.
|
|
103
118
|
# @return [Scheduler]
|
|
104
119
|
attr_reader :scheduler
|
|
@@ -109,8 +124,8 @@ module Phronomy
|
|
|
109
124
|
@scheduler = scheduler
|
|
110
125
|
@task_registry = TaskRegistry.new
|
|
111
126
|
@metrics = RuntimeMetrics.new
|
|
112
|
-
@gate_registry = GateRegistry.new
|
|
113
|
-
@pool_registry = PoolRegistry.new
|
|
127
|
+
@gate_registry = Phronomy::Concurrency::GateRegistry.new
|
|
128
|
+
@pool_registry = Phronomy::Concurrency::PoolRegistry.new
|
|
114
129
|
@timer_service = TimerService.new(scheduler)
|
|
115
130
|
end
|
|
116
131
|
|
|
@@ -120,7 +135,7 @@ module Phronomy
|
|
|
120
135
|
# is first accessed; subsequent calls return the cached gate. To change the
|
|
121
136
|
# cap at runtime, call {#reset_gate} first.
|
|
122
137
|
#
|
|
123
|
-
# @param name [:agent, :tool, :workflow, :llm, :
|
|
138
|
+
# @param name [:agent, :tool, :workflow, :llm, :vector] resource name
|
|
124
139
|
# @return [ConcurrencyGate]
|
|
125
140
|
# @api private
|
|
126
141
|
def gate(name)
|
|
@@ -264,7 +279,6 @@ module Phronomy
|
|
|
264
279
|
# | `active_agent_tasks` | currently running agent spawns |
|
|
265
280
|
# | `active_tool_tasks` | currently running tool spawns |
|
|
266
281
|
# | `active_workflow_tasks` | currently running workflow spawns |
|
|
267
|
-
# | `active_rag_tasks` | currently running RAG fetches |
|
|
268
282
|
# | `active_llm_tasks` | currently running LLM calls |
|
|
269
283
|
# | `task_wait_time_p50_ms` | p50 spawn-to-start latency (ms) |
|
|
270
284
|
# | `task_wait_time_p95_ms` | p95 spawn-to-start latency (ms) |
|
data/lib/phronomy/task_group.rb
CHANGED
|
@@ -108,7 +108,7 @@ module Phronomy
|
|
|
108
108
|
# @param tasks [Array<Task>]
|
|
109
109
|
# @return [Array]
|
|
110
110
|
def _await_all_cooperative(tasks)
|
|
111
|
-
completion_q = AsyncQueue.new
|
|
111
|
+
completion_q = Phronomy::Concurrency::AsyncQueue.new
|
|
112
112
|
tasks.each_with_index do |task, idx|
|
|
113
113
|
task.on_complete do |value, error|
|
|
114
114
|
completion_q.push({index: idx, value: value, error: error})
|
data/lib/phronomy/tool.rb
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
# This file is intentionally empty.
|
|
4
|
+
# Tool definitions have moved to Phronomy::Agent::Context::Capability.
|
|
5
|
+
# See lib/phronomy/agent/context/capability/.
|
|
7
6
|
module Phronomy
|
|
8
7
|
module Tool
|
|
9
8
|
end
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
module
|
|
4
|
+
module Tools
|
|
5
5
|
# Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
|
|
6
6
|
# ReactAgent (or any agent that supports tools) can delegate sub-tasks to a
|
|
7
7
|
# fully-capable agent.
|
|
8
8
|
#
|
|
9
|
-
# Use
|
|
9
|
+
# Use Agent.from_agent to generate a concrete tool class. The generated
|
|
10
10
|
# class is anonymous; assign it to a constant when you need a stable name.
|
|
11
11
|
#
|
|
12
12
|
# @example Wrap an existing agent
|
|
13
|
-
# SummarizerTool = Phronomy::
|
|
13
|
+
# SummarizerTool = Phronomy::Tools::Agent.from_agent(
|
|
14
14
|
# SummarizerAgent,
|
|
15
15
|
# tool_name: "summarize",
|
|
16
16
|
# description: "Summarizes a long text and returns a brief summary"
|
|
@@ -21,12 +21,12 @@ module Phronomy
|
|
|
21
21
|
# instructions "You are an orchestrator that delegates to specialist agents."
|
|
22
22
|
# tools SummarizerTool
|
|
23
23
|
# end
|
|
24
|
-
class
|
|
24
|
+
class Agent < Phronomy::Agent::Context::Capability::Base
|
|
25
25
|
description "Wraps an agent as a tool"
|
|
26
26
|
param :input, type: :string, desc: "The input to forward to the wrapped agent"
|
|
27
27
|
|
|
28
28
|
class << self
|
|
29
|
-
# Generates a Phronomy::
|
|
29
|
+
# Generates a Phronomy::Tools::Agent subclass that delegates #execute to
|
|
30
30
|
# an instance of +agent_class+.
|
|
31
31
|
#
|
|
32
32
|
# @param agent_class [Class] a Phronomy::Agent::Base subclass
|
|
@@ -34,7 +34,7 @@ module Phronomy
|
|
|
34
34
|
# defaults to a snake_case derivation of the agent class name
|
|
35
35
|
# @param description [String, nil] description exposed to the LLM;
|
|
36
36
|
# defaults to "Delegates to <AgentClassName>"
|
|
37
|
-
# @return [Class] an anonymous Phronomy::
|
|
37
|
+
# @return [Class] an anonymous Phronomy::Tools::Agent subclass
|
|
38
38
|
# @api public
|
|
39
39
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
40
40
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
@@ -8,8 +8,8 @@ require "shellwords"
|
|
|
8
8
|
require "uri"
|
|
9
9
|
|
|
10
10
|
module Phronomy
|
|
11
|
-
module
|
|
12
|
-
# A Phronomy::
|
|
11
|
+
module Tools
|
|
12
|
+
# A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
|
|
13
13
|
# MCP (Model Context Protocol) server.
|
|
14
14
|
#
|
|
15
15
|
# Supports two transport schemes:
|
|
@@ -19,15 +19,15 @@ module Phronomy
|
|
|
19
19
|
# HTTP/SSE MCP server using +net/http+.
|
|
20
20
|
#
|
|
21
21
|
# @example
|
|
22
|
-
# web_search = Phronomy::
|
|
22
|
+
# web_search = Phronomy::Tools::Mcp.from_server(
|
|
23
23
|
# "stdio://./mcp-server",
|
|
24
24
|
# tool_name: "search_web"
|
|
25
25
|
# )
|
|
26
26
|
# agent = MyAgent.new
|
|
27
27
|
# agent_class.tools(web_search)
|
|
28
|
-
class
|
|
28
|
+
class Mcp < Phronomy::Agent::Context::Capability::Base
|
|
29
29
|
class << self
|
|
30
|
-
# Build a
|
|
30
|
+
# Build a Mcp instance by querying a running MCP server for the
|
|
31
31
|
# tool definition identified by +tool_name+.
|
|
32
32
|
#
|
|
33
33
|
# @param server_uri [String] URI of the MCP server.
|
|
@@ -35,11 +35,11 @@ module Phronomy
|
|
|
35
35
|
# - "stdio://<command>" — spawn a child process
|
|
36
36
|
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
37
37
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
38
|
-
# @return [
|
|
38
|
+
# @return [Mcp] a configured subclass instance ready for use with an Agent
|
|
39
39
|
# @api public
|
|
40
40
|
def from_server(server_uri, tool_name:)
|
|
41
41
|
# Use a short-lived transport only to query the tool definition,
|
|
42
|
-
# then close it. Each
|
|
42
|
+
# then close it. Each Mcp instance creates its own transport
|
|
43
43
|
# so that concurrent callers never share IO streams.
|
|
44
44
|
transport = build_transport(server_uri)
|
|
45
45
|
begin
|
|
@@ -65,7 +65,7 @@ module Phronomy
|
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def build_tool_class(tool_name, server_uri, tool_def)
|
|
68
|
-
klass = Class.new(
|
|
68
|
+
klass = Class.new(Mcp)
|
|
69
69
|
klass.tool_name(tool_name)
|
|
70
70
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
71
71
|
|
|
@@ -289,7 +289,7 @@ module Phronomy
|
|
|
289
289
|
# both the 2024-11-05 and 2025-03-26 MCP HTTP transport specifications.
|
|
290
290
|
#
|
|
291
291
|
# @example
|
|
292
|
-
# tool = Phronomy::
|
|
292
|
+
# tool = Phronomy::Tools::Mcp.from_server(
|
|
293
293
|
# "http://localhost:8080/mcp",
|
|
294
294
|
# tool_name: "weather_lookup"
|
|
295
295
|
# )
|