phronomy 0.15.1 → 0.17.0

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