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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. 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