phronomy 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -28
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -475
  16. data/lib/phronomy/agent/base.rb +301 -285
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -7
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -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
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module ContextParts
6
+ module Budget
7
+ class TokenBudgetPacker
8
+ def pack(request:, units:)
9
+ return Array(units).freeze unless request.token_budget
10
+
11
+ candidate_index = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
12
+ mandatory = Integer(request.metadata["mandatory_token_estimate"] || 0)
13
+ limit = request.token_budget.effective_input_limit
14
+ if mandatory > limit
15
+ raise Phronomy::ContextBudgetExceededError,
16
+ "Mandatory content (estimated #{mandatory} tokens) exceeds " \
17
+ "available input budget (#{limit} tokens)"
18
+ end
19
+
20
+ remaining = limit - mandatory
21
+ required, optional = Array(units).partition { |unit| unit.requirement != :optional }
22
+ required_cost = required.sum { |unit| unit_cost(unit, candidate_index) }
23
+ if required_cost > remaining
24
+ raise Phronomy::ContextBudgetExceededError,
25
+ "Required Context (estimated #{required_cost} tokens) exceeds " \
26
+ "remaining input budget (#{remaining} tokens after mandatory content)"
27
+ end
28
+
29
+ selected = required.dup
30
+ remaining -= required_cost
31
+ optional.each do |unit|
32
+ cost = unit_cost(unit, candidate_index)
33
+ next if cost > remaining
34
+
35
+ selected << unit
36
+ remaining -= cost
37
+ end
38
+ selected.freeze
39
+ end
40
+
41
+ private
42
+
43
+ def unit_cost(unit, candidate_index)
44
+ unit.candidate_ids.sum do |candidate_id|
45
+ candidate = candidate_index.fetch(candidate_id)
46
+ Integer(candidate.metadata["estimated_tokens"] || 0)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module ContextParts
6
+ module Requirements
7
+ class RequiredContextResolver
8
+ def resolve(request:, units:)
9
+ candidates = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
10
+ declared = request.required_coverage.to_h { |value| [value.to_s, true] }
11
+ latest_tool_unit = latest_current_tool_unit(request, units, candidates)
12
+
13
+ Array(units).map do |unit|
14
+ requirement = unit.requirement
15
+ if unit.unit_id == latest_tool_unit&.unit_id
16
+ requirement = :protocol_required
17
+ elsif requirement == :optional && unit.candidate_ids.any? do |candidate_id|
18
+ candidate = candidates.fetch(candidate_id)
19
+ declared[candidate.candidate_id] || declared[candidate.record_id.to_s]
20
+ end
21
+ requirement = :declared_required
22
+ end
23
+
24
+ next unit if requirement == unit.requirement
25
+
26
+ ContextSelectionUnit.new(
27
+ unit_id: unit.unit_id,
28
+ candidate_ids: unit.candidate_ids,
29
+ dependency_unit_ids: unit.dependency_unit_ids,
30
+ kind: unit.kind,
31
+ requirement: requirement,
32
+ priority: unit.priority,
33
+ sequence_range: unit.sequence_range,
34
+ metadata: unit.metadata
35
+ )
36
+ end.freeze
37
+ end
38
+
39
+ private
40
+
41
+ def latest_current_tool_unit(request, units, candidates)
42
+ return unless request.call_mode == :complete
43
+
44
+ Array(units).select do |unit|
45
+ unit.kind == :tool_exchange && unit.candidate_ids.any? do |candidate_id|
46
+ candidate = candidates.fetch(candidate_id)
47
+ candidate.source_kind == :working &&
48
+ candidate.execution_id.to_s == request.execution_id.to_s
49
+ end
50
+ end.max_by { |unit| unit.sequence_range.last }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module ContextParts
6
+ module Selectors
7
+ class RecentFirstSelector
8
+ def order(request:, units:)
9
+ candidates = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
10
+ Array(units).sort_by do |unit|
11
+ required_rank = (unit.requirement == :optional) ? 1 : 0
12
+ current_rank = current_execution_unit?(request, unit, candidates) ? 0 : 1
13
+ [required_rank, current_rank, -unit.priority, -unit.sequence_range.last]
14
+ end.freeze
15
+ end
16
+
17
+ private
18
+
19
+ def current_execution_unit?(request, unit, candidates)
20
+ unit.candidate_ids.any? do |candidate_id|
21
+ candidate = candidates.fetch(candidate_id)
22
+ candidate.execution_id.to_s == request.execution_id.to_s &&
23
+ candidate.source_kind == :working
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module ContextParts
8
+ module UnitBuilders
9
+ class DependencyAwareUnitBuilder
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] }
13
+
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)
17
+ assistant_by_tool_call_id = canonical_assistants.each_with_object({}) do |candidate, result|
18
+ canonical_tool_call_ids(candidate).each do |tool_call_id|
19
+ if result.key?(tool_call_id)
20
+ raise ArgumentError, "duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
21
+ end
22
+ result[tool_call_id] = candidate
23
+ end
24
+ end
25
+
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
+ claimed = Set.new
36
+ units = []
37
+ ordered.each_with_index do |candidate, index|
38
+ next if claimed.include?(candidate.candidate_id)
39
+
40
+ group = canonical_tool_exchange(
41
+ candidate,
42
+ assistant_by_tool_call_id: assistant_by_tool_call_id,
43
+ tool_messages: canonical_tool_messages
44
+ )
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
+
56
+ if group && !group.empty?
57
+ group.each { |item| claimed << item.candidate_id }
58
+ units << build_unit(group, kind: :tool_exchange)
59
+ else
60
+ claimed << candidate.candidate_id
61
+ units << build_unit([by_id.fetch(candidate.candidate_id)], kind: :message)
62
+ end
63
+ end
64
+
65
+ units.sort_by { |unit| unit.sequence_range.first }.freeze
66
+ end
67
+
68
+ private
69
+
70
+ def canonical_tool_exchange(candidate, assistant_by_tool_call_id:, tool_messages:)
71
+ assistant = if candidate.category == :assistant_message
72
+ candidate
73
+ elsif candidate.category == :tool_message
74
+ assistant_by_tool_call_id[candidate.tool_call_id]
75
+ end
76
+ return unless assistant
77
+
78
+ call_ids = canonical_tool_call_ids(assistant)
79
+ return if call_ids.empty?
80
+
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] }
83
+ end
84
+
85
+ def canonical_tool_call_ids(candidate)
86
+ Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
87
+ .compact
88
+ .map(&:to_s)
89
+ end
90
+
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
+ def build_unit(candidates, kind:)
152
+ ids = candidates.map(&:candidate_id)
153
+ sequences = candidates.map(&:sequence).compact
154
+ requirements = candidates.map(&:requirement)
155
+ requirement = if requirements.include?(:protocol_required)
156
+ :protocol_required
157
+ elsif requirements.include?(:declared_required)
158
+ :declared_required
159
+ else
160
+ :optional
161
+ end
162
+ tool_call_ids = candidates.flat_map do |candidate|
163
+ call_ids = canonical_tool_call_ids(candidate)
164
+ call_ids << candidate.tool_call_id if candidate.tool_call_id
165
+ call_ids
166
+ end.compact.uniq
167
+ llm_call_ids = candidates.map(&:llm_call_id).compact.uniq
168
+ digest = Digest::SHA256.hexdigest(ids.join("\0"))[0, 20]
169
+
170
+ ContextSelectionUnit.new(
171
+ unit_id: "#{kind}:#{digest}",
172
+ candidate_ids: ids,
173
+ dependency_unit_ids: [],
174
+ kind: kind,
175
+ requirement: requirement,
176
+ priority: candidates.map(&:priority).max || 0,
177
+ sequence_range: [sequences.min || 0, sequences.max || 0],
178
+ metadata: {
179
+ "tool_call_ids" => tool_call_ids,
180
+ "llm_call_ids" => llm_call_ids
181
+ }
182
+ )
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module ContextParts
6
+ module Validators
7
+ class FinalBudgetValidator
8
+ def initialize(content_loader:)
9
+ @content_loader = content_loader
10
+ end
11
+
12
+ def validate!(token_budget:, segments:, extra_values: [])
13
+ return 0 unless token_budget
14
+
15
+ estimated = Array(segments).sum do |segment|
16
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(
17
+ @content_loader.call(segment.fetch(:content_ref))
18
+ )
19
+ end
20
+ estimated += Array(extra_values).sum do |value|
21
+ bytes = value.is_a?(String) ? value : Phronomy::CanonicalJSON.dump(value)
22
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
23
+ end
24
+
25
+ limit = token_budget.effective_input_limit
26
+ if estimated > limit
27
+ raise Phronomy::ContextBudgetExceededError,
28
+ "Canonical LLM input (estimated #{estimated} tokens) exceeds " \
29
+ "available input budget (#{limit} tokens)"
30
+ end
31
+ estimated
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ ContextPlan = Data.define(
6
+ :selected_unit_ids,
7
+ :derived_contents,
8
+ :selected_tool_ids,
9
+ :ordering_hints,
10
+ :policy_descriptor,
11
+ :metadata
12
+ ) do
13
+ def initialize(**values)
14
+ super(**values.merge(
15
+ selected_unit_ids: Array(values[:selected_unit_ids]).map(&:to_s).freeze,
16
+ derived_contents: Array(values[:derived_contents]).freeze,
17
+ selected_tool_ids: Array(values[:selected_tool_ids]).map(&:to_s).freeze,
18
+ ordering_hints: Immutable.copy(values[:ordering_hints] || {}),
19
+ metadata: Immutable.copy(values[:metadata] || {})
20
+ ))
21
+ freeze
22
+ end
23
+ end
24
+ end
25
+ end