phronomy 0.22.0 → 0.24.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -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 +147 -0
  15. data/docs/architecture/persistence.md +186 -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 +89 -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 +114 -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/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -1,14 +1,120 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  class ContextPolicy
6
- def call(_request)
8
+ def call(_input)
7
9
  raise NotImplementedError, "#{self.class}#call is not implemented"
8
10
  end
9
11
 
10
- def descriptor
11
- raise NotImplementedError, "#{self.class}#descriptor is not implemented"
12
+ protected
13
+
14
+ def instruction_item(
15
+ content:,
16
+ role: :system,
17
+ kind: :instruction,
18
+ content_format: nil,
19
+ required: false,
20
+ metadata: {}
21
+ )
22
+ ContextPolicyInput::InstructionItem.new(
23
+ id: generated_item_id(:instruction),
24
+ kind: kind,
25
+ role: role,
26
+ content: content,
27
+ content_format: content_format || inferred_content_format(content),
28
+ estimated_tokens: estimate_content(content),
29
+ required: required,
30
+ provenance: generated_provenance,
31
+ metadata: metadata
32
+ )
33
+ end
34
+
35
+ def knowledge_item(
36
+ content:,
37
+ role: :user,
38
+ kind: :knowledge,
39
+ content_format: nil,
40
+ required: false,
41
+ metadata: {}
42
+ )
43
+ ContextPolicyInput::KnowledgeItem.new(
44
+ id: generated_item_id(:knowledge),
45
+ kind: kind,
46
+ role: role,
47
+ content: content,
48
+ content_format: content_format || inferred_content_format(content),
49
+ estimated_tokens: estimate_content(content),
50
+ required: required,
51
+ provenance: generated_provenance,
52
+ metadata: metadata
53
+ )
54
+ end
55
+
56
+ def conversation_item(
57
+ content:,
58
+ role:,
59
+ kind: :conversation,
60
+ content_format: nil,
61
+ sequence: nil,
62
+ required: false,
63
+ tool_call_id: nil,
64
+ tool_call_ids: [],
65
+ delivery: :chat_message,
66
+ metadata: {}
67
+ )
68
+ ContextPolicyInput::ConversationItem.new(
69
+ id: generated_item_id(:conversation),
70
+ kind: kind,
71
+ role: role,
72
+ content: content,
73
+ content_format: content_format || inferred_content_format(content),
74
+ sequence: sequence,
75
+ estimated_tokens: estimate_content(content),
76
+ required: required,
77
+ provenance: generated_provenance,
78
+ tool_call_id: tool_call_id,
79
+ tool_call_ids: tool_call_ids,
80
+ delivery: delivery,
81
+ metadata: metadata
82
+ )
83
+ end
84
+
85
+ def plan(
86
+ instruction: [],
87
+ knowledge: [],
88
+ tools: [],
89
+ conversation: [],
90
+ metadata: {}
91
+ )
92
+ ContextPlan.new(
93
+ instruction: instruction,
94
+ knowledge: knowledge,
95
+ tools: tools,
96
+ conversation: conversation,
97
+ metadata: metadata
98
+ )
99
+ end
100
+
101
+ private
102
+
103
+ def generated_item_id(category)
104
+ "policy:#{category}:#{SecureRandom.uuid}".freeze
105
+ end
106
+
107
+ def generated_provenance
108
+ ContextPolicyInput::Provenance.new(origin: :policy_generated)
109
+ end
110
+
111
+ def inferred_content_format(content)
112
+ content.is_a?(String) ? :text : :json
113
+ end
114
+
115
+ def estimate_content(content)
116
+ bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
117
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
12
118
  end
13
119
  end
14
120
  end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ ContextPolicyInput = Data.define(
6
+ :agent_id,
7
+ :execution_id,
8
+ :call_sequence,
9
+ :call_mode,
10
+ :instruction,
11
+ :knowledge,
12
+ :tools,
13
+ :conversation,
14
+ :token_budget,
15
+ :model_config,
16
+ :previous_manifest,
17
+ :metadata
18
+ ) do
19
+ def initialize(**values)
20
+ normalized_instruction = Array(values[:instruction]).freeze
21
+ normalized_knowledge = Array(values[:knowledge]).freeze
22
+ normalized_tools = Array(values[:tools]).freeze
23
+ normalized_conversation = Array(values[:conversation]).map do |group|
24
+ normalized_group = Array(group).freeze
25
+ raise ArgumentError, "ContextPolicyInput conversation groups must not be empty" if normalized_group.empty?
26
+ normalized_group
27
+ end.freeze
28
+
29
+ validate_items!(normalized_instruction, ContextPolicyInput::InstructionItem, :instruction)
30
+ validate_items!(normalized_knowledge, ContextPolicyInput::KnowledgeItem, :knowledge)
31
+ validate_items!(normalized_tools, ContextPolicyInput::ToolItem, :tools)
32
+ normalized_conversation.each do |group|
33
+ validate_items!(group, ContextPolicyInput::ConversationItem, :conversation)
34
+ end
35
+
36
+ call_sequence = Integer(values.fetch(:call_sequence))
37
+ raise ArgumentError, "ContextPolicyInput call_sequence must be positive" unless call_sequence.positive?
38
+
39
+ call_mode = values.fetch(:call_mode).to_sym
40
+ unless LLMInputManifest::CALL_MODES.include?(call_mode)
41
+ raise ArgumentError, "unknown ContextPolicyInput call_mode: #{call_mode.inspect}"
42
+ end
43
+
44
+ super(**values.merge(
45
+ agent_id: values.fetch(:agent_id).to_s.freeze,
46
+ execution_id: values[:execution_id]&.to_s&.freeze,
47
+ call_sequence: call_sequence,
48
+ call_mode: call_mode,
49
+ instruction: normalized_instruction,
50
+ knowledge: normalized_knowledge,
51
+ tools: normalized_tools,
52
+ conversation: normalized_conversation,
53
+ model_config: Immutable.copy(values[:model_config] || {}),
54
+ metadata: Immutable.copy(values[:metadata] || {})
55
+ ))
56
+ freeze
57
+ end
58
+
59
+ private
60
+
61
+ def validate_items!(items, expected_class, label)
62
+ invalid = items.reject { |item| item.is_a?(expected_class) }
63
+ return if invalid.empty?
64
+
65
+ raise ArgumentError,
66
+ "ContextPolicyInput #{label} contains #{invalid.first.class}; expected #{expected_class}"
67
+ end
68
+ end
69
+
70
+ class ContextPolicyInput
71
+ CONTENT_FORMATS = %i[text json].freeze
72
+ DELIVERIES = %i[ask_argument chat_message].freeze
73
+ FRAMEWORK_METADATA_KEYS = %w[
74
+ phronomy_origin
75
+ context_policy_origin
76
+ context_policy_item_id
77
+ context_policy_semantic_category
78
+ context_policy_content_format
79
+ context_policy_conversation_group_id
80
+ handoff_policy_category
81
+ handoff_provenance
82
+ selection_candidate_id
83
+ selection_unit_id
84
+ selection_unit_kind
85
+ journal_record_id
86
+ source_agent_id
87
+ source_execution_id
88
+ llm_call_id
89
+ ].freeze
90
+
91
+ Provenance = Data.define(
92
+ :origin,
93
+ :content_ref,
94
+ :record_id,
95
+ :agent_id,
96
+ :execution_id,
97
+ :llm_call_id
98
+ ) do
99
+ def initialize(
100
+ origin:,
101
+ content_ref: nil,
102
+ record_id: nil,
103
+ agent_id: nil,
104
+ execution_id: nil,
105
+ llm_call_id: nil
106
+ )
107
+ super(
108
+ origin: origin.to_sym,
109
+ content_ref: content_ref&.to_s&.freeze,
110
+ record_id: record_id&.to_s&.freeze,
111
+ agent_id: agent_id&.to_s&.freeze,
112
+ execution_id: execution_id&.to_s&.freeze,
113
+ llm_call_id: llm_call_id&.to_s&.freeze
114
+ )
115
+ freeze
116
+ end
117
+ end
118
+
119
+ InstructionItem = Data.define(
120
+ :id, :kind, :role, :content, :content_format,
121
+ :estimated_tokens, :required, :provenance, :metadata
122
+ ) do
123
+ def initialize(**values)
124
+ super(**ContextPolicyInput.send(:normalize_content_item_values, values))
125
+ freeze
126
+ end
127
+
128
+ def required? = required
129
+ end
130
+
131
+ KnowledgeItem = Data.define(
132
+ :id, :kind, :role, :content, :content_format,
133
+ :estimated_tokens, :required, :provenance, :metadata
134
+ ) do
135
+ def initialize(**values)
136
+ super(**ContextPolicyInput.send(:normalize_content_item_values, values))
137
+ freeze
138
+ end
139
+
140
+ def required? = required
141
+ end
142
+
143
+ ToolItem = Data.define(
144
+ :id, :definition, :estimated_tokens, :required, :provenance, :metadata
145
+ ) do
146
+ def initialize(**values)
147
+ provenance = ContextPolicyInput.send(:normalize_provenance, values[:provenance])
148
+ definition = Immutable.copy(values.fetch(:definition))
149
+ unless definition.is_a?(Hash) && definition["name"].to_s != ""
150
+ raise ArgumentError, "ContextPolicyInput ToolItem definition requires a non-empty name"
151
+ end
152
+
153
+ super(
154
+ id: values.fetch(:id).to_s.freeze,
155
+ definition: definition,
156
+ estimated_tokens: ContextPolicyInput.send(:normalize_estimated_tokens, values[:estimated_tokens]),
157
+ required: !!values[:required],
158
+ provenance: provenance,
159
+ metadata: Immutable.copy(values[:metadata] || {})
160
+ )
161
+ raise ArgumentError, "ContextPolicyInput ToolItem id must not be empty" if id.empty?
162
+ freeze
163
+ end
164
+
165
+ def required? = required
166
+ end
167
+
168
+ ConversationItem = Data.define(
169
+ :id, :kind, :role, :content, :content_format,
170
+ :sequence, :estimated_tokens, :required, :provenance,
171
+ :tool_call_id, :tool_call_ids, :delivery, :metadata
172
+ ) do
173
+ def initialize(**values)
174
+ normalized = ContextPolicyInput.send(:normalize_content_item_values, values)
175
+ delivery = (values[:delivery] || :chat_message).to_sym
176
+ unless ContextPolicyInput::DELIVERIES.include?(delivery)
177
+ raise ArgumentError, "unknown ContextPolicyInput delivery: #{delivery.inspect}"
178
+ end
179
+
180
+ super(**normalized.merge(
181
+ sequence: values[:sequence] && Integer(values[:sequence]),
182
+ tool_call_id: values[:tool_call_id]&.to_s&.freeze,
183
+ tool_call_ids: Array(values[:tool_call_ids]).compact.map(&:to_s).freeze,
184
+ delivery: delivery
185
+ ))
186
+ freeze
187
+ end
188
+
189
+ def required? = required
190
+
191
+ def with_required(required)
192
+ return self if required? == !!required
193
+ self.class.new(**to_h.merge(required: !!required))
194
+ end
195
+ end
196
+
197
+ class << self
198
+ private
199
+
200
+ def normalize_content_item_values(values)
201
+ id = values.fetch(:id).to_s.freeze
202
+ raise ArgumentError, "ContextPolicyInput item id must not be empty" if id.empty?
203
+
204
+ kind = values.fetch(:kind).to_sym
205
+ role = values[:role]&.to_sym
206
+ content_format = (values[:content_format] || infer_content_format(values[:content])).to_sym
207
+ unless ContextPolicyInput::CONTENT_FORMATS.include?(content_format)
208
+ raise ArgumentError, "unknown ContextPolicyInput content format: #{content_format.inspect}"
209
+ end
210
+
211
+ {
212
+ id: id,
213
+ kind: kind,
214
+ role: role,
215
+ content: Immutable.copy(values[:content]),
216
+ content_format: content_format,
217
+ estimated_tokens: normalize_estimated_tokens(values[:estimated_tokens]),
218
+ required: !!values[:required],
219
+ provenance: normalize_provenance(values[:provenance]),
220
+ metadata: Immutable.copy(values[:metadata] || {})
221
+ }
222
+ end
223
+
224
+ def infer_content_format(content)
225
+ content.is_a?(String) ? :text : :json
226
+ end
227
+
228
+ def normalize_estimated_tokens(value)
229
+ result = Integer(value || 0)
230
+ raise ArgumentError, "estimated_tokens must be non-negative" if result.negative?
231
+ result
232
+ end
233
+
234
+ def normalize_provenance(value)
235
+ return value if value.is_a?(ContextPolicyInput::Provenance)
236
+ return ContextPolicyInput::Provenance.new(origin: :unknown) if value.nil?
237
+ return ContextPolicyInput::Provenance.new(**value) if value.is_a?(Hash)
238
+
239
+ raise ArgumentError, "ContextPolicyInput item provenance must be Provenance or Hash"
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ContextPolicyInputBuilder
6
+ KNOWLEDGE_KINDS = %i[knowledge memory summary structured_state].freeze
7
+ CONVERSATION_KINDS = %i[external_message assistant_message tool_message current_input current_request].freeze
8
+
9
+ def initialize(content_loader:)
10
+ @content_loader = content_loader
11
+ end
12
+
13
+ def build(
14
+ agent_id:,
15
+ execution_id:,
16
+ call_sequence:,
17
+ call_mode:,
18
+ candidates:,
19
+ instruction:,
20
+ tools:,
21
+ token_budget:, model_config:, previous_manifest:, current_input: nil,
22
+ metadata: {}
23
+ )
24
+ instruction_items = Array(instruction).dup
25
+ knowledge = []
26
+ conversation = []
27
+
28
+ Array(candidates).each do |candidate|
29
+ case semantic_category(candidate)
30
+ when :instruction
31
+ instruction_items << instruction_item(candidate)
32
+ when :knowledge
33
+ knowledge << knowledge_item(candidate)
34
+ when :conversation
35
+ conversation << conversation_item(candidate)
36
+ end
37
+ end
38
+ conversation << current_input if current_input
39
+
40
+ groups = conversation_groups(
41
+ conversation,
42
+ execution_id: execution_id,
43
+ call_mode: call_mode
44
+ )
45
+
46
+ ContextPolicyInput.new(
47
+ agent_id: agent_id,
48
+ execution_id: execution_id,
49
+ call_sequence: call_sequence,
50
+ call_mode: call_mode,
51
+ instruction: instruction_items,
52
+ knowledge: knowledge,
53
+ tools: tools,
54
+ conversation: groups,
55
+ token_budget: token_budget,
56
+ model_config: model_config,
57
+ previous_manifest: previous_manifest,
58
+ metadata: metadata
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ def semantic_category(candidate)
65
+ explicit = candidate.metadata["context_policy_semantic_category"] ||
66
+ candidate.metadata[:context_policy_semantic_category]
67
+ if explicit
68
+ normalized = explicit.to_sym
69
+ return normalized if %i[instruction knowledge conversation].include?(normalized)
70
+
71
+ raise ArgumentError,
72
+ "unsupported Context Policy semantic category metadata: #{explicit.inspect}"
73
+ end
74
+
75
+ kind = candidate.category
76
+ normalized = kind.to_sym
77
+ return :instruction if normalized == :instruction || normalized == :handoff_responsibility
78
+ return :knowledge if KNOWLEDGE_KINDS.include?(normalized)
79
+ return :conversation if CONVERSATION_KINDS.include?(normalized)
80
+
81
+ raise ArgumentError, "unsupported Context Policy candidate category: #{kind.inspect}"
82
+ end
83
+
84
+ def instruction_item(candidate)
85
+ ContextPolicyInput::InstructionItem.new(
86
+ **common_content_values(candidate),
87
+ role: candidate.role || :system
88
+ )
89
+ end
90
+
91
+ def knowledge_item(candidate)
92
+ ContextPolicyInput::KnowledgeItem.new(
93
+ **common_content_values(candidate),
94
+ role: candidate.role || :user
95
+ )
96
+ end
97
+
98
+ def conversation_item(candidate)
99
+ values = common_content_values(candidate)
100
+ ContextPolicyInput::ConversationItem.new(
101
+ **values,
102
+ role: candidate.role,
103
+ sequence: candidate.sequence,
104
+ tool_call_id: candidate.tool_call_id,
105
+ tool_call_ids: canonical_tool_call_ids(candidate),
106
+ delivery: :chat_message
107
+ )
108
+ end
109
+
110
+ def common_content_values(candidate)
111
+ format = content_format(candidate)
112
+ {
113
+ id: candidate.candidate_id,
114
+ kind: candidate.category,
115
+ content: load_content(candidate.content_ref, format),
116
+ content_format: format,
117
+ estimated_tokens: Integer(candidate.metadata["estimated_tokens"] || 0),
118
+ required: candidate.constraint.required?,
119
+ provenance: ContextPolicyInput::Provenance.new(
120
+ origin: candidate.source_kind,
121
+ content_ref: candidate.content_ref,
122
+ record_id: candidate.record_id,
123
+ agent_id: candidate.agent_id,
124
+ execution_id: candidate.execution_id,
125
+ llm_call_id: candidate.llm_call_id
126
+ ),
127
+ metadata: candidate.metadata
128
+ }
129
+ end
130
+
131
+ def content_format(candidate)
132
+ explicit = candidate.metadata["content_format"] || candidate.metadata[:content_format]
133
+ return explicit.to_sym if explicit
134
+ return :json if %i[assistant_message tool_message].include?(candidate.category.to_sym)
135
+
136
+ :text
137
+ end
138
+
139
+ def load_content(content_ref, format)
140
+ bytes = @content_loader.call(content_ref)
141
+ return bytes if format == :text
142
+
143
+ Phronomy::CanonicalJSON.load(bytes)
144
+ end
145
+
146
+ def conversation_groups(items, execution_id:, call_mode:)
147
+ ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
148
+ assistants = ordered.select { |item| item.kind == :assistant_message }
149
+ tool_messages = ordered.select { |item| item.kind == :tool_message }
150
+ tool_messages_by_id = tool_messages.group_by(&:tool_call_id)
151
+ tool_messages_by_id.each do |tool_call_id, messages|
152
+ if tool_call_id.nil? || tool_call_id.empty?
153
+ raise ArgumentError, "Tool message Context item requires tool_call_id"
154
+ end
155
+ if messages.length > 1
156
+ raise ArgumentError, "duplicate Tool message in Context Policy input: #{tool_call_id}"
157
+ end
158
+ end
159
+
160
+ assistant_by_tool_call = {}
161
+ assistants.each do |assistant|
162
+ assistant.tool_call_ids.each do |tool_call_id|
163
+ if assistant_by_tool_call.key?(tool_call_id)
164
+ raise ArgumentError,
165
+ "duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
166
+ end
167
+ assistant_by_tool_call[tool_call_id] = assistant
168
+ end
169
+ end
170
+
171
+ claimed = {}
172
+ groups = []
173
+ ordered.each do |item|
174
+ next if claimed[item.id]
175
+
176
+ if item.kind == :assistant_message && !item.tool_call_ids.empty?
177
+ messages = item.tool_call_ids.map do |tool_call_id|
178
+ tool_messages_by_id.fetch(tool_call_id) do
179
+ raise ArgumentError,
180
+ "assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
181
+ end.first
182
+ end
183
+ group = ([item] + messages).uniq.sort_by { |member| [member.sequence || 0, member.id] }
184
+ elsif item.kind == :tool_message
185
+ assistant = assistant_by_tool_call[item.tool_call_id]
186
+ unless assistant
187
+ raise ArgumentError,
188
+ "orphan Tool message in Context Policy input: #{item.tool_call_id}"
189
+ end
190
+ next if claimed[assistant.id]
191
+ group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
192
+ tool_messages_by_id.fetch(tool_call_id).first
193
+ end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
194
+ else
195
+ group = [item]
196
+ end
197
+
198
+ group.each { |member| claimed[member.id] = true }
199
+ groups << group
200
+ end
201
+
202
+ mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
203
+ end
204
+
205
+ def mark_framework_required(groups, execution_id:, call_mode:)
206
+ return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
207
+
208
+ current_request_ids = groups.flatten.select do |item|
209
+ item.kind == :external_message &&
210
+ item.provenance.origin == :working &&
211
+ item.provenance.execution_id.to_s == execution_id.to_s
212
+ end.map(&:id).to_h { |id| [id, true] }
213
+
214
+ current_tool_groups = groups.select do |group|
215
+ group.any? do |item|
216
+ %i[assistant_message tool_message].include?(item.kind) &&
217
+ item.provenance.origin == :working &&
218
+ item.provenance.execution_id.to_s == execution_id.to_s
219
+ end && group.any? { |item| item.kind == :tool_message }
220
+ end
221
+ latest_tool_group = current_tool_groups.max_by do |group|
222
+ group.filter_map(&:sequence).max || 0
223
+ end
224
+ latest_tool_ids = Array(latest_tool_group).map(&:id).to_h { |id| [id, true] }
225
+
226
+ groups.map do |group|
227
+ group.map do |item|
228
+ required = item.required? || current_request_ids[item.id] || latest_tool_ids[item.id]
229
+ item.with_required(required)
230
+ end.freeze
231
+ end.freeze
232
+ end
233
+
234
+ def canonical_tool_call_ids(candidate)
235
+ Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
236
+ .compact
237
+ .map(&:to_s)
238
+ end
239
+ end
240
+ end
241
+ end