phronomy 0.7.1 → 0.8.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 +16 -16
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +5 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/lib/phronomy/agent/base.rb +86 -123
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +1 -1
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -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 +19 -14
- data/lib/phronomy/agent/runner.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 -1
- data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/embeddings.rb +2 -2
- 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/{context → llm_context_window}/assembler.rb +18 -3
- 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/loader.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
- data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime.rb +19 -4
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task_group.rb +1 -1
- data/lib/phronomy/tool/base.rb +50 -9
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/vector_store.rb +2 -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 +1 -0
- metadata +44 -42
- 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/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/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/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/tool_executor.rb +0 -106
- data/lib/phronomy/vector_store/async_backend.rb +0 -110
- data/lib/phronomy/vector_store/base.rb +0 -89
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -56,6 +56,124 @@ module Phronomy
|
|
|
56
56
|
@pending_tool_args = pending_tool_args
|
|
57
57
|
@pending_tool_call_id = pending_tool_call_id
|
|
58
58
|
end
|
|
59
|
+
|
|
60
|
+
# Converts this checkpoint to a plain Hash suitable for JSON / Marshal serialization.
|
|
61
|
+
#
|
|
62
|
+
# All values are plain Ruby objects (String, Symbol, Hash, Array, Numeric,
|
|
63
|
+
# nil). +RubyLLM::Message+ objects in +:messages+ are deep-converted so that
|
|
64
|
+
# any embedded +RubyLLM::ToolCall+ objects are also serialized as plain hashes.
|
|
65
|
+
#
|
|
66
|
+
# @example Round-trip via JSON
|
|
67
|
+
# json = JSON.generate(checkpoint.to_h)
|
|
68
|
+
# checkpoint2 = Phronomy::Agent::Checkpoint.from_h(JSON.parse(json))
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash]
|
|
71
|
+
# @api public
|
|
72
|
+
def to_h
|
|
73
|
+
{
|
|
74
|
+
thread_id: @thread_id,
|
|
75
|
+
original_input: @original_input,
|
|
76
|
+
messages: @messages.map { |m| serialize_message(m) },
|
|
77
|
+
pending_tool_name: @pending_tool_name,
|
|
78
|
+
pending_tool_args: @pending_tool_args,
|
|
79
|
+
pending_tool_call_id: @pending_tool_call_id
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Reconstructs a +Checkpoint+ from a plain Hash (e.g. produced by {#to_h}
|
|
84
|
+
# and deserialized from JSON or Marshal).
|
|
85
|
+
#
|
|
86
|
+
# Hash keys may be either Symbols or Strings; both are accepted.
|
|
87
|
+
# +RubyLLM::ToolCall+ objects inside message +:tool_calls+ arrays are
|
|
88
|
+
# reconstructed from their hash representations.
|
|
89
|
+
#
|
|
90
|
+
# @param h [Hash] a hash previously produced by {#to_h}
|
|
91
|
+
# @return [Checkpoint]
|
|
92
|
+
# @api public
|
|
93
|
+
def self.from_h(h)
|
|
94
|
+
h = h.transform_keys { |k|
|
|
95
|
+
begin
|
|
96
|
+
k.to_sym
|
|
97
|
+
rescue
|
|
98
|
+
k
|
|
99
|
+
end
|
|
100
|
+
}
|
|
101
|
+
messages = Array(h[:messages]).map { |m| deserialize_message(m) }
|
|
102
|
+
new(
|
|
103
|
+
thread_id: h[:thread_id],
|
|
104
|
+
original_input: h[:original_input],
|
|
105
|
+
messages: messages,
|
|
106
|
+
pending_tool_name: h[:pending_tool_name]&.to_s,
|
|
107
|
+
pending_tool_args: h[:pending_tool_args] ? h[:pending_tool_args].transform_keys { |k|
|
|
108
|
+
begin
|
|
109
|
+
k.to_sym
|
|
110
|
+
rescue
|
|
111
|
+
k
|
|
112
|
+
end
|
|
113
|
+
} : {},
|
|
114
|
+
pending_tool_call_id: h[:pending_tool_call_id]&.to_s
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Converts a +RubyLLM::Message+ to a plain Hash, ensuring that any
|
|
121
|
+
# embedded +RubyLLM::ToolCall+ objects in +:tool_calls+ are also converted.
|
|
122
|
+
#
|
|
123
|
+
# @param msg [RubyLLM::Message]
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
# @api private
|
|
126
|
+
def serialize_message(msg)
|
|
127
|
+
h = msg.to_h
|
|
128
|
+
return h unless h[:tool_calls]
|
|
129
|
+
|
|
130
|
+
h.merge(tool_calls: h[:tool_calls].map { |tc|
|
|
131
|
+
tc.respond_to?(:to_h) ? tc.to_h : tc
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Reconstructs a +RubyLLM::Message+ from a plain Hash.
|
|
136
|
+
# +RubyLLM::ToolCall+ entries in +:tool_calls+ are re-instantiated.
|
|
137
|
+
#
|
|
138
|
+
# @param h [Hash]
|
|
139
|
+
# @return [RubyLLM::Message]
|
|
140
|
+
# @api private
|
|
141
|
+
def self.deserialize_message(h)
|
|
142
|
+
h = h.transform_keys { |k|
|
|
143
|
+
begin
|
|
144
|
+
k.to_sym
|
|
145
|
+
rescue
|
|
146
|
+
k
|
|
147
|
+
end
|
|
148
|
+
}
|
|
149
|
+
if h[:tool_calls]
|
|
150
|
+
h = h.merge(tool_calls: Array(h[:tool_calls]).map { |tc|
|
|
151
|
+
next tc if tc.is_a?(RubyLLM::ToolCall)
|
|
152
|
+
|
|
153
|
+
tc = tc.transform_keys { |k|
|
|
154
|
+
begin
|
|
155
|
+
k.to_sym
|
|
156
|
+
rescue
|
|
157
|
+
k
|
|
158
|
+
end
|
|
159
|
+
}
|
|
160
|
+
RubyLLM::ToolCall.new(
|
|
161
|
+
id: tc[:id].to_s,
|
|
162
|
+
name: tc[:name].to_s,
|
|
163
|
+
arguments: (tc[:arguments] || {}).transform_keys { |k|
|
|
164
|
+
begin
|
|
165
|
+
k.to_sym
|
|
166
|
+
rescue
|
|
167
|
+
k
|
|
168
|
+
end
|
|
169
|
+
},
|
|
170
|
+
thought_signature: tc[:thought_signature]
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
end
|
|
174
|
+
RubyLLM::Message.new(h)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :deserialize_message
|
|
59
177
|
end
|
|
60
178
|
end
|
|
61
179
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Conversation
|
|
7
|
+
# Context object passed to the +on_compact+ callback registered on an agent.
|
|
8
|
+
#
|
|
9
|
+
# The callback calls #compact one or more times to specify which ranges of
|
|
10
|
+
# messages to replace with a summary. Each call:
|
|
11
|
+
# 1. Yields the selected message elements to the block.
|
|
12
|
+
# 2. Receives the block's return value as the summary text.
|
|
13
|
+
# 3. Persists a compaction record to the memory store (if available).
|
|
14
|
+
# 4. Updates #result_messages so that the compacted range is replaced
|
|
15
|
+
# by a single +:system+ summary message.
|
|
16
|
+
#
|
|
17
|
+
# The agent reads #result_messages after the callback returns and uses it
|
|
18
|
+
# as the new message list for this invocation.
|
|
19
|
+
#
|
|
20
|
+
# @example Summarise the oldest half of the conversation
|
|
21
|
+
# on_compact do |ctx|
|
|
22
|
+
# half = ctx.message_elements.length / 2
|
|
23
|
+
# ctx.compact(0...half) do |elements|
|
|
24
|
+
# texts = elements.map { |e| "#{e[:role]}: #{e[:message].content}" }.join("\n")
|
|
25
|
+
# "Summary of earlier conversation:\n#{texts}"
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class CompactionContext
|
|
29
|
+
# @return [Array<Hash>] message elements at compaction time
|
|
30
|
+
attr_reader :message_elements
|
|
31
|
+
|
|
32
|
+
# @return [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
33
|
+
attr_reader :budget
|
|
34
|
+
|
|
35
|
+
# @return [Integer] total estimated token count before compaction
|
|
36
|
+
attr_reader :total_tokens
|
|
37
|
+
|
|
38
|
+
# The current message list to be used after all compact calls have been made.
|
|
39
|
+
# Updated by each call to #compact.
|
|
40
|
+
#
|
|
41
|
+
# @return [Array]
|
|
42
|
+
attr_reader :result_messages
|
|
43
|
+
|
|
44
|
+
# @param message_elements [Array<Hash>]
|
|
45
|
+
# each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
|
|
46
|
+
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
47
|
+
# @param thread_id [String, nil] used when saving compaction records
|
|
48
|
+
# @param memory [Object, nil] memory object; must respond to #save_compaction
|
|
49
|
+
# for compaction records to be persisted
|
|
50
|
+
# @api private
|
|
51
|
+
# mutant:disable - e[:tokens] vs e.fetch(:tokens) and e[:message] vs e.fetch(:message) are genuine equivalent mutations: elements always carry both keys
|
|
52
|
+
def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
|
|
53
|
+
@message_elements = message_elements.dup
|
|
54
|
+
@budget = budget
|
|
55
|
+
@total_tokens = message_elements.sum { |e| e[:tokens] }
|
|
56
|
+
@thread_id = thread_id
|
|
57
|
+
@memory = memory
|
|
58
|
+
@result_messages = @message_elements.map { |e| e[:message] }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replace a range of messages with a summary produced by the block.
|
|
62
|
+
#
|
|
63
|
+
# The block receives the selected Array<Hash> elements and must return a
|
|
64
|
+
# String that serves as the summary text. After the call, #result_messages
|
|
65
|
+
# reflects the replacement.
|
|
66
|
+
#
|
|
67
|
+
# If the memory object responds to #save_compaction, a compaction record
|
|
68
|
+
# { start_seq:, end_seq:, summary_text: } is persisted for auditability.
|
|
69
|
+
#
|
|
70
|
+
# @param range [Range, Integer] index range into message_elements (0-based)
|
|
71
|
+
# @yieldparam elements [Array<Hash>] the selected message elements
|
|
72
|
+
# @yieldreturn [String] summary text to replace the selected messages
|
|
73
|
+
# @return [Array] the updated result_messages array
|
|
74
|
+
# @api private
|
|
75
|
+
# mutant:disable - multiple genuine equivalent mutations: is_a? vs instance_of? (Array/Range have no subclasses), yield.to_s vs yield (block always returns String), [:seq]/[:message] vs .fetch(:seq)/.fetch(:message) (keys always present), range.to_i vs range/to_int/Integer() (Integer is already integer), || [] vs nothing (Array#[] never returns nil for slice), RubyLLM::Message vs Message (killfork inherits Message=Struct from integration specs, both expose identical role/content interface)
|
|
76
|
+
def compact(range)
|
|
77
|
+
# Normalise: Integer index → single-element Array; Range → Array slice.
|
|
78
|
+
raw = @message_elements[range]
|
|
79
|
+
elements = if raw.is_a?(Array)
|
|
80
|
+
raw
|
|
81
|
+
elsif raw.nil?
|
|
82
|
+
[]
|
|
83
|
+
else
|
|
84
|
+
[raw]
|
|
85
|
+
end
|
|
86
|
+
return @result_messages if elements.empty?
|
|
87
|
+
|
|
88
|
+
summary_text = yield(elements).to_s
|
|
89
|
+
|
|
90
|
+
start_seq = elements.first[:seq]
|
|
91
|
+
end_seq = elements.last[:seq]
|
|
92
|
+
|
|
93
|
+
if @memory && @thread_id && @memory.respond_to?(:save_compaction)
|
|
94
|
+
@memory.save_compaction(
|
|
95
|
+
thread_id: @thread_id,
|
|
96
|
+
start_seq: start_seq,
|
|
97
|
+
end_seq: end_seq,
|
|
98
|
+
summary_text: summary_text
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Compute the last included index in the original @message_elements array.
|
|
103
|
+
last_idx = if range.is_a?(Range)
|
|
104
|
+
range.exclude_end? ? range.last - 1 : range.last
|
|
105
|
+
else
|
|
106
|
+
range.to_i
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
remaining = (@message_elements[(last_idx + 1)..] || []).map { |e| e[:message] }
|
|
110
|
+
summary_msg = RubyLLM::Message.new(role: :system, content: summary_text)
|
|
111
|
+
@result_messages = [summary_msg] + remaining
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Conversation
|
|
7
|
+
# Read-only context passed to the +on_compaction_trigger+ callback.
|
|
8
|
+
#
|
|
9
|
+
# The callback inspects the current message list and budget, then returns
|
|
10
|
+
# a truthy value to trigger compaction or a falsy value to skip it.
|
|
11
|
+
#
|
|
12
|
+
# No mutations are allowed through this object; use CompactionContext
|
|
13
|
+
# (passed to +on_compact+) for actual modifications.
|
|
14
|
+
#
|
|
15
|
+
# @example Trigger compaction when messages exceed 80% of the input budget
|
|
16
|
+
# on_compaction_trigger do |ctx|
|
|
17
|
+
# limit = ctx.budget&.available(used: 0) || Float::INFINITY
|
|
18
|
+
# ctx.total_tokens > limit * 0.8
|
|
19
|
+
# end
|
|
20
|
+
class TriggerContext
|
|
21
|
+
# @return [Array<Hash>] frozen snapshot of message elements
|
|
22
|
+
# each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
|
|
23
|
+
attr_reader :message_elements
|
|
24
|
+
|
|
25
|
+
# @return [Phronomy::LlmContextWindow::TokenBudget, nil] token budget for this invocation
|
|
26
|
+
attr_reader :budget
|
|
27
|
+
|
|
28
|
+
# @return [Integer] total estimated token count of all message elements
|
|
29
|
+
attr_reader :total_tokens
|
|
30
|
+
|
|
31
|
+
# @param message_elements [Array<Hash>]
|
|
32
|
+
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
33
|
+
# @api private
|
|
34
|
+
def initialize(message_elements:, budget:)
|
|
35
|
+
@message_elements = message_elements.dup.freeze
|
|
36
|
+
@budget = budget
|
|
37
|
+
@total_tokens = message_elements.sum { |e| e[:tokens] }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Conversation
|
|
7
|
+
# Context object passed to the +on_trim+ callback registered on an agent class.
|
|
8
|
+
#
|
|
9
|
+
# The callback receives a TrimContext and may call #remove to drop specific
|
|
10
|
+
# messages from the conversation before the LLM is called. Changes affect
|
|
11
|
+
# only the current invocation; the underlying memory store is not modified.
|
|
12
|
+
#
|
|
13
|
+
# Message elements are identified by a +:seq+ integer that is assigned
|
|
14
|
+
# sequentially (0-based) when messages are loaded from memory each turn.
|
|
15
|
+
#
|
|
16
|
+
# @example Remove the oldest two messages when the budget is tight
|
|
17
|
+
# on_trim do |ctx|
|
|
18
|
+
# if ctx.total_tokens > ctx.budget.available(used: 0) * 0.9
|
|
19
|
+
# seqs_to_drop = ctx.message_elements.first(2).map { |e| e[:seq] }
|
|
20
|
+
# ctx.remove(seqs_to_drop)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
class TrimContext
|
|
24
|
+
# @return [Phronomy::LlmContextWindow::TokenBudget, nil] token budget for this invocation
|
|
25
|
+
attr_reader :budget
|
|
26
|
+
|
|
27
|
+
# @return [Integer] total estimated token count of all current message elements
|
|
28
|
+
attr_reader :total_tokens
|
|
29
|
+
|
|
30
|
+
# @param message_elements [Array<Hash>]
|
|
31
|
+
# each element: { seq: Integer, message: Object, tokens: Integer, role: Symbol }
|
|
32
|
+
# @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
|
|
33
|
+
# @api private
|
|
34
|
+
def initialize(message_elements:, budget:)
|
|
35
|
+
@message_elements = message_elements.dup
|
|
36
|
+
@budget = budget
|
|
37
|
+
recalculate!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns a snapshot of the current message elements (defensive copy).
|
|
41
|
+
# Each element is a Hash with +:seq+, +:message+, +:tokens+, and +:role+.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Hash>]
|
|
44
|
+
# @api private
|
|
45
|
+
def message_elements
|
|
46
|
+
@message_elements.dup
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Remove messages identified by seq numbers.
|
|
50
|
+
# Calling this multiple times accumulates removals.
|
|
51
|
+
#
|
|
52
|
+
# @param seqs [Integer, Array<Integer>] seq number(s) to remove
|
|
53
|
+
# @return [self]
|
|
54
|
+
# @api private
|
|
55
|
+
# mutant:disable - Array(seqs).to_set vs Array(seqs) and e[:seq] vs e.fetch(:seq) are genuine equivalent: Array#include? returns identical results for both
|
|
56
|
+
def remove(seqs)
|
|
57
|
+
seqs_set = Array(seqs).to_set
|
|
58
|
+
@message_elements.reject! { |e| seqs_set.include?(e[:seq]) }
|
|
59
|
+
recalculate!
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Convenience: returns the plain message objects (without element metadata).
|
|
64
|
+
#
|
|
65
|
+
# @return [Array]
|
|
66
|
+
# @api private
|
|
67
|
+
# mutant:disable - e[:message] vs e.fetch(:message) is a genuine equivalent mutation: elements always carry :message
|
|
68
|
+
def messages
|
|
69
|
+
@message_elements.map { |e| e[:message] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# mutant:disable - e[:tokens] vs e.fetch(:tokens) is a genuine equivalent mutation: elements always carry :tokens
|
|
75
|
+
def recalculate!
|
|
76
|
+
@total_tokens = @message_elements.sum { |e| e[:tokens] }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Instruction
|
|
7
|
+
# A prompt template that substitutes {{variable}} placeholders in a string.
|
|
8
|
+
#
|
|
9
|
+
# @example Simple human template
|
|
10
|
+
# t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
|
|
11
|
+
# t.format(lang: "French", text: "Hello")
|
|
12
|
+
# # => "Translate to French: Hello"
|
|
13
|
+
#
|
|
14
|
+
# @example With a system template
|
|
15
|
+
# t = Phronomy::Agent::Context::Instruction::PromptTemplate.new(
|
|
16
|
+
# template: "{{question}}",
|
|
17
|
+
# system_template: "You are a {{role}} assistant."
|
|
18
|
+
# )
|
|
19
|
+
# t.format_system(role: "helpful")
|
|
20
|
+
# # => "You are a helpful assistant."
|
|
21
|
+
#
|
|
22
|
+
# As a Runnable, #invoke accepts a Hash of variables and returns a Hash
|
|
23
|
+
# with :prompt (and optionally :system) keys.
|
|
24
|
+
class PromptTemplate
|
|
25
|
+
include Phronomy::Runnable
|
|
26
|
+
|
|
27
|
+
PLACEHOLDER = /\{\{(\w+)\}\}/
|
|
28
|
+
|
|
29
|
+
attr_reader :template, :system_template
|
|
30
|
+
|
|
31
|
+
# @param template [String] human message template with {{var}} placeholders
|
|
32
|
+
# @param system_template [String, nil] optional system message template
|
|
33
|
+
# @api public
|
|
34
|
+
def initialize(template:, system_template: nil)
|
|
35
|
+
@template = template
|
|
36
|
+
@system_template = system_template
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Substitute all {{var}} placeholders in the human template.
|
|
40
|
+
#
|
|
41
|
+
# @param variables [Hash{Symbol => String}]
|
|
42
|
+
# @return [String]
|
|
43
|
+
# @api public
|
|
44
|
+
def format(**variables)
|
|
45
|
+
substitute(@template, variables)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Substitute all {{var}} placeholders in the system template.
|
|
49
|
+
# Returns nil when no system template was set.
|
|
50
|
+
#
|
|
51
|
+
# @param variables [Hash{Symbol => String}]
|
|
52
|
+
# @return [String, nil]
|
|
53
|
+
# @api public
|
|
54
|
+
def format_system(**variables)
|
|
55
|
+
@system_template && substitute(@system_template, variables)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Runnable interface: accepts a Hash of variable values.
|
|
59
|
+
# Returns { prompt: String, system: String|nil }.
|
|
60
|
+
#
|
|
61
|
+
# @param input [Hash{Symbol => String}]
|
|
62
|
+
# @return [Hash]
|
|
63
|
+
# @api public
|
|
64
|
+
def invoke(input, config: {})
|
|
65
|
+
vars = normalize_input(input)
|
|
66
|
+
result = {prompt: format(**vars)}
|
|
67
|
+
sys = format_system(**vars)
|
|
68
|
+
result[:system] = sys if sys
|
|
69
|
+
result
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the list of placeholder names found in both templates.
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Symbol>]
|
|
75
|
+
# @api public
|
|
76
|
+
def variables
|
|
77
|
+
names = @template.scan(PLACEHOLDER).flatten
|
|
78
|
+
names += @system_template.scan(PLACEHOLDER).flatten if @system_template
|
|
79
|
+
names.map(&:to_sym).uniq
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def substitute(text, variables)
|
|
85
|
+
text.gsub(PLACEHOLDER) do |match|
|
|
86
|
+
key = Regexp.last_match(1).to_sym
|
|
87
|
+
variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_input(input)
|
|
92
|
+
case input
|
|
93
|
+
when Hash then input
|
|
94
|
+
when String then {input: input}
|
|
95
|
+
else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module Embeddings
|
|
8
|
+
# Abstract interface for embedding adapters.
|
|
9
|
+
#
|
|
10
|
+
# Concrete implementations must override {#embed} and return a vector
|
|
11
|
+
# as an +Array<Float>+.
|
|
12
|
+
class Base
|
|
13
|
+
# Embed the given text and return a vector representation.
|
|
14
|
+
#
|
|
15
|
+
# @param text [String] the text to embed
|
|
16
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
17
|
+
# @return [Array<Float>] the embedding vector
|
|
18
|
+
# @api public
|
|
19
|
+
def embed(text, cancellation_token = nil)
|
|
20
|
+
cancellation_token&.raise_if_cancelled!
|
|
21
|
+
raise NotImplementedError, "#{self.class}#embed is not implemented"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Submits an {#embed} call to {BlockingAdapterPool} and returns a
|
|
25
|
+
# {BlockingAdapterPool::PendingOperation}.
|
|
26
|
+
#
|
|
27
|
+
# @param text [String]
|
|
28
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
|
|
29
|
+
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
30
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
31
|
+
# @api public
|
|
32
|
+
def embed_async(text, cancellation_token = nil, timeout: nil)
|
|
33
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
34
|
+
timeout: timeout,
|
|
35
|
+
cancellation_token: cancellation_token
|
|
36
|
+
) do
|
|
37
|
+
embed(text, cancellation_token)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module Embeddings
|
|
8
|
+
# Embeddings adapter backed by RubyLLM.
|
|
9
|
+
#
|
|
10
|
+
# Delegates to +RubyLLM.embed+ and returns the resulting vector as an
|
|
11
|
+
# +Array<Float>+.
|
|
12
|
+
#
|
|
13
|
+
# @example Default model
|
|
14
|
+
# embeddings = Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings.new
|
|
15
|
+
# vector = embeddings.embed("Hello, world!")
|
|
16
|
+
#
|
|
17
|
+
# @example Explicit model
|
|
18
|
+
# embeddings = Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small")
|
|
19
|
+
# vector = embeddings.embed("Hello, world!")
|
|
20
|
+
class RubyLLMEmbeddings < Base
|
|
21
|
+
# @param model [String, nil] embedding model identifier; nil uses the RubyLLM default
|
|
22
|
+
# @param provider [Symbol, nil] provider override (e.g. :openai); nil uses the RubyLLM default
|
|
23
|
+
# @param assume_model_exists [Boolean] when true, skips RubyLLM model-registry validation
|
|
24
|
+
# (useful for locally hosted models not in the registry)
|
|
25
|
+
# @api public
|
|
26
|
+
def initialize(model: nil, provider: nil, assume_model_exists: false)
|
|
27
|
+
@model = model
|
|
28
|
+
@provider = provider
|
|
29
|
+
@assume_model_exists = assume_model_exists
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Embed text via RubyLLM.
|
|
33
|
+
#
|
|
34
|
+
# @param text [String]
|
|
35
|
+
# @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
36
|
+
# @return [Array<Float>]
|
|
37
|
+
# @api public
|
|
38
|
+
def embed(text, cancellation_token = nil)
|
|
39
|
+
cancellation_token&.raise_if_cancelled!
|
|
40
|
+
opts = {}
|
|
41
|
+
opts[:model] = @model if @model
|
|
42
|
+
opts[:provider] = @provider if @provider
|
|
43
|
+
opts[:assume_model_exists] = true if @assume_model_exists
|
|
44
|
+
RubyLLM.embed(text, **opts).vectors
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Context
|
|
6
|
+
module Knowledge
|
|
7
|
+
module Loader
|
|
8
|
+
# Abstract base class for document loaders.
|
|
9
|
+
#
|
|
10
|
+
# A loader converts an external source (file path, URL, etc.) into an
|
|
11
|
+
# Array of document hashes understood by the rest of the pipeline:
|
|
12
|
+
#
|
|
13
|
+
# [{ text: String, metadata: Hash }, ...]
|
|
14
|
+
#
|
|
15
|
+
# Subclasses must implement {#load}.
|
|
16
|
+
class Base
|
|
17
|
+
# Load documents from +source+ and return an array of document hashes.
|
|
18
|
+
#
|
|
19
|
+
# @param source [String] file path, URL, or other source identifier
|
|
20
|
+
# @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
|
|
21
|
+
# @raise [NotImplementedError] when not overridden by a subclass
|
|
22
|
+
# @api public
|
|
23
|
+
def load(source)
|
|
24
|
+
raise NotImplementedError, "#{self.class}#load is not implemented"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
module Context
|
|
8
|
+
module Knowledge
|
|
9
|
+
module Loader
|
|
10
|
+
# Loads a CSV file, converting each row into a separate document.
|
|
11
|
+
#
|
|
12
|
+
# By default the first row is treated as a header and column names are
|
|
13
|
+
# available in the document metadata. The full row is serialised to
|
|
14
|
+
# a human-readable "key: value" string for embedding.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# loader = Phronomy::Agent::Context::Knowledge::Loader::CsvLoader.new
|
|
18
|
+
# docs = loader.load("products.csv")
|
|
19
|
+
# # => [
|
|
20
|
+
# # { text: "name: Widget\nprice: 9.99", metadata: { source: "...", row: 1, name: "Widget", price: "9.99" } },
|
|
21
|
+
# # ...
|
|
22
|
+
# # ]
|
|
23
|
+
class CsvLoader < Base
|
|
24
|
+
# @param headers [Boolean] treat the first row as headers (default: true)
|
|
25
|
+
# @param text_column [String, nil] if set, use only this column as the document text
|
|
26
|
+
# @api public
|
|
27
|
+
def initialize(headers: true, text_column: nil)
|
|
28
|
+
@headers = headers
|
|
29
|
+
@text_column = text_column
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param source [String] path to a CSV file
|
|
33
|
+
# @return [Array<Hash>]
|
|
34
|
+
# @raise [Errno::ENOENT] if the file does not exist
|
|
35
|
+
# @api public
|
|
36
|
+
def load(source)
|
|
37
|
+
rows = CSV.read(source, headers: @headers, encoding: "UTF-8")
|
|
38
|
+
|
|
39
|
+
if @headers
|
|
40
|
+
rows.each_with_index.map do |row, idx|
|
|
41
|
+
row_hash = row.to_h
|
|
42
|
+
text = if @text_column
|
|
43
|
+
row_hash[@text_column].to_s
|
|
44
|
+
else
|
|
45
|
+
row_hash.map { |k, v| "#{k}: #{v}" }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
metadata = row_hash.transform_keys(&:to_sym).merge(source: source, row: idx + 1)
|
|
48
|
+
{text: text, metadata: metadata}
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
rows.each_with_index.map do |row, idx|
|
|
52
|
+
text = row.join(", ")
|
|
53
|
+
{text: text, metadata: {source: source, row: idx + 1}}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|