phronomy 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -18
- data/README.md +300 -75
- data/benchmark/bench_agent_invoke.rb +3 -0
- data/benchmark/bench_regression.rb +2 -18
- data/benchmark/bench_tool_schema.rb +1 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +42 -10
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
- data/lib/phronomy/agent/agent_root.rb +67 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -393
- data/lib/phronomy/agent/base.rb +301 -641
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context_assembler.rb +321 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +167 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +34 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
- data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent.rb +14 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +9 -4
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/assembler.rb +8 -8
- data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +11 -0
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -47
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
|
@@ -0,0 +1,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
|