phronomy 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
|
@@ -0,0 +1,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,118 @@
|
|
|
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 do |candidate|
|
|
12
|
+
[candidate.sequence || 0, candidate.candidate_id]
|
|
13
|
+
end
|
|
14
|
+
|
|
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)
|
|
21
|
+
assistant_by_tool_call_id = canonical_assistants.each_with_object({}) do |candidate, result|
|
|
22
|
+
canonical_tool_call_ids(candidate).each do |tool_call_id|
|
|
23
|
+
if result.key?(tool_call_id)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
|
|
26
|
+
end
|
|
27
|
+
result[tool_call_id] = candidate
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
claimed = Set.new
|
|
32
|
+
units = []
|
|
33
|
+
ordered.each do |candidate|
|
|
34
|
+
next if claimed.include?(candidate.candidate_id)
|
|
35
|
+
|
|
36
|
+
group = canonical_tool_exchange(
|
|
37
|
+
candidate,
|
|
38
|
+
assistant_by_tool_call_id: assistant_by_tool_call_id,
|
|
39
|
+
tool_messages: canonical_tool_messages
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if group && !group.empty?
|
|
43
|
+
group.each { |item| claimed << item.candidate_id }
|
|
44
|
+
units << build_unit(group, kind: :tool_exchange)
|
|
45
|
+
else
|
|
46
|
+
claimed << candidate.candidate_id
|
|
47
|
+
units << build_unit([candidate], kind: :message)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
units.sort_by { |unit| unit.sequence_range.first }.freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def canonical_tool_exchange(candidate, assistant_by_tool_call_id:, tool_messages:)
|
|
57
|
+
assistant = if candidate.category == :assistant_message
|
|
58
|
+
candidate
|
|
59
|
+
elsif candidate.category == :tool_message
|
|
60
|
+
assistant_by_tool_call_id[candidate.tool_call_id]
|
|
61
|
+
end
|
|
62
|
+
return unless assistant
|
|
63
|
+
|
|
64
|
+
call_ids = canonical_tool_call_ids(assistant)
|
|
65
|
+
return if call_ids.empty?
|
|
66
|
+
|
|
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
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def canonical_tool_call_ids(candidate)
|
|
76
|
+
Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
|
|
77
|
+
.compact
|
|
78
|
+
.map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_unit(candidates, kind:)
|
|
82
|
+
ids = candidates.map(&:candidate_id)
|
|
83
|
+
sequences = candidates.map(&:sequence).compact
|
|
84
|
+
requirements = candidates.map(&:requirement)
|
|
85
|
+
requirement = if requirements.include?(:protocol_required)
|
|
86
|
+
:protocol_required
|
|
87
|
+
elsif requirements.include?(:declared_required)
|
|
88
|
+
:declared_required
|
|
89
|
+
else
|
|
90
|
+
:optional
|
|
91
|
+
end
|
|
92
|
+
tool_call_ids = candidates.flat_map do |candidate|
|
|
93
|
+
call_ids = canonical_tool_call_ids(candidate)
|
|
94
|
+
call_ids << candidate.tool_call_id if candidate.tool_call_id
|
|
95
|
+
call_ids
|
|
96
|
+
end.compact.uniq
|
|
97
|
+
llm_call_ids = candidates.map(&:llm_call_id).compact.uniq
|
|
98
|
+
digest = Digest::SHA256.hexdigest(ids.join("\0"))[0, 20]
|
|
99
|
+
|
|
100
|
+
ContextSelectionUnit.new(
|
|
101
|
+
unit_id: "#{kind}:#{digest}",
|
|
102
|
+
candidate_ids: ids,
|
|
103
|
+
dependency_unit_ids: [],
|
|
104
|
+
kind: kind,
|
|
105
|
+
requirement: requirement,
|
|
106
|
+
priority: candidates.map(&:priority).max || 0,
|
|
107
|
+
sequence_range: [sequences.min || 0, sequences.max || 0],
|
|
108
|
+
metadata: {
|
|
109
|
+
"tool_call_ids" => tool_call_ids,
|
|
110
|
+
"llm_call_ids" => llm_call_ids
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
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
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextPlanValidator
|
|
6
|
+
ValidatedContextPlan = Data.define(
|
|
7
|
+
:plan, :units, :selected_units, :selected_candidates
|
|
8
|
+
) do
|
|
9
|
+
def initialize(**values)
|
|
10
|
+
super(**values.merge(
|
|
11
|
+
units: Array(values[:units]).freeze,
|
|
12
|
+
selected_units: Array(values[:selected_units]).freeze,
|
|
13
|
+
selected_candidates: Array(values[:selected_candidates]).freeze
|
|
14
|
+
))
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate!(request:, plan:)
|
|
20
|
+
unless plan.is_a?(ContextPlan)
|
|
21
|
+
raise ArgumentError, "Context Policy returned #{plan.class}, expected #{ContextPlan}"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
units = request.parts.fetch(:unit_builder).build(request.candidates)
|
|
25
|
+
units = request.parts.fetch(:required_context_resolver).resolve(
|
|
26
|
+
request: request,
|
|
27
|
+
units: units
|
|
28
|
+
)
|
|
29
|
+
unit_index = units.to_h { |unit| [unit.unit_id, unit] }
|
|
30
|
+
unknown = plan.selected_unit_ids.reject { |unit_id| unit_index.key?(unit_id) }
|
|
31
|
+
raise ArgumentError, "ContextPlan contains unknown units: #{unknown.inspect}" unless unknown.empty?
|
|
32
|
+
|
|
33
|
+
selected_units = plan.selected_unit_ids.map { |unit_id| unit_index.fetch(unit_id) }
|
|
34
|
+
required = units.reject { |unit| unit.requirement == :optional }.map(&:unit_id)
|
|
35
|
+
missing_required = required - plan.selected_unit_ids
|
|
36
|
+
unless missing_required.empty?
|
|
37
|
+
raise Phronomy::ContextBudgetExceededError,
|
|
38
|
+
"ContextPlan omitted required units: #{missing_required.inspect}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
candidate_index = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
|
|
42
|
+
selected_candidates = selected_units.flat_map(&:candidate_ids).uniq.map do |candidate_id|
|
|
43
|
+
candidate_index.fetch(candidate_id)
|
|
44
|
+
end
|
|
45
|
+
validate_tool_dependencies!(request.candidates, selected_candidates)
|
|
46
|
+
validate_derived_contents!(request, plan)
|
|
47
|
+
|
|
48
|
+
ValidatedContextPlan.new(
|
|
49
|
+
plan: plan,
|
|
50
|
+
units: units,
|
|
51
|
+
selected_units: selected_units,
|
|
52
|
+
selected_candidates: selected_candidates
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def validate_tool_dependencies!(all_candidates, selected_candidates)
|
|
59
|
+
selected_ids = selected_candidates.to_h { |candidate| [candidate.candidate_id, true] }
|
|
60
|
+
validate_canonical_tool_dependencies!(all_candidates, selected_ids)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def validate_canonical_tool_dependencies!(all_candidates, selected_ids)
|
|
64
|
+
assistants = Array(all_candidates).select { |candidate| candidate.category == :assistant_message }
|
|
65
|
+
tool_messages = Array(all_candidates).select { |candidate| candidate.category == :tool_message }
|
|
66
|
+
.group_by(&:tool_call_id)
|
|
67
|
+
assistant_by_tool_call_id = {}
|
|
68
|
+
|
|
69
|
+
tool_messages.each do |tool_call_id, messages|
|
|
70
|
+
if messages.length > 1
|
|
71
|
+
raise ArgumentError, "duplicate Tool message in Context candidates: #{tool_call_id}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
assistants.each do |assistant|
|
|
76
|
+
canonical_tool_call_ids(assistant).each do |tool_call_id|
|
|
77
|
+
if assistant_by_tool_call_id.key?(tool_call_id)
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
|
|
80
|
+
end
|
|
81
|
+
assistant_by_tool_call_id[tool_call_id] = assistant
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
assistants.each do |assistant|
|
|
86
|
+
next unless selected_ids[assistant.candidate_id]
|
|
87
|
+
|
|
88
|
+
canonical_tool_call_ids(assistant).each do |tool_call_id|
|
|
89
|
+
messages = tool_messages.fetch(tool_call_id, [])
|
|
90
|
+
if messages.empty?
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"ContextPlan selected assistant Tool Call without a Tool message: #{tool_call_id}"
|
|
93
|
+
end
|
|
94
|
+
missing = messages.reject { |message| selected_ids[message.candidate_id] }
|
|
95
|
+
unless missing.empty?
|
|
96
|
+
raise ArgumentError,
|
|
97
|
+
"ContextPlan split assistant/Tool message dependency: #{tool_call_id}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
tool_messages.each do |tool_call_id, messages|
|
|
103
|
+
messages.each do |message|
|
|
104
|
+
next unless selected_ids[message.candidate_id]
|
|
105
|
+
|
|
106
|
+
assistant = assistant_by_tool_call_id[tool_call_id]
|
|
107
|
+
unless assistant && selected_ids[assistant.candidate_id]
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"ContextPlan selected orphan Tool message: #{tool_call_id}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def canonical_tool_call_ids(candidate)
|
|
116
|
+
Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
|
|
117
|
+
.compact
|
|
118
|
+
.map(&:to_s)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_derived_contents!(request, plan)
|
|
122
|
+
known = request.candidates.to_h { |candidate| [candidate.candidate_id, true] }
|
|
123
|
+
plan.derived_contents.each do |derived|
|
|
124
|
+
unless derived.is_a?(DerivedContentSpec)
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"ContextPlan derived content must be DerivedContentSpec, got #{derived.class}"
|
|
127
|
+
end
|
|
128
|
+
unknown = derived.coverage_candidate_ids.reject { |id| known[id] }
|
|
129
|
+
raise ArgumentError, "Derived content covers unknown candidates: #{unknown.inspect}" unless unknown.empty?
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
module ContextPolicies
|
|
6
|
+
class Default < ContextPolicy
|
|
7
|
+
DESCRIPTOR = ContextPolicyDescriptor.new(
|
|
8
|
+
id: "default-recent-v1",
|
|
9
|
+
version: 1,
|
|
10
|
+
config: {}
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def initialize(config = {})
|
|
14
|
+
@config = Immutable.copy(config || {})
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def descriptor
|
|
18
|
+
return DESCRIPTOR if @config.empty?
|
|
19
|
+
ContextPolicyDescriptor.new(id: DESCRIPTOR.id, version: DESCRIPTOR.version, config: @config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(request)
|
|
23
|
+
units = request.parts.fetch(:unit_builder).build(request.candidates)
|
|
24
|
+
units = request.parts.fetch(:required_context_resolver).resolve(
|
|
25
|
+
request: request,
|
|
26
|
+
units: units
|
|
27
|
+
)
|
|
28
|
+
ordered = request.parts.fetch(:recent_first_selector).order(
|
|
29
|
+
request: request,
|
|
30
|
+
units: units
|
|
31
|
+
)
|
|
32
|
+
selected = request.parts.fetch(:token_budget_packer).pack(
|
|
33
|
+
request: request,
|
|
34
|
+
units: ordered
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
ContextPlan.new(
|
|
38
|
+
selected_unit_ids: selected.map(&:unit_id),
|
|
39
|
+
derived_contents: [],
|
|
40
|
+
selected_tool_ids: [],
|
|
41
|
+
ordering_hints: {"history" => "chronological"},
|
|
42
|
+
policy_descriptor: descriptor,
|
|
43
|
+
metadata: {
|
|
44
|
+
"candidate_count" => request.candidates.length,
|
|
45
|
+
"unit_count" => units.length,
|
|
46
|
+
"selected_unit_count" => selected.length
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextPolicy
|
|
6
|
+
def call(_request)
|
|
7
|
+
raise NotImplementedError, "#{self.class}#call is not implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def descriptor
|
|
11
|
+
raise NotImplementedError, "#{self.class}#descriptor is not implemented"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
ContextPolicyDescriptor = Data.define(:id, :version, :config) do
|
|
8
|
+
def initialize(id:, version:, config: {})
|
|
9
|
+
normalized_config = Immutable.copy(config || {})
|
|
10
|
+
Immutable.validate_canonical_json!(normalized_config, label: "Context Policy config")
|
|
11
|
+
super(
|
|
12
|
+
id: id.to_s.freeze,
|
|
13
|
+
version: Integer(version),
|
|
14
|
+
config: normalized_config
|
|
15
|
+
)
|
|
16
|
+
raise ArgumentError, "Context Policy id must not be empty" if id.empty?
|
|
17
|
+
raise ArgumentError, "Context Policy version must be positive" unless version.positive?
|
|
18
|
+
freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.from_h(hash)
|
|
22
|
+
descriptor = new(
|
|
23
|
+
id: hash.fetch("id") { hash.fetch(:id) },
|
|
24
|
+
version: hash.fetch("version") { hash.fetch(:version) },
|
|
25
|
+
config: hash["config"] || hash[:config] || {}
|
|
26
|
+
)
|
|
27
|
+
expected_digest = hash["config_digest"] || hash[:config_digest]
|
|
28
|
+
if expected_digest && expected_digest.to_s != descriptor.config_digest
|
|
29
|
+
raise Phronomy::ConfigurationError,
|
|
30
|
+
"Context Policy config digest mismatch for #{descriptor.id}@#{descriptor.version}"
|
|
31
|
+
end
|
|
32
|
+
descriptor
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def config_digest
|
|
36
|
+
"sha256:#{Digest::SHA256.hexdigest(Phronomy::CanonicalJSON.dump(config))}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
"id" => id,
|
|
42
|
+
"version" => version,
|
|
43
|
+
"config" => config,
|
|
44
|
+
"config_digest" => config_digest
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextPolicyRegistry
|
|
6
|
+
class << self
|
|
7
|
+
def default
|
|
8
|
+
@default ||= new.tap do |registry|
|
|
9
|
+
registry.register(id: "default-recent-v1", version: 1) do |config|
|
|
10
|
+
ContextPolicies::Default.new(config)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@factories = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register(id:, version:, &factory)
|
|
22
|
+
raise ArgumentError, "Context Policy factory is required" unless factory
|
|
23
|
+
|
|
24
|
+
key = [id.to_s, Integer(version)].freeze
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
raise ArgumentError, "Context Policy already registered: #{key.join("@")}" if @factories.key?(key)
|
|
27
|
+
@factories[key] = factory
|
|
28
|
+
end
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def resolve(descriptor)
|
|
33
|
+
key = [descriptor.id.to_s, Integer(descriptor.version)]
|
|
34
|
+
factory = @mutex.synchronize { @factories[key] }
|
|
35
|
+
raise Phronomy::ConfigurationError, "Unknown Context Policy: #{key.join("@")}" unless factory
|
|
36
|
+
|
|
37
|
+
policy = factory.call(descriptor.config)
|
|
38
|
+
unless policy.is_a?(ContextPolicy)
|
|
39
|
+
raise Phronomy::ConfigurationError,
|
|
40
|
+
"Context Policy factory returned #{policy.class}, expected #{ContextPolicy}"
|
|
41
|
+
end
|
|
42
|
+
policy
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
ContextRequest = Data.define(
|
|
6
|
+
:agent_id,
|
|
7
|
+
:execution_id,
|
|
8
|
+
:call_sequence,
|
|
9
|
+
:call_mode,
|
|
10
|
+
:candidates,
|
|
11
|
+
:token_budget,
|
|
12
|
+
:model_config,
|
|
13
|
+
:previous_manifest,
|
|
14
|
+
:required_coverage,
|
|
15
|
+
:parts,
|
|
16
|
+
:metadata
|
|
17
|
+
) do
|
|
18
|
+
def initialize(**values)
|
|
19
|
+
super(**values.merge(
|
|
20
|
+
agent_id: values.fetch(:agent_id).to_s.freeze,
|
|
21
|
+
execution_id: values[:execution_id]&.to_s&.freeze,
|
|
22
|
+
call_sequence: Integer(values.fetch(:call_sequence)),
|
|
23
|
+
call_mode: values.fetch(:call_mode).to_sym,
|
|
24
|
+
candidates: Array(values[:candidates]).freeze,
|
|
25
|
+
model_config: Immutable.copy(values[:model_config] || {}),
|
|
26
|
+
required_coverage: Array(values[:required_coverage]).map(&:to_s).freeze,
|
|
27
|
+
parts: (values[:parts] || {}).dup.freeze,
|
|
28
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
29
|
+
))
|
|
30
|
+
raise ArgumentError, "ContextRequest call_sequence must be positive" unless call_sequence.positive?
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|