phronomy 0.15.1 → 0.17.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/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -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/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -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 +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -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 +118 -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 +134 -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 +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -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 +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -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/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- 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/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- 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/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- 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 -13
- 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
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextAssembler
|
|
6
|
+
ASSEMBLY_POLICY_VERSION = 6
|
|
7
|
+
SEGMENT_ORIGIN_METADATA_KEY = "phronomy_origin"
|
|
8
|
+
BEFORE_LLM_INPUT_ORIGIN = "before_llm_input"
|
|
9
|
+
EARLY_CONTEXT_CATEGORIES = %i[
|
|
10
|
+
instruction structured_state knowledge memory summary
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(
|
|
14
|
+
agent:,
|
|
15
|
+
persistence:,
|
|
16
|
+
policy: ContextPolicies::Default.new,
|
|
17
|
+
candidate_resolver: nil
|
|
18
|
+
)
|
|
19
|
+
@agent = agent
|
|
20
|
+
@persistence = persistence
|
|
21
|
+
@policy = policy
|
|
22
|
+
@candidate_resolver = candidate_resolver || ContextCandidateResolver.new(
|
|
23
|
+
content_loader: method(:fetch_content)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def build_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
|
|
28
|
+
projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
|
|
29
|
+
model_cfg = effective_model_config(config, patch)
|
|
30
|
+
tool_set = ToolDefinitionSet.build(@agent)
|
|
31
|
+
system_text = build_system_text(input)
|
|
32
|
+
hook_candidates = normalize_candidates(patch.segment_candidates)
|
|
33
|
+
input_ref = execution.metadata.fetch("current_input_ref")
|
|
34
|
+
current_input = @persistence.contents.fetch_text(input_ref)
|
|
35
|
+
excluded = [execution.metadata["current_input_record_id"]].compact
|
|
36
|
+
|
|
37
|
+
base_segments = []
|
|
38
|
+
unless system_text.to_s.empty?
|
|
39
|
+
base_segments << text_segment(:instruction, :system, system_text)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
assemble(
|
|
43
|
+
agent_root: agent_root,
|
|
44
|
+
execution: execution,
|
|
45
|
+
call_sequence: 1,
|
|
46
|
+
call_mode: :ask,
|
|
47
|
+
previous_manifest: nil,
|
|
48
|
+
model_config: model_cfg,
|
|
49
|
+
tool_definitions: tool_set.definitions,
|
|
50
|
+
tool_definitions_ref: nil,
|
|
51
|
+
prior_records: projection.context_records,
|
|
52
|
+
working_records: execution.working_records,
|
|
53
|
+
excluded_record_ids: excluded,
|
|
54
|
+
base_segments: base_segments,
|
|
55
|
+
hook_candidates: hook_candidates,
|
|
56
|
+
current_input_segment: segment(:current_input, :user, input_ref, :ask_argument),
|
|
57
|
+
mandatory_values: [system_text, current_input, tool_set.definitions]
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_followup(
|
|
62
|
+
base_manifest:,
|
|
63
|
+
agent_root:,
|
|
64
|
+
execution:,
|
|
65
|
+
config: {},
|
|
66
|
+
patch: LLMInputPatch.empty
|
|
67
|
+
)
|
|
68
|
+
projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
|
|
69
|
+
model_cfg = effective_model_config(config, patch)
|
|
70
|
+
hook_candidates = normalize_candidates(patch.segment_candidates)
|
|
71
|
+
system_segments = base_manifest.segments.select do |segment|
|
|
72
|
+
segment.role == :system && !before_llm_input_segment?(segment)
|
|
73
|
+
end
|
|
74
|
+
tool_definitions = base_manifest.tool_definitions_ref ?
|
|
75
|
+
fetch_json(base_manifest.tool_definitions_ref) : []
|
|
76
|
+
|
|
77
|
+
assemble(
|
|
78
|
+
agent_root: agent_root,
|
|
79
|
+
execution: execution,
|
|
80
|
+
call_sequence: execution.llm_calls.length + 1,
|
|
81
|
+
call_mode: :complete,
|
|
82
|
+
previous_manifest: base_manifest,
|
|
83
|
+
model_config: model_cfg,
|
|
84
|
+
tool_definitions: tool_definitions,
|
|
85
|
+
tool_definitions_ref: base_manifest.tool_definitions_ref,
|
|
86
|
+
prior_records: projection.context_records,
|
|
87
|
+
working_records: execution.working_records,
|
|
88
|
+
excluded_record_ids: [],
|
|
89
|
+
base_segments: system_segments.map { |existing| segment_hash(existing) },
|
|
90
|
+
hook_candidates: hook_candidates,
|
|
91
|
+
current_input_segment: nil,
|
|
92
|
+
mandatory_values: system_segments.map { |segment| fetch_content(segment.content_ref) } +
|
|
93
|
+
[tool_definitions]
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def assemble(
|
|
100
|
+
agent_root:,
|
|
101
|
+
execution:,
|
|
102
|
+
call_sequence:,
|
|
103
|
+
call_mode:,
|
|
104
|
+
previous_manifest:,
|
|
105
|
+
model_config:,
|
|
106
|
+
tool_definitions:,
|
|
107
|
+
tool_definitions_ref:,
|
|
108
|
+
prior_records:,
|
|
109
|
+
working_records:,
|
|
110
|
+
excluded_record_ids:,
|
|
111
|
+
base_segments:,
|
|
112
|
+
hook_candidates:,
|
|
113
|
+
current_input_segment:,
|
|
114
|
+
mandatory_values:
|
|
115
|
+
)
|
|
116
|
+
generation = agent_root.transcript_generation
|
|
117
|
+
eligible_working = Array(working_records).select do |record|
|
|
118
|
+
record.context_candidate && record.context_generation == generation
|
|
119
|
+
end
|
|
120
|
+
candidates = @candidate_resolver.resolve(
|
|
121
|
+
prior_records: prior_records,
|
|
122
|
+
working_records: eligible_working,
|
|
123
|
+
excluded_record_ids: excluded_record_ids
|
|
124
|
+
)
|
|
125
|
+
candidates = merge_hook_candidates(
|
|
126
|
+
candidates,
|
|
127
|
+
hook_candidates,
|
|
128
|
+
agent_root: agent_root,
|
|
129
|
+
execution: execution,
|
|
130
|
+
call_sequence: call_sequence
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
token_budget = TokenBudgetResolver.new(agent: @agent).resolve(model_config)
|
|
134
|
+
mandatory_token_estimate = estimate_values(mandatory_values)
|
|
135
|
+
parts = context_parts
|
|
136
|
+
request = ContextRequest.new(
|
|
137
|
+
agent_id: agent_root.agent_id,
|
|
138
|
+
execution_id: execution.execution_id,
|
|
139
|
+
call_sequence: call_sequence,
|
|
140
|
+
call_mode: call_mode,
|
|
141
|
+
candidates: candidates,
|
|
142
|
+
token_budget: token_budget,
|
|
143
|
+
model_config: model_config,
|
|
144
|
+
previous_manifest: previous_manifest,
|
|
145
|
+
required_coverage: [],
|
|
146
|
+
parts: parts,
|
|
147
|
+
metadata: {"mandatory_token_estimate" => mandatory_token_estimate}
|
|
148
|
+
)
|
|
149
|
+
plan = @policy.call(request)
|
|
150
|
+
validated = ContextPlanValidator.new.validate!(request: request, plan: plan)
|
|
151
|
+
unless validated.plan.derived_contents.empty?
|
|
152
|
+
raise Phronomy::ConfigurationError,
|
|
153
|
+
"Derived Context persistence is not enabled in Context Policy phase 1-4"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
selected_candidates = validated.selected_candidates.sort_by do |candidate|
|
|
157
|
+
[candidate.sequence || 0, candidate.candidate_id]
|
|
158
|
+
end
|
|
159
|
+
segments = Array(base_segments).dup
|
|
160
|
+
append_selected_candidates(segments, selected_candidates, before_history: true)
|
|
161
|
+
append_selected_candidates(segments, selected_candidates, before_history: false)
|
|
162
|
+
segments << current_input_segment if current_input_segment
|
|
163
|
+
|
|
164
|
+
ContextParts::Validators::FinalBudgetValidator.new(
|
|
165
|
+
content_loader: method(:fetch_content)
|
|
166
|
+
).validate!(
|
|
167
|
+
token_budget: token_budget,
|
|
168
|
+
segments: segments,
|
|
169
|
+
extra_values: [tool_definitions]
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
store_manifest(
|
|
173
|
+
call_sequence: call_sequence,
|
|
174
|
+
call_mode: call_mode,
|
|
175
|
+
segments: segments,
|
|
176
|
+
model_config_ref: @persistence.contents.put_json(model_config),
|
|
177
|
+
tool_definitions_ref: tool_definitions_ref ||
|
|
178
|
+
@persistence.contents.put_json(tool_definitions)
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def context_parts
|
|
183
|
+
{
|
|
184
|
+
unit_builder: ContextParts::UnitBuilders::DependencyAwareUnitBuilder.new,
|
|
185
|
+
required_context_resolver: ContextParts::Requirements::RequiredContextResolver.new,
|
|
186
|
+
recent_first_selector: ContextParts::Selectors::RecentFirstSelector.new,
|
|
187
|
+
token_budget_packer: ContextParts::Budget::TokenBudgetPacker.new
|
|
188
|
+
}.freeze
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def estimate_values(values)
|
|
192
|
+
Array(values).sum do |value|
|
|
193
|
+
bytes = value.is_a?(String) ? value : Phronomy::CanonicalJSON.dump(value)
|
|
194
|
+
Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def store_manifest(call_sequence:, call_mode:, segments:, model_config_ref:, tool_definitions_ref:)
|
|
199
|
+
positioned = segments.each_with_index.map do |value, position|
|
|
200
|
+
LLMInputManifest::Segment.new(**value.merge(position: position))
|
|
201
|
+
end
|
|
202
|
+
manifest = LLMInputManifest.new(
|
|
203
|
+
call_sequence: call_sequence,
|
|
204
|
+
call_mode: call_mode,
|
|
205
|
+
segments: positioned,
|
|
206
|
+
model_config_ref: model_config_ref,
|
|
207
|
+
tool_definitions_ref: tool_definitions_ref,
|
|
208
|
+
assembly_policy_version: ASSEMBLY_POLICY_VERSION,
|
|
209
|
+
ruby_llm_version: defined?(RubyLLM::VERSION) ? RubyLLM::VERSION : nil,
|
|
210
|
+
adapter_name: Phronomy.configuration.llm_adapter.class.name
|
|
211
|
+
)
|
|
212
|
+
[manifest, @persistence.contents.put_json(manifest.to_h)]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def effective_model_config(config, patch)
|
|
216
|
+
apply_model_config_patch(model_config(config), patch.model_config_patch)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def model_config(config)
|
|
220
|
+
{
|
|
221
|
+
"model" => @agent.class.model&.to_s,
|
|
222
|
+
"provider" => @agent.class.provider&.to_s,
|
|
223
|
+
"temperature" => @agent.class.temperature,
|
|
224
|
+
"max_output_tokens" => @agent.class.max_output_tokens,
|
|
225
|
+
"context_window" => @agent.class.context_window,
|
|
226
|
+
"cache_instructions" => !!@agent.class.cache_instructions,
|
|
227
|
+
"parallel_tool_execution" => !!Phronomy.configuration.parallel_tool_execution,
|
|
228
|
+
"thread_id" => config[:thread_id]&.to_s
|
|
229
|
+
}.compact
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def apply_model_config_patch(base, patch)
|
|
233
|
+
return base unless patch
|
|
234
|
+
base.merge(patch.to_h.transform_keys(&:to_s)).compact
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def normalize_candidates(candidates)
|
|
238
|
+
Array(candidates).map do |candidate|
|
|
239
|
+
hash = candidate.to_h
|
|
240
|
+
content = hash.fetch(:content) { hash.fetch("content") }
|
|
241
|
+
category = (hash[:category] || hash["category"] || :knowledge).to_sym
|
|
242
|
+
role = (hash[:role] || hash["role"] || default_role(category)).to_sym
|
|
243
|
+
metadata = hash[:metadata] || hash["metadata"] || {}
|
|
244
|
+
{
|
|
245
|
+
content: content.to_s,
|
|
246
|
+
category: category,
|
|
247
|
+
role: role,
|
|
248
|
+
metadata: metadata.to_h.transform_keys(&:to_s)
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def merge_hook_candidates(candidates, hooks, agent_root:, execution:, call_sequence:)
|
|
254
|
+
next_sequence = Array(candidates).filter_map(&:sequence).max.to_i
|
|
255
|
+
generated = hooks.each_with_index.map do |hook, index|
|
|
256
|
+
content_ref = @persistence.contents.put_text(hook.fetch(:content))
|
|
257
|
+
metadata = hook.fetch(:metadata).merge(
|
|
258
|
+
SEGMENT_ORIGIN_METADATA_KEY => BEFORE_LLM_INPUT_ORIGIN,
|
|
259
|
+
"estimated_tokens" => Phronomy::LlmContextWindow::TokenEstimator.estimate(
|
|
260
|
+
fetch_content(content_ref)
|
|
261
|
+
),
|
|
262
|
+
"source_kind" => "hook"
|
|
263
|
+
)
|
|
264
|
+
ContextCandidate.new(
|
|
265
|
+
candidate_id: "hook:#{execution.execution_id}:#{call_sequence}:#{index}",
|
|
266
|
+
source_kind: :hook,
|
|
267
|
+
category: hook.fetch(:category),
|
|
268
|
+
role: hook.fetch(:role),
|
|
269
|
+
content_ref: content_ref,
|
|
270
|
+
record_id: nil,
|
|
271
|
+
agent_id: agent_root.agent_id,
|
|
272
|
+
execution_id: execution.execution_id,
|
|
273
|
+
llm_call_id: nil,
|
|
274
|
+
tool_call_id: nil,
|
|
275
|
+
sequence: next_sequence + index + 1,
|
|
276
|
+
requirement: :optional,
|
|
277
|
+
priority: 0,
|
|
278
|
+
metadata: metadata
|
|
279
|
+
)
|
|
280
|
+
end
|
|
281
|
+
(Array(candidates) + generated)
|
|
282
|
+
.sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }
|
|
283
|
+
.freeze
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def append_selected_candidates(segments, candidates, before_history:)
|
|
287
|
+
candidates.each do |candidate|
|
|
288
|
+
early = EARLY_CONTEXT_CATEGORIES.include?(candidate.category)
|
|
289
|
+
next unless early == before_history
|
|
290
|
+
|
|
291
|
+
segments << segment_from_candidate(candidate)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def default_role(category)
|
|
296
|
+
(category == :instruction) ? :system : :user
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def text_segment(category, role, content, metadata: {})
|
|
300
|
+
segment(
|
|
301
|
+
category, role, @persistence.contents.put_text(content.to_s), :chat_message,
|
|
302
|
+
metadata: metadata
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def before_llm_input_segment?(segment)
|
|
307
|
+
segment.metadata[SEGMENT_ORIGIN_METADATA_KEY] == BEFORE_LLM_INPUT_ORIGIN
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def segment_from_candidate(candidate)
|
|
311
|
+
metadata = candidate.metadata.reject do |key, _value|
|
|
312
|
+
%w[estimated_tokens source_kind source_sequence].include?(key.to_s)
|
|
313
|
+
end
|
|
314
|
+
metadata = metadata.merge(
|
|
315
|
+
"journal_record_id" => candidate.record_id,
|
|
316
|
+
"journal_sequence" => candidate.metadata["source_sequence"],
|
|
317
|
+
"llm_call_id" => candidate.llm_call_id
|
|
318
|
+
).compact
|
|
319
|
+
segment(
|
|
320
|
+
candidate.category,
|
|
321
|
+
candidate.role,
|
|
322
|
+
candidate.content_ref,
|
|
323
|
+
:chat_message,
|
|
324
|
+
tool_call_id: candidate.tool_call_id,
|
|
325
|
+
metadata: metadata
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def segment_hash(existing)
|
|
330
|
+
{
|
|
331
|
+
category: existing.category, role: existing.role,
|
|
332
|
+
content_ref: existing.content_ref, delivery: existing.delivery,
|
|
333
|
+
tool_call_id: existing.tool_call_id, metadata: existing.metadata
|
|
334
|
+
}
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def segment(category, role, content_ref, delivery, tool_call_id: nil, metadata: {})
|
|
338
|
+
{category: category, role: role, content_ref: content_ref, delivery: delivery,
|
|
339
|
+
tool_call_id: tool_call_id, metadata: metadata}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def fetch_json(ref)
|
|
343
|
+
Phronomy::CanonicalJSON.load(@persistence.contents.fetch(ref))
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def fetch_content(ref)
|
|
347
|
+
@persistence.contents.fetch_text(ref)
|
|
348
|
+
rescue
|
|
349
|
+
@persistence.contents.fetch(ref)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def build_system_text(input)
|
|
353
|
+
@agent.send(:build_instructions, input)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
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
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextImporter
|
|
6
|
+
ImportedRecord = Data.define(
|
|
7
|
+
:kind, :channel, :role, :content, :content_format, :metadata
|
|
8
|
+
) do
|
|
9
|
+
def initialize(**values)
|
|
10
|
+
super(**values.merge(
|
|
11
|
+
content: Immutable.copy(values[:content]),
|
|
12
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
13
|
+
))
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ImportedContext = Data.define(:records) do
|
|
19
|
+
def initialize(records:)
|
|
20
|
+
super(records: records.freeze)
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def import_messages(messages, system_message: :reject)
|
|
27
|
+
source_messages = Array(messages)
|
|
28
|
+
validate_protocol!(source_messages)
|
|
29
|
+
records = source_messages.map do |message|
|
|
30
|
+
import_message(message, system_message: system_message)
|
|
31
|
+
end
|
|
32
|
+
ImportedContext.new(records: records.freeze)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Import validation is defined by the external-message contract, not by
|
|
38
|
+
# limitations of Phronomy's internal representation. Each supplied
|
|
39
|
+
# message is one explicit logical message and is journaled without
|
|
40
|
+
# splitting its assistant content from its Tool Calls.
|
|
41
|
+
def validate_protocol!(messages)
|
|
42
|
+
pending = {}
|
|
43
|
+
seen_tool_call_ids = {}
|
|
44
|
+
|
|
45
|
+
messages.each_with_index do |message, index|
|
|
46
|
+
role = read(message, :role).to_sym
|
|
47
|
+
|
|
48
|
+
if pending.any? && role != :tool
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"message #{index} appears before Tool Results for: #{pending.keys.join(", ")}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
case role
|
|
54
|
+
when :assistant
|
|
55
|
+
tool_calls_for(message).each do |call|
|
|
56
|
+
payload = normalize(read_tool_call(call))
|
|
57
|
+
call_id = payload.fetch("id").to_s
|
|
58
|
+
raise ArgumentError, "tool_call requires id" if call_id.empty?
|
|
59
|
+
if seen_tool_call_ids[call_id]
|
|
60
|
+
raise ArgumentError, "duplicate tool_call id: #{call_id}"
|
|
61
|
+
end
|
|
62
|
+
seen_tool_call_ids[call_id] = true
|
|
63
|
+
pending[call_id] = true
|
|
64
|
+
end
|
|
65
|
+
when :tool
|
|
66
|
+
call_id = read_optional(message, :tool_call_id).to_s
|
|
67
|
+
raise ArgumentError, "tool message requires tool_call_id" if call_id.empty?
|
|
68
|
+
unless pending.delete(call_id)
|
|
69
|
+
raise ArgumentError, "orphan or duplicate Tool Result: #{call_id}"
|
|
70
|
+
end
|
|
71
|
+
when :user, :system
|
|
72
|
+
# No Tool protocol state is introduced by these roles.
|
|
73
|
+
else
|
|
74
|
+
raise ArgumentError, "unsupported message role: #{role.inspect}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return if pending.empty?
|
|
79
|
+
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"imported history ends before Tool Results for: #{pending.keys.join(", ")}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def import_message(message, system_message:)
|
|
85
|
+
role = read(message, :role).to_sym
|
|
86
|
+
case role
|
|
87
|
+
when :system
|
|
88
|
+
if system_message == :reject
|
|
89
|
+
raise ArgumentError,
|
|
90
|
+
"system messages must be imported as explicit instruction segments"
|
|
91
|
+
end
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"unsupported system_message policy: #{system_message.inspect}"
|
|
94
|
+
when :user
|
|
95
|
+
text_record(:external_message, :external, :user, read(message, :content))
|
|
96
|
+
when :assistant
|
|
97
|
+
import_assistant(message)
|
|
98
|
+
when :tool
|
|
99
|
+
import_tool_message(message)
|
|
100
|
+
else
|
|
101
|
+
raise ArgumentError, "unsupported message role: #{role.inspect}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def import_assistant(message)
|
|
106
|
+
calls = tool_calls_for(message).map { |call| normalize(read_tool_call(call)) }
|
|
107
|
+
content = read_optional(message, :content)
|
|
108
|
+
content = "" if content.nil? && !calls.empty?
|
|
109
|
+
if (content.nil? || (content.respond_to?(:empty?) && content.empty?)) && calls.empty?
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"assistant message requires content or one or more tool_calls"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
payload = {
|
|
115
|
+
"role" => "assistant",
|
|
116
|
+
"content" => normalize(content),
|
|
117
|
+
"tool_calls" => calls
|
|
118
|
+
}
|
|
119
|
+
model_id = read_optional(message, :model_id)
|
|
120
|
+
payload["model_id"] = model_id.to_s if model_id
|
|
121
|
+
|
|
122
|
+
ImportedRecord.new(
|
|
123
|
+
kind: :assistant_message,
|
|
124
|
+
channel: :llm,
|
|
125
|
+
role: :assistant,
|
|
126
|
+
content: payload,
|
|
127
|
+
content_format: :json,
|
|
128
|
+
metadata: assistant_metadata(calls)
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def import_tool_message(message)
|
|
133
|
+
tool_call_id = read_optional(message, :tool_call_id).to_s
|
|
134
|
+
raise ArgumentError, "tool message requires tool_call_id" if tool_call_id.empty?
|
|
135
|
+
|
|
136
|
+
ImportedRecord.new(
|
|
137
|
+
kind: :tool_message,
|
|
138
|
+
channel: :tool,
|
|
139
|
+
role: :tool,
|
|
140
|
+
content: {
|
|
141
|
+
"role" => "tool",
|
|
142
|
+
"content" => String(read(message, :content)),
|
|
143
|
+
"tool_call_id" => tool_call_id
|
|
144
|
+
},
|
|
145
|
+
content_format: :json,
|
|
146
|
+
metadata: {"tool_call_id" => tool_call_id}
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def assistant_metadata(calls)
|
|
151
|
+
{
|
|
152
|
+
"tool_call_ids" => calls.map { |call| call.fetch("id").to_s },
|
|
153
|
+
"tool_names" => calls.map { |call| call.fetch("name").to_s }
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def tool_calls_for(message)
|
|
158
|
+
tool_calls = read_optional(message, :tool_calls)
|
|
159
|
+
Array(tool_calls.respond_to?(:values) ? tool_calls.values : tool_calls)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def text_record(kind, channel, role, content)
|
|
163
|
+
ImportedRecord.new(
|
|
164
|
+
kind: kind,
|
|
165
|
+
channel: channel,
|
|
166
|
+
role: role,
|
|
167
|
+
content: String(content),
|
|
168
|
+
content_format: :text,
|
|
169
|
+
metadata: {}
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def read_tool_call(call)
|
|
174
|
+
return call.to_h if call.respond_to?(:to_h)
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
id: read(call, :id),
|
|
178
|
+
name: read(call, :name),
|
|
179
|
+
arguments: read_optional(call, :arguments) || {},
|
|
180
|
+
thought_signature: read_optional(call, :thought_signature)
|
|
181
|
+
}.compact
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read(value, name)
|
|
185
|
+
result = read_optional(value, name)
|
|
186
|
+
raise ArgumentError, "message is missing #{name}" if result.nil?
|
|
187
|
+
|
|
188
|
+
result
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def read_optional(value, name)
|
|
192
|
+
return value.public_send(name) if value.respond_to?(name)
|
|
193
|
+
return value[name] if value.respond_to?(:key?) && value.key?(name)
|
|
194
|
+
return value[name.to_s] if value.respond_to?(:key?) && value.key?(name.to_s)
|
|
195
|
+
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def normalize(value)
|
|
200
|
+
case value
|
|
201
|
+
when Hash
|
|
202
|
+
value.to_h { |key, child| [key.to_s, normalize(child)] }
|
|
203
|
+
when Array
|
|
204
|
+
value.map { |child| normalize(child) }
|
|
205
|
+
when Symbol
|
|
206
|
+
value.to_s
|
|
207
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
208
|
+
value
|
|
209
|
+
else
|
|
210
|
+
value.respond_to?(:to_h) ? normalize(value.to_h) :
|
|
211
|
+
raise(ArgumentError, "unsupported imported value: #{value.class}")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|