phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -43,7 +43,7 @@ module Phronomy
43
43
  "source_sequence" => record.sequence
44
44
  )
45
45
 
46
- ContextCandidate.new(
46
+ Selection::Candidate.new(
47
47
  candidate_id: "record:#{record.record_id}",
48
48
  source_kind: source_kind,
49
49
  category: record.kind,
@@ -55,7 +55,7 @@ module Phronomy
55
55
  llm_call_id: record.llm_call_id,
56
56
  tool_call_id: tool_call_id,
57
57
  sequence: sequence,
58
- requirement: :optional,
58
+ constraint: Selection::Constraint.selectable(origin: :context_policy),
59
59
  priority: (source_kind == :working) ? 100 : 0,
60
60
  metadata: metadata
61
61
  )
@@ -3,21 +3,26 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  ContextPlan = Data.define(
6
- :selected_unit_ids,
7
- :derived_contents,
8
- :selected_tool_ids,
9
- :ordering_hints,
10
- :policy_descriptor,
6
+ :instruction,
7
+ :knowledge,
8
+ :tools,
9
+ :conversation,
11
10
  :metadata
12
11
  ) 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
- ))
12
+ def initialize(
13
+ instruction: [],
14
+ knowledge: [],
15
+ tools: [],
16
+ conversation: [],
17
+ metadata: {}
18
+ )
19
+ super(
20
+ instruction: Array(instruction).freeze,
21
+ knowledge: Array(knowledge).freeze,
22
+ tools: Array(tools).freeze,
23
+ conversation: Array(conversation).map { |group| Array(group).freeze }.freeze,
24
+ metadata: Immutable.copy(metadata || {})
25
+ )
21
26
  freeze
22
27
  end
23
28
  end
@@ -3,131 +3,289 @@
3
3
  module Phronomy
4
4
  module Agent
5
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
6
+ RESERVED_KIND_CATEGORIES = {
7
+ instruction: :instruction,
8
+ handoff_responsibility: :instruction,
9
+ knowledge: :knowledge,
10
+ memory: :knowledge,
11
+ summary: :knowledge,
12
+ structured_state: :knowledge,
13
+ external_message: :conversation,
14
+ assistant_message: :conversation,
15
+ tool_message: :conversation,
16
+ current_input: :conversation,
17
+ current_request: :conversation,
18
+ conversation: :conversation,
19
+ tool_result: :conversation
20
+ }.freeze
18
21
 
19
- def validate!(request:, plan:)
22
+ def validate!(input:, plan:)
23
+ unless input.is_a?(ContextPolicyInput)
24
+ raise ArgumentError, "ContextPlanValidator expected ContextPolicyInput"
25
+ end
20
26
  unless plan.is_a?(ContextPlan)
21
27
  raise ArgumentError, "Context Policy returned #{plan.class}, expected #{ContextPlan}"
22
28
  end
23
29
 
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
30
+ validate_flat_category!(
31
+ input: input.instruction,
32
+ output: plan.instruction,
33
+ item_class: ContextPolicyInput::InstructionItem,
34
+ label: :instruction,
35
+ generated_allowed: true
28
36
  )
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
37
+ validate_flat_category!(
38
+ input: input.knowledge,
39
+ output: plan.knowledge,
40
+ item_class: ContextPolicyInput::KnowledgeItem,
41
+ label: :knowledge,
42
+ generated_allowed: true
43
+ )
44
+ validate_flat_category!(
45
+ input: input.tools,
46
+ output: plan.tools,
47
+ item_class: ContextPolicyInput::ToolItem,
48
+ label: :tools,
49
+ generated_allowed: false
53
50
  )
51
+ validate_conversation!(input.conversation, plan.conversation)
52
+ validate_unique_ids!(plan)
53
+ plan
54
54
  end
55
55
 
56
56
  private
57
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
58
+ def validate_flat_category!(input:, output:, item_class:, label:, generated_allowed:)
59
+ input_by_id = input.to_h { |item| [item.id, item] }
60
+ output.each do |item|
61
+ unless item.is_a?(item_class)
62
+ raise ArgumentError,
63
+ "ContextPlan #{label} contains #{item.class}; expected #{item_class}"
64
+ end
62
65
 
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 = {}
66
+ validate_semantic_kind!(item, label)
68
67
 
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}"
68
+ source = input_by_id[item.id]
69
+ if source
70
+ unless source == item
71
+ raise ArgumentError, "ContextPlan modified input item #{item.id.inspect} in #{label}"
72
+ end
73
+ elsif !generated_allowed || item.provenance.origin != :policy_generated
74
+ raise ArgumentError, "ContextPlan contains unknown #{label} item: #{item.id.inspect}"
75
+ else
76
+ validate_policy_generated_metadata!(item)
72
77
  end
73
78
  end
74
79
 
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
+ missing = input.select(&:required?).reject do |required|
81
+ output.any? { |item| item.id == required.id }
82
+ end
83
+ return if missing.empty?
84
+
85
+ raise Phronomy::ContextBudgetExceededError,
86
+ "ContextPlan omitted required #{label} item(s): #{missing.map(&:id).inspect}"
87
+ end
88
+
89
+ def validate_conversation!(input_groups, output_groups)
90
+ input_by_group_ids = input_groups.to_h do |group|
91
+ [group.map(&:id), group]
92
+ end
93
+ selected_input_ids = {}
94
+
95
+ output_groups.each do |group|
96
+ unless group.is_a?(Array) && !group.empty?
97
+ raise ArgumentError, "ContextPlan conversation groups must be non-empty Arrays"
98
+ end
99
+ unless group.all? { |item| item.is_a?(ContextPolicyInput::ConversationItem) }
100
+ raise ArgumentError, "ContextPlan conversation group contains a non-ConversationItem"
101
+ end
102
+
103
+ group.each { |item| validate_semantic_kind!(item, :conversation) }
104
+
105
+ ids = group.map(&:id)
106
+ matching = input_by_group_ids[ids]
107
+ if matching
108
+ unless matching == group
109
+ raise ArgumentError, "ContextPlan modified an input conversation group: #{ids.inspect}"
80
110
  end
81
- assistant_by_tool_call_id[tool_call_id] = assistant
111
+ ids.each { |id| selected_input_ids[id] = true }
112
+ next
113
+ end
114
+
115
+ overlaps = ids.any? do |id|
116
+ input_groups.any? { |input_group| input_group.any? { |item| item.id == id } }
82
117
  end
118
+ if overlaps
119
+ raise ArgumentError,
120
+ "ContextPlan split, merged, or reordered an input conversation group: #{ids.inspect}"
121
+ end
122
+ unless group.all? { |item| item.provenance.origin == :policy_generated }
123
+ raise ArgumentError, "ContextPlan contains unknown conversation item(s): #{ids.inspect}"
124
+ end
125
+ validate_generated_conversation_group!(group)
83
126
  end
84
127
 
85
- assistants.each do |assistant|
86
- next unless selected_ids[assistant.candidate_id]
128
+ missing_groups = input_groups.select do |group|
129
+ group.any?(&:required?) && group.none? { |item| selected_input_ids[item.id] }
130
+ end
131
+ return if missing_groups.empty?
132
+
133
+ raise Phronomy::ContextBudgetExceededError,
134
+ "ContextPlan omitted required conversation group(s): " \
135
+ "#{missing_groups.map { |group| group.map(&:id) }.inspect}"
136
+ end
137
+
138
+ def validate_semantic_kind!(item, semantic_category)
139
+ return unless item.respond_to?(:kind)
140
+
141
+ reserved_category = RESERVED_KIND_CATEGORIES[item.kind.to_sym]
142
+ return unless reserved_category && reserved_category != semantic_category
143
+
144
+ raise ArgumentError,
145
+ "ContextPlan #{semantic_category} item #{item.id.inspect} uses reserved " \
146
+ "#{item.kind.inspect} kind for #{reserved_category}"
147
+ end
148
+
149
+ def validate_policy_generated_metadata!(item)
150
+ conflicts = item.metadata.keys.map(&:to_s) & ContextPolicyInput::FRAMEWORK_METADATA_KEYS
151
+ return if conflicts.empty?
152
+
153
+ raise ArgumentError,
154
+ "Policy-generated Context item #{item.id.inspect} metadata uses " \
155
+ "Framework-reserved key(s): #{conflicts.sort.inspect}"
156
+ end
87
157
 
88
- canonical_tool_call_ids(assistant).each do |tool_call_id|
89
- messages = tool_messages.fetch(tool_call_id, [])
90
- if messages.empty?
158
+ def validate_generated_conversation_group!(group)
159
+ group.each do |item|
160
+ validate_policy_generated_metadata!(item)
161
+ validate_generated_conversation_item!(item)
162
+ end
163
+
164
+ assistants = group.select { |item| item.kind == :assistant_message }
165
+ tools = group.select { |item| item.kind == :tool_message }
166
+ protocol_items = assistants + tools
167
+ if protocol_items.any? && protocol_items.length != group.length
168
+ raise ArgumentError,
169
+ "Policy-generated Tool exchange group may contain only assistant_message and tool_message items"
170
+ end
171
+
172
+ tool_by_id = tools.group_by(&:tool_call_id)
173
+ positions = group.each_with_index.to_h
174
+
175
+ tool_by_id.each do |tool_call_id, messages|
176
+ if tool_call_id.nil? || tool_call_id.empty? || messages.length != 1
177
+ raise ArgumentError,
178
+ "Policy-generated conversation group has invalid Tool message dependency"
179
+ end
180
+ end
181
+
182
+ assistant_by_call = {}
183
+ assistants.each do |assistant|
184
+ assistant.tool_call_ids.each do |tool_call_id|
185
+ if assistant_by_call.key?(tool_call_id)
91
186
  raise ArgumentError,
92
- "ContextPlan selected assistant Tool Call without a Tool message: #{tool_call_id}"
187
+ "Policy-generated conversation group has duplicate assistant Tool Call: #{tool_call_id}"
93
188
  end
94
- missing = messages.reject { |message| selected_ids[message.candidate_id] }
95
- unless missing.empty?
189
+ assistant_by_call[tool_call_id] = assistant
190
+ tool = tool_by_id[tool_call_id]&.first
191
+ unless tool
96
192
  raise ArgumentError,
97
- "ContextPlan split assistant/Tool message dependency: #{tool_call_id}"
193
+ "Policy-generated assistant Tool Call has no Tool message: #{tool_call_id}"
194
+ end
195
+ unless positions.fetch(tool) > positions.fetch(assistant)
196
+ raise ArgumentError,
197
+ "Policy-generated Tool message must follow its assistant Tool Call: #{tool_call_id}"
98
198
  end
99
199
  end
100
200
  end
101
201
 
102
- tool_messages.each do |tool_call_id, messages|
103
- messages.each do |message|
104
- next unless selected_ids[message.candidate_id]
202
+ tools.each do |tool|
203
+ unless assistant_by_call.key?(tool.tool_call_id)
204
+ raise ArgumentError,
205
+ "Policy-generated conversation group contains orphan Tool message: #{tool.tool_call_id}"
206
+ end
207
+ end
208
+ end
105
209
 
106
- assistant = assistant_by_tool_call_id[tool_call_id]
107
- unless assistant && selected_ids[assistant.candidate_id]
210
+ def validate_generated_conversation_item!(item)
211
+ case item.kind
212
+ when :assistant_message
213
+ payload = validate_generated_canonical_message!(item, expected_role: :assistant)
214
+ raw_calls = payload["tool_calls"] || payload[:tool_calls]
215
+ unless raw_calls.nil? || raw_calls.is_a?(Array)
216
+ raise ArgumentError,
217
+ "Policy-generated assistant_message tool_calls must be an Array"
218
+ end
219
+ payload_call_ids = Array(raw_calls).map do |call|
220
+ unless call.is_a?(Hash)
108
221
  raise ArgumentError,
109
- "ContextPlan selected orphan Tool message: #{tool_call_id}"
222
+ "Policy-generated assistant_message Tool Call must be a Hash"
110
223
  end
224
+ id = call["id"] || call[:id]
225
+ name = call["name"] || call[:name]
226
+ arguments = if call.key?("arguments")
227
+ call["arguments"]
228
+ else
229
+ call.fetch(:arguments, {})
230
+ end
231
+ if id.to_s.empty? || name.to_s.empty? || !arguments.is_a?(Hash)
232
+ raise ArgumentError,
233
+ "Policy-generated assistant_message has malformed Tool Call"
234
+ end
235
+ id.to_s
236
+ end
237
+ unless payload_call_ids == item.tool_call_ids
238
+ raise ArgumentError,
239
+ "Policy-generated assistant_message Tool Call IDs do not match ConversationItem"
240
+ end
241
+ if item.tool_call_id
242
+ raise ArgumentError,
243
+ "Policy-generated assistant_message must not set tool_call_id"
244
+ end
245
+ when :tool_message
246
+ payload = validate_generated_canonical_message!(item, expected_role: :tool)
247
+ payload_tool_call_id = payload["tool_call_id"] || payload[:tool_call_id]
248
+ if item.tool_call_id.to_s.empty? || payload_tool_call_id.to_s != item.tool_call_id
249
+ raise ArgumentError,
250
+ "Policy-generated tool_message tool_call_id does not match canonical content"
251
+ end
252
+ unless item.tool_call_ids.empty?
253
+ raise ArgumentError,
254
+ "Policy-generated tool_message must not set tool_call_ids"
255
+ end
256
+ when :tool_result
257
+ raise ArgumentError,
258
+ "Policy-generated conversation must not contain raw tool_result items"
259
+ else
260
+ if item.tool_call_id || !item.tool_call_ids.empty?
261
+ raise ArgumentError,
262
+ "Policy-generated non-Tool conversation item must not declare Tool Call IDs"
111
263
  end
112
264
  end
113
265
  end
114
266
 
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
267
+ def validate_generated_canonical_message!(item, expected_role:)
268
+ unless item.content_format == :json && item.content.is_a?(Hash)
269
+ raise ArgumentError,
270
+ "Policy-generated #{item.kind} must use canonical JSON message content"
271
+ end
120
272
 
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?
273
+ payload_role = item.content["role"] || item.content[:role]
274
+ unless item.role == expected_role && payload_role.to_s == expected_role.to_s
275
+ raise ArgumentError,
276
+ "Policy-generated #{item.kind} role does not match canonical content"
130
277
  end
278
+
279
+ item.content
280
+ end
281
+
282
+ def validate_unique_ids!(plan)
283
+ ids = plan.instruction.map(&:id) + plan.knowledge.map(&:id) + plan.tools.map(&:id) +
284
+ plan.conversation.flatten.map(&:id)
285
+ duplicates = ids.group_by(&:itself).select { |_id, values| values.length > 1 }.keys
286
+ return if duplicates.empty?
287
+
288
+ raise ArgumentError, "ContextPlan contains duplicate item IDs: #{duplicates.inspect}"
131
289
  end
132
290
  end
133
291
  end
@@ -4,49 +4,138 @@ module Phronomy
4
4
  module Agent
5
5
  module ContextPolicies
6
6
  class Default < ContextPolicy
7
- DESCRIPTOR = ContextPolicyDescriptor.new(
8
- id: "default-recent-v1",
9
- version: 1,
10
- config: {}
11
- )
7
+ CONVERSATION_SHARE = 0.60
12
8
 
13
- def initialize(config = {})
14
- @config = Immutable.copy(config || {})
9
+ def self.instance
10
+ @instance ||= new.freeze
15
11
  end
16
12
 
17
- def descriptor
18
- return DESCRIPTOR if @config.empty?
19
- ContextPolicyDescriptor.new(id: DESCRIPTOR.id, version: DESCRIPTOR.version, config: @config)
20
- end
13
+ def call(input)
14
+ unless input.is_a?(ContextPolicyInput)
15
+ raise ArgumentError, "Default ContextPolicy expected ContextPolicyInput"
16
+ end
21
17
 
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
- )
18
+ return all_context(input) unless input.token_budget
19
+
20
+ limit = input.token_budget.effective_input_limit
21
+ required_knowledge = input.knowledge.select(&:required?)
22
+ optional_knowledge = input.knowledge.reject(&:required?)
23
+ required_conversation = input.conversation.select { |group| group.any?(&:required?) }
24
+ optional_conversation = input.conversation.reject { |group| group.any?(&:required?) }
25
+
26
+ fixed_cost = item_cost(input.instruction) + item_cost(input.tools) +
27
+ item_cost(required_knowledge) + group_cost(required_conversation)
28
+ if fixed_cost > limit
29
+ raise Phronomy::ContextBudgetExceededError,
30
+ "Required Context (estimated #{fixed_cost} tokens) exceeds " \
31
+ "available input budget (#{limit} tokens)"
32
+ end
33
+
34
+ remaining = limit - fixed_cost
35
+ conversation_budget = (remaining * CONVERSATION_SHARE).floor
36
+ knowledge_budget = remaining - conversation_budget
36
37
 
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,
38
+ selected_conversation, older_conversation, conversation_used =
39
+ select_recent_suffix(optional_conversation, conversation_budget)
40
+ selected_knowledge, remaining_knowledge, knowledge_used =
41
+ select_stable_fit(optional_knowledge, knowledge_budget)
42
+
43
+ reusable = remaining - conversation_used - knowledge_used
44
+ if reusable.positive? && !older_conversation.empty?
45
+ more_conversation, _, used =
46
+ select_recent_suffix(older_conversation, reusable)
47
+ selected_conversation = more_conversation + selected_conversation
48
+ reusable -= used
49
+ end
50
+ if reusable.positive? && !remaining_knowledge.empty?
51
+ more_knowledge, _remaining_knowledge, used =
52
+ select_stable_fit(remaining_knowledge, reusable)
53
+ selected_knowledge += more_knowledge
54
+ reusable -= used
55
+ end
56
+
57
+ conversation = (required_conversation + selected_conversation)
58
+ .uniq
59
+ .sort_by { |group| group_sequence(group) }
60
+ knowledge_ids = (required_knowledge + selected_knowledge).to_h { |item| [item.id, true] }
61
+ knowledge = input.knowledge.select { |item| knowledge_ids[item.id] }
62
+
63
+ plan(
64
+ instruction: input.instruction,
65
+ knowledge: knowledge,
66
+ tools: input.tools,
67
+ conversation: conversation,
43
68
  metadata: {
44
- "candidate_count" => request.candidates.length,
45
- "unit_count" => units.length,
46
- "selected_unit_count" => selected.length
69
+ "default_policy" => true,
70
+ "estimated_fixed_tokens" => fixed_cost,
71
+ "estimated_unused_tokens" => reusable
47
72
  }
48
73
  )
49
74
  end
75
+
76
+ private
77
+
78
+ def all_context(input)
79
+ plan(
80
+ instruction: input.instruction,
81
+ knowledge: input.knowledge,
82
+ tools: input.tools,
83
+ conversation: input.conversation,
84
+ metadata: {"default_policy" => true}
85
+ )
86
+ end
87
+
88
+ def item_cost(items)
89
+ Array(items).sum { |item| Integer(item.estimated_tokens || 0) }
90
+ end
91
+
92
+ def group_cost(groups)
93
+ Array(groups).sum { |group| item_cost(group) }
94
+ end
95
+
96
+ def group_sequence(group)
97
+ group.filter_map(&:sequence).min || 0
98
+ end
99
+
100
+ def select_recent_suffix(groups, budget)
101
+ ordered = Array(groups).sort_by { |group| group_sequence(group) }
102
+ selected = []
103
+ remaining = Integer(budget)
104
+ stop_index = -1
105
+
106
+ (ordered.length - 1).downto(0) do |index|
107
+ cost = item_cost(ordered[index])
108
+ if cost > remaining
109
+ stop_index = index
110
+ break
111
+ end
112
+ selected.unshift(ordered[index])
113
+ remaining -= cost
114
+ end
115
+
116
+ older = if stop_index >= 0
117
+ ordered[0..stop_index]
118
+ else
119
+ []
120
+ end
121
+ [selected.freeze, older.freeze, Integer(budget) - remaining]
122
+ end
123
+
124
+ def select_stable_fit(items, budget)
125
+ remaining = Integer(budget)
126
+ selected = []
127
+ unselected = []
128
+ Array(items).each do |item|
129
+ cost = Integer(item.estimated_tokens || 0)
130
+ if cost <= remaining
131
+ selected << item
132
+ remaining -= cost
133
+ else
134
+ unselected << item
135
+ end
136
+ end
137
+ [selected.freeze, unselected.freeze, Integer(budget) - remaining]
138
+ end
50
139
  end
51
140
  end
52
141
  end