phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -3,9 +3,12 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  class ContextAssembler
6
- ASSEMBLY_POLICY_VERSION = 5
6
+ ASSEMBLY_POLICY_VERSION = 6
7
7
  SEGMENT_ORIGIN_METADATA_KEY = "phronomy_origin"
8
8
  BEFORE_LLM_INPUT_ORIGIN = "before_llm_input"
9
+ EARLY_CONTEXT_CATEGORIES = %i[
10
+ instruction structured_state knowledge memory summary
11
+ ].freeze
9
12
 
10
13
  def initialize(
11
14
  agent:,
@@ -45,14 +48,13 @@ module Phronomy
45
48
  model_config: model_cfg,
46
49
  tool_definitions: tool_set.definitions,
47
50
  tool_definitions_ref: nil,
48
- prior_records: projection.transcript_records,
51
+ prior_records: projection.context_records,
49
52
  working_records: execution.working_records,
50
53
  excluded_record_ids: excluded,
51
54
  base_segments: base_segments,
52
55
  hook_candidates: hook_candidates,
53
56
  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) }
57
+ mandatory_values: [system_text, current_input, tool_set.definitions]
56
58
  )
57
59
  end
58
60
 
@@ -81,14 +83,13 @@ module Phronomy
81
83
  model_config: model_cfg,
82
84
  tool_definitions: tool_definitions,
83
85
  tool_definitions_ref: base_manifest.tool_definitions_ref,
84
- prior_records: projection.transcript_records,
86
+ prior_records: projection.context_records,
85
87
  working_records: execution.working_records,
86
88
  excluded_record_ids: [],
87
89
  base_segments: system_segments.map { |existing| segment_hash(existing) },
88
90
  hook_candidates: hook_candidates,
89
91
  current_input_segment: nil,
90
92
  mandatory_values: system_segments.map { |segment| fetch_content(segment.content_ref) } +
91
- hook_candidates.map { |candidate| candidate.fetch(:content) } +
92
93
  [tool_definitions]
93
94
  )
94
95
  end
@@ -121,6 +122,14 @@ module Phronomy
121
122
  working_records: eligible_working,
122
123
  excluded_record_ids: excluded_record_ids
123
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
+
124
133
  token_budget = TokenBudgetResolver.new(agent: @agent).resolve(model_config)
125
134
  mandatory_token_estimate = estimate_values(mandatory_values)
126
135
  parts = context_parts
@@ -144,12 +153,12 @@ module Phronomy
144
153
  "Derived Context persistence is not enabled in Context Policy phase 1-4"
145
154
  end
146
155
 
156
+ selected_candidates = validated.selected_candidates.sort_by do |candidate|
157
+ [candidate.sequence || 0, candidate.candidate_id]
158
+ end
147
159
  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)
160
+ append_selected_candidates(segments, selected_candidates, before_history: true)
161
+ append_selected_candidates(segments, selected_candidates, before_history: false)
153
162
  segments << current_input_segment if current_input_segment
154
163
 
155
164
  ContextParts::Validators::FinalBudgetValidator.new(
@@ -231,20 +240,55 @@ module Phronomy
231
240
  content = hash.fetch(:content) { hash.fetch("content") }
232
241
  category = (hash[:category] || hash["category"] || :knowledge).to_sym
233
242
  role = (hash[:role] || hash["role"] || default_role(category)).to_sym
234
- {content: content.to_s, category: category, role: role}
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
+ )
235
280
  end
281
+ (Array(candidates) + generated)
282
+ .sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }
283
+ .freeze
236
284
  end
237
285
 
238
- def append_candidates(segments, candidates, before_history:)
286
+ def append_selected_candidates(segments, candidates, before_history:)
239
287
  candidates.each do |candidate|
240
- early = %i[instruction structured_state knowledge memory summary].include?(candidate[:category])
288
+ early = EARLY_CONTEXT_CATEGORIES.include?(candidate.category)
241
289
  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
- )
290
+
291
+ segments << segment_from_candidate(candidate)
248
292
  end
249
293
  end
250
294
 
@@ -306,15 +350,7 @@ module Phronomy
306
350
  end
307
351
 
308
352
  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")
353
+ @agent.send(:build_instructions, input)
318
354
  end
319
355
  end
320
356
  end
@@ -8,33 +8,29 @@ module Phronomy
8
8
  module UnitBuilders
9
9
  class DependencyAwareUnitBuilder
10
10
  def build(candidates)
11
- ordered = Array(candidates).sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }
12
- by_id = ordered.to_h { |candidate| [candidate.candidate_id, candidate] }
11
+ ordered = Array(candidates).sort_by do |candidate|
12
+ [candidate.sequence || 0, candidate.candidate_id]
13
+ end
13
14
 
14
- canonical_assistants = ordered.select { |candidate| candidate.category == :assistant_message }
15
- canonical_tool_messages = ordered.select { |candidate| candidate.category == :tool_message }
16
- .group_by(&:tool_call_id)
15
+ canonical_assistants = ordered.select do |candidate|
16
+ candidate.category == :assistant_message
17
+ end
18
+ canonical_tool_messages = ordered.select do |candidate|
19
+ candidate.category == :tool_message
20
+ end.group_by(&:tool_call_id)
17
21
  assistant_by_tool_call_id = canonical_assistants.each_with_object({}) do |candidate, result|
18
22
  canonical_tool_call_ids(candidate).each do |tool_call_id|
19
23
  if result.key?(tool_call_id)
20
- raise ArgumentError, "duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
24
+ raise ArgumentError,
25
+ "duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
21
26
  end
22
27
  result[tool_call_id] = candidate
23
28
  end
24
29
  end
25
30
 
26
- legacy_tool_results = ordered.select { |candidate| candidate.category == :tool_result }
27
- .group_by(&:tool_call_id)
28
- legacy_calls_by_llm = ordered.select do |candidate|
29
- candidate.category == :tool_call && candidate.llm_call_id
30
- end.group_by(&:llm_call_id)
31
- legacy_assistant_by_llm = ordered.select do |candidate|
32
- candidate.llm_call_id && %i[llm_message tool_call].include?(candidate.category)
33
- end.group_by(&:llm_call_id)
34
-
35
31
  claimed = Set.new
36
32
  units = []
37
- ordered.each_with_index do |candidate, index|
33
+ ordered.each do |candidate|
38
34
  next if claimed.include?(candidate.candidate_id)
39
35
 
40
36
  group = canonical_tool_exchange(
@@ -42,23 +38,13 @@ module Phronomy
42
38
  assistant_by_tool_call_id: assistant_by_tool_call_id,
43
39
  tool_messages: canonical_tool_messages
44
40
  )
45
- group ||= if legacy_runtime_tool_exchange?(candidate, legacy_calls_by_llm)
46
- legacy_runtime_tool_exchange(
47
- candidate,
48
- assistant_by_llm: legacy_assistant_by_llm,
49
- calls_by_llm: legacy_calls_by_llm,
50
- tool_results: legacy_tool_results
51
- )
52
- else
53
- legacy_import_tool_exchange(ordered, index, legacy_tool_results)
54
- end
55
41
 
56
42
  if group && !group.empty?
57
43
  group.each { |item| claimed << item.candidate_id }
58
44
  units << build_unit(group, kind: :tool_exchange)
59
45
  else
60
46
  claimed << candidate.candidate_id
61
- units << build_unit([by_id.fetch(candidate.candidate_id)], kind: :message)
47
+ units << build_unit([candidate], kind: :message)
62
48
  end
63
49
  end
64
50
 
@@ -78,8 +64,12 @@ module Phronomy
78
64
  call_ids = canonical_tool_call_ids(assistant)
79
65
  return if call_ids.empty?
80
66
 
81
- messages = call_ids.flat_map { |tool_call_id| tool_messages.fetch(tool_call_id, []) }
82
- ([assistant] + messages).uniq.sort_by { |item| [item.sequence || 0, item.candidate_id] }
67
+ messages = call_ids.flat_map do |tool_call_id|
68
+ tool_messages.fetch(tool_call_id, [])
69
+ end
70
+ ([assistant] + messages).uniq.sort_by do |item|
71
+ [item.sequence || 0, item.candidate_id]
72
+ end
83
73
  end
84
74
 
85
75
  def canonical_tool_call_ids(candidate)
@@ -88,66 +78,6 @@ module Phronomy
88
78
  .map(&:to_s)
89
79
  end
90
80
 
91
- def legacy_runtime_tool_exchange?(candidate, calls_by_llm)
92
- candidate.llm_call_id &&
93
- calls_by_llm.key?(candidate.llm_call_id) &&
94
- %i[llm_message tool_call].include?(candidate.category)
95
- end
96
-
97
- def legacy_runtime_tool_exchange(candidate, assistant_by_llm:, calls_by_llm:, tool_results:)
98
- llm_call_id = candidate.llm_call_id
99
- calls = calls_by_llm.fetch(llm_call_id)
100
- call_ids = calls.map(&:tool_call_id).compact
101
- assistant = assistant_by_llm.fetch(llm_call_id, [])
102
- results = call_ids.flat_map { |id| tool_results.fetch(id, []) }
103
- (assistant + results).uniq.sort_by { |item| [item.sequence || 0, item.candidate_id] }
104
- end
105
-
106
- def legacy_import_tool_exchange(ordered, index, tool_results)
107
- candidate = ordered.fetch(index)
108
- return unless %i[llm_message tool_call].include?(candidate.category)
109
- return if candidate.llm_call_id
110
-
111
- group = []
112
- cursor = index
113
- if candidate.category == :llm_message
114
- next_candidate = ordered[cursor + 1]
115
- return unless next_candidate &&
116
- next_candidate.category == :tool_call &&
117
- contiguous_source?(candidate, next_candidate) &&
118
- next_candidate.llm_call_id.nil?
119
- group << candidate
120
- cursor += 1
121
- end
122
-
123
- calls = []
124
- while (item = ordered[cursor]) && item.category == :tool_call && item.llm_call_id.nil?
125
- if calls.any? && !contiguous_source?(calls.last, item)
126
- break
127
- end
128
- if group.any? && calls.empty? && !contiguous_source?(group.last, item)
129
- break
130
- end
131
- calls << item
132
- cursor += 1
133
- end
134
- return if calls.empty?
135
-
136
- call_ids = calls.map(&:tool_call_id).compact
137
- results = call_ids.flat_map { |id| tool_results.fetch(id, []) }
138
- (group + calls + results).uniq.sort_by { |item| [item.sequence || 0, item.candidate_id] }
139
- end
140
-
141
- def contiguous_source?(left, right)
142
- left_source = left.metadata["source_sequence"]
143
- right_source = right.metadata["source_sequence"]
144
- if left_source && right_source
145
- Integer(right_source) == Integer(left_source) + 1
146
- else
147
- Integer(right.sequence) == Integer(left.sequence) + 1
148
- end
149
- end
150
-
151
81
  def build_unit(candidates, kind:)
152
82
  ids = candidates.map(&:candidate_id)
153
83
  sequences = candidates.map(&:sequence).compact
@@ -58,7 +58,6 @@ module Phronomy
58
58
  def validate_tool_dependencies!(all_candidates, selected_candidates)
59
59
  selected_ids = selected_candidates.to_h { |candidate| [candidate.candidate_id, true] }
60
60
  validate_canonical_tool_dependencies!(all_candidates, selected_ids)
61
- validate_legacy_tool_dependencies!(all_candidates, selected_candidates, selected_ids)
62
61
  end
63
62
 
64
63
  def validate_canonical_tool_dependencies!(all_candidates, selected_ids)
@@ -113,38 +112,6 @@ module Phronomy
113
112
  end
114
113
  end
115
114
 
116
- # Compatibility for Journal state written by the preceding stateful
117
- # refactor, where assistant content, Tool Calls and Tool Results were
118
- # flattened into separate Context candidates.
119
- def validate_legacy_tool_dependencies!(all_candidates, selected_candidates, selected_ids)
120
- all_calls = Array(all_candidates).select { |candidate| candidate.category == :tool_call }
121
- .to_h { |candidate| [candidate.tool_call_id, candidate] }
122
- all_results = Array(all_candidates).select { |candidate| candidate.category == :tool_result }
123
- .group_by(&:tool_call_id)
124
-
125
- selected_candidates.each do |candidate|
126
- case candidate.category
127
- when :tool_result
128
- call = all_calls[candidate.tool_call_id]
129
- unless call && selected_ids[call.candidate_id]
130
- raise ArgumentError,
131
- "ContextPlan selected orphan Tool Result: #{candidate.tool_call_id}"
132
- end
133
- when :tool_call
134
- results = all_results.fetch(candidate.tool_call_id, [])
135
- if results.empty?
136
- raise ArgumentError,
137
- "ContextPlan selected Tool Call without a Tool Result: #{candidate.tool_call_id}"
138
- end
139
- missing = results.reject { |result| selected_ids[result.candidate_id] }
140
- unless missing.empty?
141
- raise ArgumentError,
142
- "ContextPlan split Tool Call/Result dependency: #{candidate.tool_call_id}"
143
- end
144
- end
145
- end
146
- end
147
-
148
115
  def canonical_tool_call_ids(candidate)
149
116
  Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
150
117
  .compact
@@ -19,7 +19,7 @@ module Phronomy
19
19
  )
20
20
  result_task = Phronomy::Task.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
21
21
  runtime = Phronomy::Runtime.instance
22
- preparation = runtime.blocking_io.submit do
22
+ preparation = runtime.offload.submit(on_full: :raise) do
23
23
  prepare(input, thread_id: thread_id, config: config)
24
24
  end
25
25
  preparation.on_complete do |prepared, error|
@@ -52,7 +52,7 @@ module Phronomy
52
52
  name: "agent-approval-resume:#{execution_id}"
53
53
  )
54
54
  runtime = Phronomy::Runtime.instance
55
- preparation = runtime.blocking_io.submit do
55
+ preparation = runtime.offload.submit(on_full: :raise) do
56
56
  execution = @agent.persistence.executions.load(execution_id)
57
57
  unless execution.agent_id == @agent.agent_id && execution.status == :suspended
58
58
  raise ArgumentError,
@@ -86,7 +86,7 @@ module Phronomy
86
86
  config: config
87
87
  )
88
88
  rescue => start_error
89
- commit = runtime.blocking_io.submit do
89
+ commit = runtime.offload.submit(on_full: :raise) do
90
90
  commit_failed(activation, activation.invocation, start_error)
91
91
  end
92
92
  commit.on_complete do |terminal, commit_error|
@@ -107,7 +107,7 @@ module Phronomy
107
107
 
108
108
  # Persists all Runtime events from the previous call, fixes the next
109
109
  # Canonical LLM Input Manifest, and materializes the next Runtime Projection.
110
- # This method is executed on the blocking adapter pool, never on EventLoop.
110
+ # This method is executed on an OffloadPool worker, never on EventLoop.
111
111
  def prepare_next_llm_call(activation)
112
112
  snapshot = activation.runtime_snapshot
113
113
  manifest = manifest_ref = updated = root = nil
@@ -367,7 +367,6 @@ module Phronomy
367
367
  session = Agent::AgentInvocationSessionBuilder.build(
368
368
  agent: @agent,
369
369
  input: prepared.filtered_input,
370
- messages: prepared.runtime_projection.messages,
371
370
  config: effective_config,
372
371
  approval_policy: approval_policy,
373
372
  approval_listener: approval_listener,
@@ -398,7 +397,7 @@ module Phronomy
398
397
 
399
398
  def finish(activation, result_task, invocation, error)
400
399
  runtime = Phronomy::Runtime.instance
401
- operation = runtime.blocking_io.submit do
400
+ operation = runtime.offload.submit(on_full: :raise) do
402
401
  compute_terminal(activation, invocation, error)
403
402
  end
404
403
  operation.on_complete do |outcome, commit_error|
@@ -1070,7 +1069,7 @@ module Phronomy
1070
1069
  def dispatch_approval_listener(invocation, request)
1071
1070
  listener = invocation.approval_listener
1072
1071
  return unless listener
1073
- Phronomy::Runtime.instance.blocking_io.submit { listener.call(request) }
1072
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) { listener.call(request) }
1074
1073
  end
1075
1074
 
1076
1075
  def json_value(value)
@@ -4,9 +4,11 @@ module Phronomy
4
4
  module Agent
5
5
  class JournalProjection
6
6
  MESSAGE_KINDS = %i[
7
- external_message assistant_message tool_message
8
- llm_message tool_call tool_result
7
+ external_message
8
+ assistant_message
9
+ tool_message
9
10
  ].freeze
11
+ KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
10
12
 
11
13
  def initialize(persistence:, agent_root:)
12
14
  @persistence = persistence
@@ -22,6 +24,16 @@ module Phronomy
22
24
  end
23
25
  end
24
26
 
27
+ # Returns all persistent records eligible for Context selection.
28
+ # Knowledge has an independent logical lifetime from the transcript:
29
+ # clear_transcript! does not remove Knowledge, while knowledge_cleared and
30
+ # context_reset invalidate earlier Knowledge records by Journal position.
31
+ def context_records
32
+ (transcript_records + active_knowledge_records)
33
+ .sort_by { |record| [record.sequence || 0, record.record_id] }
34
+ .freeze
35
+ end
36
+
25
37
  def llm_call_records
26
38
  records.select { |record| record.kind == :llm_call_recorded }
27
39
  end
@@ -29,6 +41,20 @@ module Phronomy
29
41
  def records
30
42
  @records ||= @persistence.journals.read(@agent_root.agent_id)
31
43
  end
44
+
45
+ private
46
+
47
+ def active_knowledge_records
48
+ reset_sequence = records.filter_map do |record|
49
+ record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
50
+ end.max.to_i
51
+
52
+ records.select do |record|
53
+ record.context_candidate &&
54
+ record.kind == :knowledge &&
55
+ record.sequence.to_i > reset_sequence
56
+ end
57
+ end
32
58
  end
33
59
  end
34
60
  end
@@ -112,102 +112,7 @@ module Phronomy
112
112
  end
113
113
 
114
114
  def materialize_message_segments(segments)
115
- result = []
116
- index = 0
117
- while index < segments.length
118
- segment = segments[index]
119
- if legacy_assistant_segment?(segment)
120
- grouped, consumed = legacy_assistant_group(segments, index)
121
- result << materialize_legacy_assistant_group(grouped)
122
- index += consumed
123
- else
124
- result << materialize_segment(segment)
125
- index += 1
126
- end
127
- end
128
- result
129
- end
130
-
131
- # New canonical records preserve one logical assistant/tool message per
132
- # record. These legacy helpers remain only so already-persisted state from
133
- # the preceding stateful refactor can still be read during migration.
134
- def legacy_assistant_segment?(segment)
135
- segment.role&.to_sym == :assistant &&
136
- %i[llm_message tool_call].include?(segment.category.to_sym)
137
- end
138
-
139
- def legacy_assistant_group(segments, start_index)
140
- first = segments.fetch(start_index)
141
- llm_call_id = metadata_value(first, "llm_call_id")
142
- return legacy_runtime_assistant_group(segments, start_index, llm_call_id) if llm_call_id
143
-
144
- legacy_import_assistant_group(segments, start_index)
145
- end
146
-
147
- def legacy_runtime_assistant_group(segments, start_index, llm_call_id)
148
- group = []
149
- index = start_index
150
- while (segment = segments[index]) && legacy_assistant_segment?(segment) &&
151
- metadata_value(segment, "llm_call_id").to_s == llm_call_id.to_s
152
- group << segment
153
- index += 1
154
- end
155
- [group, group.length]
156
- end
157
-
158
- def legacy_import_assistant_group(segments, start_index)
159
- first = segments.fetch(start_index)
160
- group = [first]
161
- index = start_index + 1
162
- last = first
163
-
164
- while (segment = segments[index]) && legacy_assistant_segment?(segment)
165
- break unless segment.category.to_sym == :tool_call
166
- break unless contiguous_source?(last, segment)
167
- break if metadata_value(segment, "llm_call_id")
168
-
169
- group << segment
170
- last = segment
171
- index += 1
172
- end
173
- [group, group.length]
174
- end
175
-
176
- def contiguous_source?(left, right)
177
- left_sequence = metadata_value(left, "journal_sequence")
178
- right_sequence = metadata_value(right, "journal_sequence")
179
- return false unless left_sequence && right_sequence
180
-
181
- Integer(right_sequence) == Integer(left_sequence) + 1
182
- end
183
-
184
- def materialize_legacy_assistant_group(segments)
185
- content_segments = segments.select { |segment| segment.category.to_sym == :llm_message }
186
- if content_segments.length > 1
187
- raise ArgumentError, "ambiguous assistant message group contains multiple content records"
188
- end
189
-
190
- tool_calls = {}
191
- segments.select { |segment| segment.category.to_sym == :tool_call }.each do |segment|
192
- payload = fetch_json(segment.content_ref)
193
- tool_call_id = payload.fetch("id").to_s
194
- if tool_calls.key?(tool_call_id)
195
- raise ArgumentError, "duplicate Tool Call in assistant message: #{tool_call_id}"
196
- end
197
- tool_calls[tool_call_id] = materialize_tool_call(payload)
198
- end
199
-
200
- content = if content_segments.empty?
201
- ""
202
- else
203
- @persistence.contents.fetch_text(content_segments.first.content_ref)
204
- end
205
-
206
- RubyLLM::Message.new(
207
- role: :assistant,
208
- content: content,
209
- tool_calls: tool_calls.empty? ? nil : tool_calls
210
- )
115
+ segments.map { |segment| materialize_segment(segment) }
211
116
  end
212
117
 
213
118
  def materialize_segment(segment)
@@ -216,18 +121,8 @@ module Phronomy
216
121
  materialize_canonical_message(segment, expected_role: :assistant)
217
122
  when :tool_message
218
123
  materialize_canonical_message(segment, expected_role: :tool)
219
- when :tool_call
220
- materialize_legacy_assistant_group([segment])
221
124
  when :tool_result
222
- unless segment.role&.to_sym == :tool
223
- raise ArgumentError, "raw Tool result is not an LLM message"
224
- end
225
- RubyLLM::Message.new(
226
- role: :tool,
227
- content: @persistence.contents.fetch_text(segment.content_ref),
228
- tool_call_id: segment.tool_call_id ||
229
- segment.metadata["tool_call_id"] || segment.metadata[:tool_call_id]
230
- )
125
+ raise ArgumentError, "raw Tool result is not an LLM message"
231
126
  else
232
127
  RubyLLM::Message.new(
233
128
  role: segment.role,
@@ -289,10 +184,6 @@ module Phronomy
289
184
  thought_signature: payload["thought_signature"]
290
185
  )
291
186
  end
292
-
293
- def metadata_value(segment, name)
294
- segment.metadata[name] || segment.metadata[name.to_sym]
295
- end
296
187
  end
297
188
  end
298
189
  end