phronomy 0.15.0 → 0.16.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/CHANGELOG.md +107 -18
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -393
- data/lib/phronomy/agent/base.rb +301 -641
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +167 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -47
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module Concerns
|
|
6
|
+
module BeforeLLMInput
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
def before_llm_input(callable = nil, &block)
|
|
13
|
+
if callable.nil? && !block_given?
|
|
14
|
+
@before_llm_input
|
|
15
|
+
else
|
|
16
|
+
@before_llm_input = callable || block
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def _before_llm_input = @before_llm_input
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_accessor :before_llm_input
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def run_before_llm_input_hooks(call_sequence:, config:)
|
|
28
|
+
hooks = [
|
|
29
|
+
Phronomy.configuration.before_llm_input,
|
|
30
|
+
self.class._before_llm_input,
|
|
31
|
+
@before_llm_input
|
|
32
|
+
].compact
|
|
33
|
+
return LLMInputPatch.empty if hooks.empty?
|
|
34
|
+
|
|
35
|
+
definition = self.class.agent_definition
|
|
36
|
+
context = LLMInputBuildContext.new(
|
|
37
|
+
agent_id: agent_id,
|
|
38
|
+
agent_definition_id: definition.fetch(:id),
|
|
39
|
+
definition_version: definition.fetch(:version),
|
|
40
|
+
config: config,
|
|
41
|
+
call_sequence: call_sequence
|
|
42
|
+
)
|
|
43
|
+
model_patch = {}
|
|
44
|
+
segments = []
|
|
45
|
+
|
|
46
|
+
hooks.each do |hook|
|
|
47
|
+
result = hook.call(context)
|
|
48
|
+
check_cancellation!(config, "invocation cancelled during before_llm_input hook")
|
|
49
|
+
next if result.nil?
|
|
50
|
+
unless result.is_a?(LLMInputPatch)
|
|
51
|
+
raise TypeError,
|
|
52
|
+
"before_llm_input must return LLMInputPatch or nil, got #{result.class}"
|
|
53
|
+
end
|
|
54
|
+
model_patch.merge!(result.model_config_patch) if result.model_config_patch
|
|
55
|
+
segments.concat(Array(result.segment_candidates))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
LLMInputPatch.new(
|
|
59
|
+
model_config_patch: model_patch.empty? ? nil : model_patch,
|
|
60
|
+
segment_candidates: segments.empty? ? nil : segments
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextAssembler
|
|
6
|
+
ASSEMBLY_POLICY_VERSION = 5
|
|
7
|
+
SEGMENT_ORIGIN_METADATA_KEY = "phronomy_origin"
|
|
8
|
+
BEFORE_LLM_INPUT_ORIGIN = "before_llm_input"
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
agent:,
|
|
12
|
+
persistence:,
|
|
13
|
+
policy: ContextPolicies::Default.new,
|
|
14
|
+
candidate_resolver: nil
|
|
15
|
+
)
|
|
16
|
+
@agent = agent
|
|
17
|
+
@persistence = persistence
|
|
18
|
+
@policy = policy
|
|
19
|
+
@candidate_resolver = candidate_resolver || ContextCandidateResolver.new(
|
|
20
|
+
content_loader: method(:fetch_content)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
|
|
25
|
+
projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
|
|
26
|
+
model_cfg = effective_model_config(config, patch)
|
|
27
|
+
tool_set = ToolDefinitionSet.build(@agent)
|
|
28
|
+
system_text = build_system_text(input)
|
|
29
|
+
hook_candidates = normalize_candidates(patch.segment_candidates)
|
|
30
|
+
input_ref = execution.metadata.fetch("current_input_ref")
|
|
31
|
+
current_input = @persistence.contents.fetch_text(input_ref)
|
|
32
|
+
excluded = [execution.metadata["current_input_record_id"]].compact
|
|
33
|
+
|
|
34
|
+
base_segments = []
|
|
35
|
+
unless system_text.to_s.empty?
|
|
36
|
+
base_segments << text_segment(:instruction, :system, system_text)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
assemble(
|
|
40
|
+
agent_root: agent_root,
|
|
41
|
+
execution: execution,
|
|
42
|
+
call_sequence: 1,
|
|
43
|
+
call_mode: :ask,
|
|
44
|
+
previous_manifest: nil,
|
|
45
|
+
model_config: model_cfg,
|
|
46
|
+
tool_definitions: tool_set.definitions,
|
|
47
|
+
tool_definitions_ref: nil,
|
|
48
|
+
prior_records: projection.transcript_records,
|
|
49
|
+
working_records: execution.working_records,
|
|
50
|
+
excluded_record_ids: excluded,
|
|
51
|
+
base_segments: base_segments,
|
|
52
|
+
hook_candidates: hook_candidates,
|
|
53
|
+
current_input_segment: segment(:current_input, :user, input_ref, :ask_argument),
|
|
54
|
+
mandatory_values: [system_text, current_input, tool_set.definitions] +
|
|
55
|
+
hook_candidates.map { |candidate| candidate.fetch(:content) }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_followup(
|
|
60
|
+
base_manifest:,
|
|
61
|
+
agent_root:,
|
|
62
|
+
execution:,
|
|
63
|
+
config: {},
|
|
64
|
+
patch: LLMInputPatch.empty
|
|
65
|
+
)
|
|
66
|
+
projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
|
|
67
|
+
model_cfg = effective_model_config(config, patch)
|
|
68
|
+
hook_candidates = normalize_candidates(patch.segment_candidates)
|
|
69
|
+
system_segments = base_manifest.segments.select do |segment|
|
|
70
|
+
segment.role == :system && !before_llm_input_segment?(segment)
|
|
71
|
+
end
|
|
72
|
+
tool_definitions = base_manifest.tool_definitions_ref ?
|
|
73
|
+
fetch_json(base_manifest.tool_definitions_ref) : []
|
|
74
|
+
|
|
75
|
+
assemble(
|
|
76
|
+
agent_root: agent_root,
|
|
77
|
+
execution: execution,
|
|
78
|
+
call_sequence: execution.llm_calls.length + 1,
|
|
79
|
+
call_mode: :complete,
|
|
80
|
+
previous_manifest: base_manifest,
|
|
81
|
+
model_config: model_cfg,
|
|
82
|
+
tool_definitions: tool_definitions,
|
|
83
|
+
tool_definitions_ref: base_manifest.tool_definitions_ref,
|
|
84
|
+
prior_records: projection.transcript_records,
|
|
85
|
+
working_records: execution.working_records,
|
|
86
|
+
excluded_record_ids: [],
|
|
87
|
+
base_segments: system_segments.map { |existing| segment_hash(existing) },
|
|
88
|
+
hook_candidates: hook_candidates,
|
|
89
|
+
current_input_segment: nil,
|
|
90
|
+
mandatory_values: system_segments.map { |segment| fetch_content(segment.content_ref) } +
|
|
91
|
+
hook_candidates.map { |candidate| candidate.fetch(:content) } +
|
|
92
|
+
[tool_definitions]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def assemble(
|
|
99
|
+
agent_root:,
|
|
100
|
+
execution:,
|
|
101
|
+
call_sequence:,
|
|
102
|
+
call_mode:,
|
|
103
|
+
previous_manifest:,
|
|
104
|
+
model_config:,
|
|
105
|
+
tool_definitions:,
|
|
106
|
+
tool_definitions_ref:,
|
|
107
|
+
prior_records:,
|
|
108
|
+
working_records:,
|
|
109
|
+
excluded_record_ids:,
|
|
110
|
+
base_segments:,
|
|
111
|
+
hook_candidates:,
|
|
112
|
+
current_input_segment:,
|
|
113
|
+
mandatory_values:
|
|
114
|
+
)
|
|
115
|
+
generation = agent_root.transcript_generation
|
|
116
|
+
eligible_working = Array(working_records).select do |record|
|
|
117
|
+
record.context_candidate && record.context_generation == generation
|
|
118
|
+
end
|
|
119
|
+
candidates = @candidate_resolver.resolve(
|
|
120
|
+
prior_records: prior_records,
|
|
121
|
+
working_records: eligible_working,
|
|
122
|
+
excluded_record_ids: excluded_record_ids
|
|
123
|
+
)
|
|
124
|
+
token_budget = TokenBudgetResolver.new(agent: @agent).resolve(model_config)
|
|
125
|
+
mandatory_token_estimate = estimate_values(mandatory_values)
|
|
126
|
+
parts = context_parts
|
|
127
|
+
request = ContextRequest.new(
|
|
128
|
+
agent_id: agent_root.agent_id,
|
|
129
|
+
execution_id: execution.execution_id,
|
|
130
|
+
call_sequence: call_sequence,
|
|
131
|
+
call_mode: call_mode,
|
|
132
|
+
candidates: candidates,
|
|
133
|
+
token_budget: token_budget,
|
|
134
|
+
model_config: model_config,
|
|
135
|
+
previous_manifest: previous_manifest,
|
|
136
|
+
required_coverage: [],
|
|
137
|
+
parts: parts,
|
|
138
|
+
metadata: {"mandatory_token_estimate" => mandatory_token_estimate}
|
|
139
|
+
)
|
|
140
|
+
plan = @policy.call(request)
|
|
141
|
+
validated = ContextPlanValidator.new.validate!(request: request, plan: plan)
|
|
142
|
+
unless validated.plan.derived_contents.empty?
|
|
143
|
+
raise Phronomy::ConfigurationError,
|
|
144
|
+
"Derived Context persistence is not enabled in Context Policy phase 1-4"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
segments = Array(base_segments).dup
|
|
148
|
+
append_candidates(segments, hook_candidates, before_history: true)
|
|
149
|
+
validated.selected_candidates
|
|
150
|
+
.sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }
|
|
151
|
+
.each { |candidate| segments << segment_from_candidate(candidate) }
|
|
152
|
+
append_candidates(segments, hook_candidates, before_history: false)
|
|
153
|
+
segments << current_input_segment if current_input_segment
|
|
154
|
+
|
|
155
|
+
ContextParts::Validators::FinalBudgetValidator.new(
|
|
156
|
+
content_loader: method(:fetch_content)
|
|
157
|
+
).validate!(
|
|
158
|
+
token_budget: token_budget,
|
|
159
|
+
segments: segments,
|
|
160
|
+
extra_values: [tool_definitions]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
store_manifest(
|
|
164
|
+
call_sequence: call_sequence,
|
|
165
|
+
call_mode: call_mode,
|
|
166
|
+
segments: segments,
|
|
167
|
+
model_config_ref: @persistence.contents.put_json(model_config),
|
|
168
|
+
tool_definitions_ref: tool_definitions_ref ||
|
|
169
|
+
@persistence.contents.put_json(tool_definitions)
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def context_parts
|
|
174
|
+
{
|
|
175
|
+
unit_builder: ContextParts::UnitBuilders::DependencyAwareUnitBuilder.new,
|
|
176
|
+
required_context_resolver: ContextParts::Requirements::RequiredContextResolver.new,
|
|
177
|
+
recent_first_selector: ContextParts::Selectors::RecentFirstSelector.new,
|
|
178
|
+
token_budget_packer: ContextParts::Budget::TokenBudgetPacker.new
|
|
179
|
+
}.freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def estimate_values(values)
|
|
183
|
+
Array(values).sum do |value|
|
|
184
|
+
bytes = value.is_a?(String) ? value : Phronomy::CanonicalJSON.dump(value)
|
|
185
|
+
Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def store_manifest(call_sequence:, call_mode:, segments:, model_config_ref:, tool_definitions_ref:)
|
|
190
|
+
positioned = segments.each_with_index.map do |value, position|
|
|
191
|
+
LLMInputManifest::Segment.new(**value.merge(position: position))
|
|
192
|
+
end
|
|
193
|
+
manifest = LLMInputManifest.new(
|
|
194
|
+
call_sequence: call_sequence,
|
|
195
|
+
call_mode: call_mode,
|
|
196
|
+
segments: positioned,
|
|
197
|
+
model_config_ref: model_config_ref,
|
|
198
|
+
tool_definitions_ref: tool_definitions_ref,
|
|
199
|
+
assembly_policy_version: ASSEMBLY_POLICY_VERSION,
|
|
200
|
+
ruby_llm_version: defined?(RubyLLM::VERSION) ? RubyLLM::VERSION : nil,
|
|
201
|
+
adapter_name: Phronomy.configuration.llm_adapter.class.name
|
|
202
|
+
)
|
|
203
|
+
[manifest, @persistence.contents.put_json(manifest.to_h)]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def effective_model_config(config, patch)
|
|
207
|
+
apply_model_config_patch(model_config(config), patch.model_config_patch)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def model_config(config)
|
|
211
|
+
{
|
|
212
|
+
"model" => @agent.class.model&.to_s,
|
|
213
|
+
"provider" => @agent.class.provider&.to_s,
|
|
214
|
+
"temperature" => @agent.class.temperature,
|
|
215
|
+
"max_output_tokens" => @agent.class.max_output_tokens,
|
|
216
|
+
"context_window" => @agent.class.context_window,
|
|
217
|
+
"cache_instructions" => !!@agent.class.cache_instructions,
|
|
218
|
+
"parallel_tool_execution" => !!Phronomy.configuration.parallel_tool_execution,
|
|
219
|
+
"thread_id" => config[:thread_id]&.to_s
|
|
220
|
+
}.compact
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def apply_model_config_patch(base, patch)
|
|
224
|
+
return base unless patch
|
|
225
|
+
base.merge(patch.to_h.transform_keys(&:to_s)).compact
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def normalize_candidates(candidates)
|
|
229
|
+
Array(candidates).map do |candidate|
|
|
230
|
+
hash = candidate.to_h
|
|
231
|
+
content = hash.fetch(:content) { hash.fetch("content") }
|
|
232
|
+
category = (hash[:category] || hash["category"] || :knowledge).to_sym
|
|
233
|
+
role = (hash[:role] || hash["role"] || default_role(category)).to_sym
|
|
234
|
+
{content: content.to_s, category: category, role: role}
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def append_candidates(segments, candidates, before_history:)
|
|
239
|
+
candidates.each do |candidate|
|
|
240
|
+
early = %i[instruction structured_state knowledge memory summary].include?(candidate[:category])
|
|
241
|
+
next unless early == before_history
|
|
242
|
+
segments << text_segment(
|
|
243
|
+
candidate[:category],
|
|
244
|
+
candidate[:role],
|
|
245
|
+
candidate[:content],
|
|
246
|
+
metadata: {SEGMENT_ORIGIN_METADATA_KEY => BEFORE_LLM_INPUT_ORIGIN}
|
|
247
|
+
)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def default_role(category)
|
|
252
|
+
(category == :instruction) ? :system : :user
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def text_segment(category, role, content, metadata: {})
|
|
256
|
+
segment(
|
|
257
|
+
category, role, @persistence.contents.put_text(content.to_s), :chat_message,
|
|
258
|
+
metadata: metadata
|
|
259
|
+
)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def before_llm_input_segment?(segment)
|
|
263
|
+
segment.metadata[SEGMENT_ORIGIN_METADATA_KEY] == BEFORE_LLM_INPUT_ORIGIN
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def segment_from_candidate(candidate)
|
|
267
|
+
metadata = candidate.metadata.reject do |key, _value|
|
|
268
|
+
%w[estimated_tokens source_kind source_sequence].include?(key.to_s)
|
|
269
|
+
end
|
|
270
|
+
metadata = metadata.merge(
|
|
271
|
+
"journal_record_id" => candidate.record_id,
|
|
272
|
+
"journal_sequence" => candidate.metadata["source_sequence"],
|
|
273
|
+
"llm_call_id" => candidate.llm_call_id
|
|
274
|
+
).compact
|
|
275
|
+
segment(
|
|
276
|
+
candidate.category,
|
|
277
|
+
candidate.role,
|
|
278
|
+
candidate.content_ref,
|
|
279
|
+
:chat_message,
|
|
280
|
+
tool_call_id: candidate.tool_call_id,
|
|
281
|
+
metadata: metadata
|
|
282
|
+
)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def segment_hash(existing)
|
|
286
|
+
{
|
|
287
|
+
category: existing.category, role: existing.role,
|
|
288
|
+
content_ref: existing.content_ref, delivery: existing.delivery,
|
|
289
|
+
tool_call_id: existing.tool_call_id, metadata: existing.metadata
|
|
290
|
+
}
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def segment(category, role, content_ref, delivery, tool_call_id: nil, metadata: {})
|
|
294
|
+
{category: category, role: role, content_ref: content_ref, delivery: delivery,
|
|
295
|
+
tool_call_id: tool_call_id, metadata: metadata}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def fetch_json(ref)
|
|
299
|
+
Phronomy::CanonicalJSON.load(@persistence.contents.fetch(ref))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def fetch_content(ref)
|
|
303
|
+
@persistence.contents.fetch_text(ref)
|
|
304
|
+
rescue
|
|
305
|
+
@persistence.contents.fetch(ref)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def build_system_text(input)
|
|
309
|
+
instruction = @agent.send(:build_instructions, input)
|
|
310
|
+
knowledge = @agent.class.static_knowledge_chunks + @agent.send(:instance_knowledge_chunks)
|
|
311
|
+
parts = [instruction]
|
|
312
|
+
knowledge.each do |chunk|
|
|
313
|
+
parts << Phronomy::LlmContextWindow::Assembler.xml_tag(
|
|
314
|
+
chunk[:content], type: chunk[:type] || :static, trusted: true
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
parts.compact.join("\n\n")
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
ContextCandidate = Data.define(
|
|
6
|
+
:candidate_id,
|
|
7
|
+
:source_kind,
|
|
8
|
+
:category,
|
|
9
|
+
:role,
|
|
10
|
+
:content_ref,
|
|
11
|
+
:record_id,
|
|
12
|
+
:agent_id,
|
|
13
|
+
:execution_id,
|
|
14
|
+
:llm_call_id,
|
|
15
|
+
:tool_call_id,
|
|
16
|
+
:sequence,
|
|
17
|
+
:requirement,
|
|
18
|
+
:priority,
|
|
19
|
+
:metadata
|
|
20
|
+
) do
|
|
21
|
+
def initialize(**values)
|
|
22
|
+
normalized = values.merge(
|
|
23
|
+
candidate_id: values.fetch(:candidate_id).to_s.freeze,
|
|
24
|
+
source_kind: values.fetch(:source_kind).to_sym,
|
|
25
|
+
category: values.fetch(:category).to_sym,
|
|
26
|
+
role: values[:role]&.to_sym,
|
|
27
|
+
content_ref: values.fetch(:content_ref).to_s.freeze,
|
|
28
|
+
record_id: values[:record_id]&.to_s&.freeze,
|
|
29
|
+
agent_id: values[:agent_id]&.to_s&.freeze,
|
|
30
|
+
execution_id: values[:execution_id]&.to_s&.freeze,
|
|
31
|
+
llm_call_id: values[:llm_call_id]&.to_s&.freeze,
|
|
32
|
+
tool_call_id: values[:tool_call_id]&.to_s&.freeze,
|
|
33
|
+
sequence: values[:sequence] && Integer(values[:sequence]),
|
|
34
|
+
requirement: (values[:requirement] || :optional).to_sym,
|
|
35
|
+
priority: Integer(values[:priority] || 0),
|
|
36
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
37
|
+
)
|
|
38
|
+
unless %i[protocol_required declared_required optional].include?(normalized[:requirement])
|
|
39
|
+
raise ArgumentError, "unknown ContextCandidate requirement: #{normalized[:requirement].inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
super(**normalized)
|
|
43
|
+
freeze
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextCandidateResolver
|
|
6
|
+
def initialize(content_loader:)
|
|
7
|
+
@content_loader = content_loader
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def resolve(prior_records:, working_records:, excluded_record_ids: [])
|
|
11
|
+
excluded = Array(excluded_record_ids).compact.map(&:to_s).to_h { |id| [id, true] }
|
|
12
|
+
prior = eligible(prior_records, excluded)
|
|
13
|
+
working = eligible(working_records, excluded)
|
|
14
|
+
next_sequence = prior.map(&:sequence).compact.max.to_i
|
|
15
|
+
|
|
16
|
+
candidates = []
|
|
17
|
+
prior.each do |record|
|
|
18
|
+
candidates << candidate_for(record, source_kind: :journal, sequence: record.sequence)
|
|
19
|
+
end
|
|
20
|
+
working.each do |record|
|
|
21
|
+
sequence = record.sequence || (next_sequence += 1)
|
|
22
|
+
candidates << candidate_for(record, source_kind: :working, sequence: sequence)
|
|
23
|
+
end
|
|
24
|
+
candidates.sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }.freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def eligible(records, excluded)
|
|
30
|
+
Array(records).select do |record|
|
|
31
|
+
record.context_candidate &&
|
|
32
|
+
record.content_ref &&
|
|
33
|
+
!excluded.key?(record.record_id.to_s)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def candidate_for(record, source_kind:, sequence:)
|
|
38
|
+
tool_call_id = record.metadata["tool_call_id"] || record.metadata[:tool_call_id]
|
|
39
|
+
bytes = @content_loader.call(record.content_ref)
|
|
40
|
+
metadata = record.metadata.merge(
|
|
41
|
+
"estimated_tokens" => Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes),
|
|
42
|
+
"source_kind" => source_kind.to_s,
|
|
43
|
+
"source_sequence" => record.sequence
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ContextCandidate.new(
|
|
47
|
+
candidate_id: "record:#{record.record_id}",
|
|
48
|
+
source_kind: source_kind,
|
|
49
|
+
category: record.kind,
|
|
50
|
+
role: record.role,
|
|
51
|
+
content_ref: record.content_ref,
|
|
52
|
+
record_id: record.record_id,
|
|
53
|
+
agent_id: record.agent_id,
|
|
54
|
+
execution_id: record.execution_id,
|
|
55
|
+
llm_call_id: record.llm_call_id,
|
|
56
|
+
tool_call_id: tool_call_id,
|
|
57
|
+
sequence: sequence,
|
|
58
|
+
requirement: :optional,
|
|
59
|
+
priority: (source_kind == :working) ? 100 : 0,
|
|
60
|
+
metadata: metadata
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|