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
@@ -3,64 +3,156 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  class ContextAssembler
6
- ASSEMBLY_POLICY_VERSION = 6
6
+ ASSEMBLY_POLICY_VERSION = 8
7
7
  SEGMENT_ORIGIN_METADATA_KEY = "phronomy_origin"
8
- BEFORE_LLM_INPUT_ORIGIN = "before_llm_input"
9
- EARLY_CONTEXT_CATEGORIES = %i[
10
- instruction structured_state knowledge memory summary
8
+ POLICY_ORIGIN_METADATA_KEY = "context_policy_origin"
9
+ POLICY_ITEM_ID_METADATA_KEY = "context_policy_item_id"
10
+ SEMANTIC_CATEGORY_METADATA_KEY = "context_policy_semantic_category"
11
+ CONTENT_FORMAT_METADATA_KEY = "context_policy_content_format"
12
+ CONVERSATION_GROUP_ID_METADATA_KEY = "context_policy_conversation_group_id"
13
+ TRUSTED_HANDOFF_METADATA_KEYS = %w[
14
+ handoff_policy_category
15
+ handoff_provenance
11
16
  ].freeze
17
+ HANDOFF_POLICY_CATEGORY_METADATA_KEY = "handoff_policy_category"
18
+ BEFORE_LLM_INPUT_ORIGIN = "before_llm_input"
19
+ HANDOFF_CONTEXT_ORIGIN = "handoff_context"
20
+
21
+ Prepared = Data.define(:input, :plan, :model_config, :call_sequence, :call_mode) do
22
+ def initialize(**values)
23
+ super
24
+ freeze
25
+ end
26
+ end
12
27
 
13
28
  def initialize(
14
29
  agent:,
15
30
  persistence:,
16
- policy: ContextPolicies::Default.new,
17
31
  candidate_resolver: nil,
18
32
  journal_records: nil
19
33
  )
20
34
  @agent = agent
21
35
  @persistence = persistence
22
- @policy = policy
36
+ @policy = agent.class.context_policy
23
37
  @journal_records = journal_records
24
38
  @candidate_resolver = candidate_resolver || ContextCandidateResolver.new(
25
39
  content_loader: method(:fetch_content)
26
40
  )
41
+ @input_builder = ContextPolicyInputBuilder.new(
42
+ content_loader: method(:fetch_content)
43
+ )
27
44
  end
28
45
 
29
- def build_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
46
+ # Builds one immutable Policy input snapshot and executes Application Policy.
47
+ # This method must run outside a Phronomy Persistence transaction.
48
+ def prepare_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
30
49
  projection = journal_projection(agent_root)
31
50
  model_cfg = effective_model_config(config, patch)
32
- tool_set = ToolDefinitionSet.build(@agent)
51
+ tool_set = ToolDefinitionSet.build(
52
+ @agent,
53
+ additional_tools: handoff_tool_classes(config)
54
+ )
33
55
  system_text = build_system_text(input)
34
56
  hook_candidates = normalize_candidates(patch.segment_candidates)
35
57
  input_ref = execution.metadata.fetch("current_input_ref")
36
- current_input = @persistence.contents.fetch_text(input_ref)
58
+ current_input_content = @persistence.contents.fetch_text(input_ref)
37
59
  excluded = [execution.metadata["current_input_record_id"]].compact
60
+ handoff_context = config[:phronomy_handoff_context]
38
61
 
39
- base_segments = []
62
+ instructions = []
40
63
  unless system_text.to_s.empty?
41
- base_segments << text_segment(:instruction, :system, system_text)
64
+ instructions << ContextPolicyInput::InstructionItem.new(
65
+ id: "instruction:agent:#{execution.execution_id}:1",
66
+ kind: :instruction,
67
+ role: :system,
68
+ content: system_text.to_s,
69
+ content_format: :text,
70
+ estimated_tokens: estimate_value(system_text.to_s),
71
+ required: true,
72
+ provenance: ContextPolicyInput::Provenance.new(origin: :agent_configuration),
73
+ metadata: {}
74
+ )
75
+ end
76
+ if handoff_context
77
+ instructions << ContextPolicyInput::InstructionItem.new(
78
+ id: "instruction:handoff:#{execution.execution_id}:1",
79
+ kind: :handoff_responsibility,
80
+ role: :user,
81
+ content: handoff_context.responsibility.to_s,
82
+ content_format: :text,
83
+ estimated_tokens: estimate_value(handoff_context.responsibility.to_s),
84
+ required: true,
85
+ provenance: ContextPolicyInput::Provenance.new(origin: :handoff_context),
86
+ metadata: {SEGMENT_ORIGIN_METADATA_KEY => HANDOFF_CONTEXT_ORIGIN}
87
+ )
42
88
  end
43
89
 
44
- assemble(
90
+ generation = agent_root.transcript_generation
91
+ eligible_working = Array(execution.working_records).select do |record|
92
+ record.context_candidate && record.context_generation == generation
93
+ end
94
+ candidates = @candidate_resolver.resolve(
95
+ prior_records: projection.context_records,
96
+ working_records: eligible_working,
97
+ excluded_record_ids: excluded
98
+ )
99
+ candidates = merge_hook_candidates(
100
+ candidates,
101
+ hook_candidates,
102
+ agent_root: agent_root,
103
+ execution: execution,
104
+ call_sequence: 1
105
+ )
106
+ candidates = merge_handoff_candidates(
107
+ candidates,
108
+ handoff_context,
109
+ execution: execution
110
+ )
111
+ next_sequence = Array(candidates).filter_map(&:sequence).max.to_i + 1
112
+ current_input = ContextPolicyInput::ConversationItem.new(
113
+ id: "current-input:#{execution.execution_id}",
114
+ kind: :current_input,
115
+ role: :user,
116
+ content: current_input_content,
117
+ content_format: :text,
118
+ sequence: next_sequence,
119
+ estimated_tokens: estimate_value(current_input_content),
120
+ required: true,
121
+ provenance: ContextPolicyInput::Provenance.new(
122
+ origin: :working,
123
+ content_ref: input_ref,
124
+ record_id: execution.metadata["current_input_record_id"],
125
+ agent_id: agent_root.agent_id,
126
+ execution_id: execution.execution_id
127
+ ),
128
+ tool_call_id: nil,
129
+ tool_call_ids: [],
130
+ delivery: :ask_argument,
131
+ metadata: {
132
+ "source_agent_id" => agent_root.agent_id,
133
+ "source_execution_id" => execution.execution_id,
134
+ "handoff_policy_category" => "current_request"
135
+ }
136
+ )
137
+
138
+ prepare(
45
139
  agent_root: agent_root,
46
140
  execution: execution,
47
141
  call_sequence: 1,
48
142
  call_mode: :ask,
49
143
  previous_manifest: nil,
50
144
  model_config: model_cfg,
51
- tool_definitions: tool_set.definitions,
52
- tool_definitions_ref: nil,
53
- prior_records: projection.context_records,
54
- working_records: execution.working_records,
55
- excluded_record_ids: excluded,
56
- base_segments: base_segments,
57
- hook_candidates: hook_candidates,
58
- current_input_segment: segment(:current_input, :user, input_ref, :ask_argument),
59
- mandatory_values: [system_text, current_input, tool_set.definitions]
145
+ candidates: candidates,
146
+ instruction: instructions,
147
+ tools: tool_items(tool_set),
148
+ current_input: current_input
60
149
  )
61
150
  end
62
151
 
63
- def build_followup(
152
+ # Builds one immutable follow-up Policy input snapshot and executes Policy.
153
+ # The previous finalized Manifest is read-only input; Policy-generated items
154
+ # from a prior call are not promoted into future candidates automatically.
155
+ def prepare_followup(
64
156
  base_manifest:,
65
157
  agent_root:,
66
158
  execution:,
@@ -70,68 +162,27 @@ module Phronomy
70
162
  projection = journal_projection(agent_root)
71
163
  model_cfg = effective_model_config(config, patch)
72
164
  hook_candidates = normalize_candidates(patch.segment_candidates)
73
- system_segments = base_manifest.segments.select do |segment|
74
- segment.role == :system && !before_llm_input_segment?(segment)
75
- end
76
- tool_definitions = base_manifest.tool_definitions_ref ?
77
- fetch_json(base_manifest.tool_definitions_ref) : []
78
-
79
- assemble(
80
- agent_root: agent_root,
81
- execution: execution,
82
- call_sequence: execution.llm_calls.length + 1,
83
- call_mode: :complete,
84
- previous_manifest: base_manifest,
85
- model_config: model_cfg,
86
- tool_definitions: tool_definitions,
87
- tool_definitions_ref: base_manifest.tool_definitions_ref,
88
- prior_records: projection.context_records,
89
- working_records: execution.working_records,
90
- excluded_record_ids: [],
91
- base_segments: system_segments.map { |existing| segment_hash(existing) },
92
- hook_candidates: hook_candidates,
93
- current_input_segment: nil,
94
- mandatory_values: system_segments.map { |segment| fetch_content(segment.content_ref) } +
95
- [tool_definitions]
165
+ handoff_context = config[:phronomy_handoff_context]
166
+ tool_set = ToolDefinitionSet.build(
167
+ @agent,
168
+ additional_tools: handoff_tool_classes(config)
96
169
  )
97
- end
98
170
 
99
- private
100
-
101
- def journal_projection(agent_root)
102
- if @journal_records
103
- JournalProjection.new(agent_root: agent_root, records: @journal_records)
104
- else
105
- JournalProjection.new(persistence: @persistence, agent_root: agent_root)
171
+ instructions = base_manifest.segments.filter_map do |segment|
172
+ next unless retained_base_instruction?(segment)
173
+ instruction_item_from_manifest(segment)
106
174
  end
107
- end
108
175
 
109
- def assemble(
110
- agent_root:,
111
- execution:,
112
- call_sequence:,
113
- call_mode:,
114
- previous_manifest:,
115
- model_config:,
116
- tool_definitions:,
117
- tool_definitions_ref:,
118
- prior_records:,
119
- working_records:,
120
- excluded_record_ids:,
121
- base_segments:,
122
- hook_candidates:,
123
- current_input_segment:,
124
- mandatory_values:
125
- )
126
176
  generation = agent_root.transcript_generation
127
- eligible_working = Array(working_records).select do |record|
177
+ eligible_working = Array(execution.working_records).select do |record|
128
178
  record.context_candidate && record.context_generation == generation
129
179
  end
130
180
  candidates = @candidate_resolver.resolve(
131
- prior_records: prior_records,
181
+ prior_records: projection.context_records,
132
182
  working_records: eligible_working,
133
- excluded_record_ids: excluded_record_ids
183
+ excluded_record_ids: []
134
184
  )
185
+ call_sequence = execution.llm_calls.length + 1
135
186
  candidates = merge_hook_candidates(
136
187
  candidates,
137
188
  hook_candidates,
@@ -139,73 +190,214 @@ module Phronomy
139
190
  execution: execution,
140
191
  call_sequence: call_sequence
141
192
  )
193
+ candidates = merge_handoff_candidates(
194
+ candidates,
195
+ handoff_context,
196
+ execution: execution
197
+ )
142
198
 
143
- token_budget = TokenBudgetResolver.new(agent: @agent).resolve(model_config)
144
- mandatory_token_estimate = estimate_values(mandatory_values)
145
- parts = context_parts
146
- request = ContextRequest.new(
147
- agent_id: agent_root.agent_id,
148
- execution_id: execution.execution_id,
199
+ prepare(
200
+ agent_root: agent_root,
201
+ execution: execution,
149
202
  call_sequence: call_sequence,
150
- call_mode: call_mode,
203
+ call_mode: :complete,
204
+ previous_manifest: base_manifest,
205
+ model_config: model_cfg,
151
206
  candidates: candidates,
152
- token_budget: token_budget,
153
- model_config: model_config,
154
- previous_manifest: previous_manifest,
155
- required_coverage: [],
156
- parts: parts,
157
- metadata: {"mandatory_token_estimate" => mandatory_token_estimate}
207
+ instruction: instructions,
208
+ tools: tool_items(tool_set),
209
+ current_input: nil
158
210
  )
159
- plan = @policy.call(request)
160
- validated = ContextPlanValidator.new.validate!(request: request, plan: plan)
161
- unless validated.plan.derived_contents.empty?
162
- raise Phronomy::ConfigurationError,
163
- "Derived Context persistence is not enabled in Context Policy phase 1-4"
211
+ end
212
+
213
+ # Validates and canonicalizes a previously prepared Policy decision.
214
+ # This method contains no ContextPolicy invocation and is safe to execute in
215
+ # the short commit transaction after the caller revalidates durable revision.
216
+ def finalize(prepared, persistence: @persistence)
217
+ unless prepared.is_a?(Prepared)
218
+ raise ArgumentError, "ContextAssembler#finalize expected ContextAssembler::Prepared"
164
219
  end
165
220
 
166
- selected_candidates = validated.selected_candidates.sort_by do |candidate|
167
- [candidate.sequence || 0, candidate.candidate_id]
221
+ plan = ContextPlanValidator.new.validate!(input: prepared.input, plan: prepared.plan)
222
+ segments = []
223
+ plan.instruction.each do |item|
224
+ segments << segment_from_content_item(
225
+ item,
226
+ persistence: persistence,
227
+ additional_metadata: {SEMANTIC_CATEGORY_METADATA_KEY => "instruction"}
228
+ )
229
+ end
230
+ plan.knowledge.each do |item|
231
+ segments << segment_from_content_item(
232
+ item,
233
+ persistence: persistence,
234
+ additional_metadata: {SEMANTIC_CATEGORY_METADATA_KEY => "knowledge"}
235
+ )
236
+ end
237
+ plan.conversation.each_with_index do |group, group_index|
238
+ group_metadata = {
239
+ SEMANTIC_CATEGORY_METADATA_KEY => "conversation",
240
+ CONVERSATION_GROUP_ID_METADATA_KEY =>
241
+ "conversation:#{prepared.call_sequence}:#{group_index}"
242
+ }
243
+ if tool_exchange_group?(group)
244
+ group_metadata[HANDOFF_POLICY_CATEGORY_METADATA_KEY] = "tool_exchanges"
245
+ end
246
+
247
+ group.each do |item|
248
+ segments << segment_from_content_item(
249
+ item,
250
+ persistence: persistence,
251
+ additional_metadata: group_metadata
252
+ )
253
+ end
168
254
  end
169
- segments = Array(base_segments).dup
170
- append_selected_candidates(segments, selected_candidates, before_history: true)
171
- append_selected_candidates(segments, selected_candidates, before_history: false)
172
- segments << current_input_segment if current_input_segment
173
255
 
256
+ selected_tool_definitions = plan.tools.map(&:definition)
174
257
  ContextParts::Validators::FinalBudgetValidator.new(
175
- content_loader: method(:fetch_content)
258
+ content_loader: lambda { |ref| fetch_content_from(persistence, ref) }
176
259
  ).validate!(
177
- token_budget: token_budget,
260
+ token_budget: prepared.input.token_budget,
178
261
  segments: segments,
179
- extra_values: [tool_definitions]
262
+ extra_values: [selected_tool_definitions]
180
263
  )
181
264
 
182
265
  store_manifest(
266
+ persistence: persistence,
267
+ call_sequence: prepared.call_sequence,
268
+ call_mode: prepared.call_mode,
269
+ segments: segments,
270
+ model_config_ref: persistence.contents.put_json(prepared.model_config),
271
+ tool_definitions_ref: persistence.contents.put_json(selected_tool_definitions)
272
+ )
273
+ end
274
+
275
+ private
276
+
277
+ def prepare(
278
+ agent_root:,
279
+ execution:,
280
+ call_sequence:,
281
+ call_mode:,
282
+ previous_manifest:,
283
+ model_config:,
284
+ candidates:,
285
+ instruction:,
286
+ tools:,
287
+ current_input:
288
+ )
289
+ token_budget = TokenBudgetResolver.new(agent: @agent).resolve(model_config)
290
+ policy_input = @input_builder.build(
291
+ agent_id: agent_root.agent_id,
292
+ execution_id: execution.execution_id,
183
293
  call_sequence: call_sequence,
184
294
  call_mode: call_mode,
185
- segments: segments,
186
- model_config_ref: @persistence.contents.put_json(model_config),
187
- tool_definitions_ref: tool_definitions_ref ||
188
- @persistence.contents.put_json(tool_definitions)
295
+ candidates: candidates,
296
+ instruction: instruction,
297
+ tools: tools,
298
+ current_input: current_input,
299
+ token_budget: token_budget,
300
+ model_config: model_config,
301
+ previous_manifest: previous_manifest,
302
+ metadata: {
303
+ "agent_revision" => agent_root.agent_revision,
304
+ "context_revision" => agent_root.context_revision,
305
+ "journal_position" => agent_root.journal_position,
306
+ "execution_revision" => execution.execution_revision
307
+ }
308
+ )
309
+ plan = invoke_policy(policy_input)
310
+ ContextPlanValidator.new.validate!(input: policy_input, plan: plan)
311
+ Prepared.new(
312
+ input: policy_input,
313
+ plan: plan,
314
+ model_config: model_config,
315
+ call_sequence: call_sequence,
316
+ call_mode: call_mode
189
317
  )
190
318
  end
191
319
 
192
- def context_parts
193
- {
194
- unit_builder: ContextParts::UnitBuilders::DependencyAwareUnitBuilder.new,
195
- required_context_resolver: ContextParts::Requirements::RequiredContextResolver.new,
196
- recent_first_selector: ContextParts::Selectors::RecentFirstSelector.new,
197
- token_budget_packer: ContextParts::Budget::TokenBudgetPacker.new
198
- }.freeze
320
+ def invoke_policy(policy_input)
321
+ tracer = Phronomy.configuration.tracer
322
+ span = tracer.start_span(
323
+ "context_policy",
324
+ agent_id: policy_input.agent_id,
325
+ execution_id: policy_input.execution_id,
326
+ call_sequence: policy_input.call_sequence,
327
+ policy_class: @policy.class.name || @policy.class.to_s,
328
+ instruction_count: policy_input.instruction.length,
329
+ knowledge_count: policy_input.knowledge.length,
330
+ tool_count: policy_input.tools.length,
331
+ conversation_group_count: policy_input.conversation.length
332
+ )
333
+ result = @policy.call(policy_input)
334
+ tracer.finish_span(span)
335
+ result
336
+ rescue => error
337
+ tracer&.finish_span(span, error: error) if defined?(span) && span
338
+ raise
199
339
  end
200
340
 
201
- def estimate_values(values)
202
- Array(values).sum do |value|
203
- bytes = value.is_a?(String) ? value : Phronomy::CanonicalJSON.dump(value)
204
- Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
341
+ def journal_projection(agent_root)
342
+ if @journal_records
343
+ JournalProjection.new(agent_root: agent_root, records: @journal_records)
344
+ else
345
+ JournalProjection.new(persistence: @persistence, agent_root: agent_root)
205
346
  end
206
347
  end
207
348
 
208
- def store_manifest(call_sequence:, call_mode:, segments:, model_config_ref:, tool_definitions_ref:)
349
+ def retained_base_instruction?(segment)
350
+ origin = segment.metadata[POLICY_ORIGIN_METADATA_KEY]
351
+ return %w[agent_configuration handoff_context].include?(origin) if origin
352
+
353
+ # Current-format manifests created before ACS-04 do not have the origin
354
+ # marker. Preserve only the legacy base instruction shapes.
355
+ (segment.role == :system &&
356
+ segment.metadata[SEGMENT_ORIGIN_METADATA_KEY] != BEFORE_LLM_INPUT_ORIGIN) ||
357
+ segment.category.to_sym == :handoff_responsibility
358
+ end
359
+
360
+ def instruction_item_from_manifest(segment)
361
+ origin = segment.metadata[POLICY_ORIGIN_METADATA_KEY] ||
362
+ ((segment.category.to_sym == :handoff_responsibility) ? "handoff_context" : "agent_configuration")
363
+ ContextPolicyInput::InstructionItem.new(
364
+ id: segment.metadata[POLICY_ITEM_ID_METADATA_KEY] || "manifest-instruction:#{segment.position}",
365
+ kind: segment.category,
366
+ role: segment.role,
367
+ content: fetch_content(segment.content_ref),
368
+ content_format: :text,
369
+ estimated_tokens: estimate_value(fetch_content(segment.content_ref)),
370
+ required: true,
371
+ provenance: ContextPolicyInput::Provenance.new(
372
+ origin: origin.to_sym,
373
+ content_ref: segment.content_ref
374
+ ),
375
+ metadata: segment.metadata
376
+ )
377
+ end
378
+
379
+ def tool_items(tool_set)
380
+ tool_set.definitions.map do |definition|
381
+ name = definition.fetch("name")
382
+ ContextPolicyInput::ToolItem.new(
383
+ id: "tool:#{name}",
384
+ definition: definition,
385
+ estimated_tokens: estimate_value(definition),
386
+ required: false,
387
+ provenance: ContextPolicyInput::Provenance.new(origin: :agent_configuration),
388
+ metadata: {}
389
+ )
390
+ end.freeze
391
+ end
392
+
393
+ def store_manifest(
394
+ persistence:,
395
+ call_sequence:,
396
+ call_mode:,
397
+ segments:,
398
+ model_config_ref:,
399
+ tool_definitions_ref:
400
+ )
209
401
  positioned = segments.each_with_index.map do |value, position|
210
402
  LLMInputManifest::Segment.new(**value.merge(position: position))
211
403
  end
@@ -219,7 +411,57 @@ module Phronomy
219
411
  ruby_llm_version: defined?(RubyLLM::VERSION) ? RubyLLM::VERSION : nil,
220
412
  adapter_name: Phronomy.configuration.llm_adapter.class.name
221
413
  )
222
- [manifest, @persistence.contents.put_json(manifest.to_h)]
414
+ [manifest, persistence.contents.put_json(manifest.to_h)]
415
+ end
416
+
417
+ def segment_from_content_item(item, persistence:, additional_metadata: {})
418
+ content_ref = item.provenance.content_ref || store_item_content(item, persistence)
419
+ metadata = sanitized_item_metadata(item).merge(additional_metadata).merge(
420
+ CONTENT_FORMAT_METADATA_KEY => item.content_format.to_s,
421
+ POLICY_ITEM_ID_METADATA_KEY => item.id,
422
+ POLICY_ORIGIN_METADATA_KEY => item.provenance.origin.to_s,
423
+ "journal_record_id" => item.provenance.record_id,
424
+ "source_agent_id" => item.provenance.agent_id,
425
+ "source_execution_id" => item.provenance.execution_id,
426
+ "llm_call_id" => item.provenance.llm_call_id
427
+ ).compact
428
+
429
+ {
430
+ category: item.kind,
431
+ role: item.role,
432
+ content_ref: content_ref,
433
+ delivery: item.respond_to?(:delivery) ? item.delivery : :chat_message,
434
+ tool_call_id: item.respond_to?(:tool_call_id) ? item.tool_call_id : nil,
435
+ metadata: metadata
436
+ }
437
+ end
438
+
439
+ def sanitized_item_metadata(item)
440
+ raw = item.metadata.to_h.transform_keys(&:to_s)
441
+ # :working origin is Phronomy-controlled (from perform_initial_preparation),
442
+ # so handoff routing metadata on working records is equally trusted.
443
+ trusted_handoff = if item.provenance.origin == :handoff || item.provenance.origin == :working
444
+ raw.slice(*TRUSTED_HANDOFF_METADATA_KEYS)
445
+ else
446
+ {}
447
+ end
448
+
449
+ raw.except(*ContextPolicyInput::FRAMEWORK_METADATA_KEYS)
450
+ .merge(trusted_handoff)
451
+ end
452
+
453
+ def tool_exchange_group?(group)
454
+ Array(group).any? do |item|
455
+ item.kind == :assistant_message && !item.tool_call_ids.empty?
456
+ end && Array(group).any? { |item| item.kind == :tool_message }
457
+ end
458
+
459
+ def store_item_content(item, persistence)
460
+ if item.content_format == :json
461
+ persistence.contents.put_json(item.content)
462
+ else
463
+ persistence.contents.put_text(item.content.to_s)
464
+ end
223
465
  end
224
466
 
225
467
  def effective_model_config(config, patch)
@@ -234,8 +476,7 @@ module Phronomy
234
476
  "max_output_tokens" => @agent.class.max_output_tokens,
235
477
  "context_window" => @agent.class.context_window,
236
478
  "cache_instructions" => !!@agent.class.cache_instructions,
237
- "parallel_tool_execution" => !!Phronomy.configuration.parallel_tool_execution,
238
- "thread_id" => config[:thread_id]&.to_s
479
+ "parallel_tool_execution" => !!Phronomy.configuration.parallel_tool_execution
239
480
  }.compact
240
481
  end
241
482
 
@@ -250,28 +491,43 @@ module Phronomy
250
491
  content = hash.fetch(:content) { hash.fetch("content") }
251
492
  category = (hash[:category] || hash["category"] || :knowledge).to_sym
252
493
  role = (hash[:role] || hash["role"] || default_role(category)).to_sym
253
- metadata = hash[:metadata] || hash["metadata"] || {}
494
+ metadata = (hash[:metadata] || hash["metadata"] || {})
495
+ .to_h
496
+ .transform_keys(&:to_s)
497
+ validate_application_metadata!(metadata, source: "LLMInputPatch segment candidate")
254
498
  {
255
- content: content.to_s,
499
+ content: content,
256
500
  category: category,
257
501
  role: role,
258
- metadata: metadata.to_h.transform_keys(&:to_s)
502
+ metadata: metadata
259
503
  }
260
504
  end
261
505
  end
262
506
 
507
+ def validate_application_metadata!(metadata, source:)
508
+ conflicts = metadata.keys.map(&:to_s) & ContextPolicyInput::FRAMEWORK_METADATA_KEYS
509
+ return if conflicts.empty?
510
+
511
+ raise ArgumentError,
512
+ "#{source} metadata uses Framework-reserved key(s): #{conflicts.sort.inspect}"
513
+ end
514
+
263
515
  def merge_hook_candidates(candidates, hooks, agent_root:, execution:, call_sequence:)
264
516
  next_sequence = Array(candidates).filter_map(&:sequence).max.to_i
265
517
  generated = hooks.each_with_index.map do |hook, index|
266
- content_ref = @persistence.contents.put_text(hook.fetch(:content))
518
+ content = hook.fetch(:content)
519
+ content_ref = if content.is_a?(String)
520
+ @persistence.contents.put_text(content)
521
+ else
522
+ @persistence.contents.put_json(content)
523
+ end
267
524
  metadata = hook.fetch(:metadata).merge(
268
525
  SEGMENT_ORIGIN_METADATA_KEY => BEFORE_LLM_INPUT_ORIGIN,
269
- "estimated_tokens" => Phronomy::LlmContextWindow::TokenEstimator.estimate(
270
- fetch_content(content_ref)
271
- ),
272
- "source_kind" => "hook"
526
+ "estimated_tokens" => estimate_value(content),
527
+ "source_kind" => "hook",
528
+ "content_format" => content.is_a?(String) ? "text" : "json"
273
529
  )
274
- ContextCandidate.new(
530
+ Selection::Candidate.new(
275
531
  candidate_id: "hook:#{execution.execution_id}:#{call_sequence}:#{index}",
276
532
  source_kind: :hook,
277
533
  category: hook.fetch(:category),
@@ -283,7 +539,7 @@ module Phronomy
283
539
  llm_call_id: nil,
284
540
  tool_call_id: nil,
285
541
  sequence: next_sequence + index + 1,
286
- requirement: :optional,
542
+ constraint: Selection::Constraint.selectable(origin: :context_policy),
287
543
  priority: 0,
288
544
  metadata: metadata
289
545
  )
@@ -293,70 +549,73 @@ module Phronomy
293
549
  .freeze
294
550
  end
295
551
 
296
- def append_selected_candidates(segments, candidates, before_history:)
297
- candidates.each do |candidate|
298
- early = EARLY_CONTEXT_CATEGORIES.include?(candidate.category)
299
- next unless early == before_history
552
+ def merge_handoff_candidates(candidates, handoff_context, execution:)
553
+ return Array(candidates).freeze unless handoff_context
300
554
 
301
- segments << segment_from_candidate(candidate)
555
+ unless handoff_context.is_a?(Phronomy::MultiAgent::HandoffContext)
556
+ raise ArgumentError, "phronomy_handoff_context must be a HandoffContext"
302
557
  end
303
- end
304
558
 
305
- def default_role(category)
306
- (category == :instruction) ? :system : :user
307
- end
559
+ next_sequence = Array(candidates).filter_map(&:sequence).max.to_i
560
+ transferred = handoff_context.items.each_with_index.map do |item, index|
561
+ content_ref = if item.content_format == :json
562
+ @persistence.contents.put_json(item.content)
563
+ else
564
+ @persistence.contents.put_text(item.content.to_s)
565
+ end
566
+ metadata = item.metadata.merge(
567
+ SEGMENT_ORIGIN_METADATA_KEY => HANDOFF_CONTEXT_ORIGIN,
568
+ "estimated_tokens" => estimate_value(item.content),
569
+ "source_kind" => "handoff",
570
+ "content_format" => item.content_format.to_s,
571
+ "handoff_policy_category" => item.policy_category.to_s,
572
+ "handoff_provenance" => item.provenance.to_h
573
+ )
308
574
 
309
- def text_segment(category, role, content, metadata: {})
310
- segment(
311
- category, role, @persistence.contents.put_text(content.to_s), :chat_message,
312
- metadata: metadata
313
- )
314
- end
575
+ Selection::Candidate.new(
576
+ candidate_id: "handoff:#{execution.execution_id}:#{index}:#{item.provenance.origin_record_id || item.provenance.origin_tool_call_id || index}",
577
+ source_kind: :handoff,
578
+ category: item.candidate_category,
579
+ role: item.role,
580
+ content_ref: content_ref,
581
+ record_id: nil,
582
+ agent_id: item.provenance.origin_agent_id,
583
+ execution_id: execution.execution_id,
584
+ llm_call_id: item.provenance.origin_llm_call_id,
585
+ tool_call_id: item.tool_call_id,
586
+ sequence: next_sequence + index + 1,
587
+ constraint: Selection::Constraint.selectable(origin: :handoff_context),
588
+ priority: 50,
589
+ metadata: metadata
590
+ )
591
+ end
315
592
 
316
- def before_llm_input_segment?(segment)
317
- segment.metadata[SEGMENT_ORIGIN_METADATA_KEY] == BEFORE_LLM_INPUT_ORIGIN
593
+ (Array(candidates) + transferred)
594
+ .sort_by { |candidate| [candidate.sequence || 0, candidate.candidate_id] }
595
+ .freeze
318
596
  end
319
597
 
320
- def segment_from_candidate(candidate)
321
- metadata = candidate.metadata.reject do |key, _value|
322
- %w[estimated_tokens source_kind source_sequence].include?(key.to_s)
323
- end
324
- metadata = metadata.merge(
325
- "journal_record_id" => candidate.record_id,
326
- "journal_sequence" => candidate.metadata["source_sequence"],
327
- "llm_call_id" => candidate.llm_call_id
328
- ).compact
329
- segment(
330
- candidate.category,
331
- candidate.role,
332
- candidate.content_ref,
333
- :chat_message,
334
- tool_call_id: candidate.tool_call_id,
335
- metadata: metadata
336
- )
598
+ def default_role(category)
599
+ (category == :instruction) ? :system : :user
337
600
  end
338
601
 
339
- def segment_hash(existing)
340
- {
341
- category: existing.category, role: existing.role,
342
- content_ref: existing.content_ref, delivery: existing.delivery,
343
- tool_call_id: existing.tool_call_id, metadata: existing.metadata
344
- }
602
+ def handoff_tool_classes(config)
603
+ Array(config[:phronomy_handoff_bindings]).map(&:tool_class).freeze
345
604
  end
346
605
 
347
- def segment(category, role, content_ref, delivery, tool_call_id: nil, metadata: {})
348
- {category: category, role: role, content_ref: content_ref, delivery: delivery,
349
- tool_call_id: tool_call_id, metadata: metadata}
606
+ def estimate_value(value)
607
+ bytes = value.is_a?(String) ? value : Phronomy::CanonicalJSON.dump(value)
608
+ Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
350
609
  end
351
610
 
352
- def fetch_json(ref)
353
- Phronomy::CanonicalJSON.load(@persistence.contents.fetch(ref))
611
+ def fetch_content(ref)
612
+ fetch_content_from(@persistence, ref)
354
613
  end
355
614
 
356
- def fetch_content(ref)
357
- @persistence.contents.fetch_text(ref)
615
+ def fetch_content_from(persistence, ref)
616
+ persistence.contents.fetch_text(ref)
358
617
  rescue
359
- @persistence.contents.fetch(ref)
618
+ persistence.contents.fetch(ref)
360
619
  end
361
620
 
362
621
  def build_system_text(input)