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
@@ -0,0 +1,504 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ module RecoverySupport
8
+ CONTRACT_VERSION = 1
9
+ RECOVERY_METADATA_KEY = "recovery"
10
+ TOOL_BATCH_METADATA_KEY = "recovery_tool_batch"
11
+ PENDING_LLM_ID_KEY = "pending_llm_call_id"
12
+ PENDING_LLM_STARTED_AT_KEY = "pending_llm_started_at"
13
+ INVOCATION_MODE_KEY = "invocation_mode"
14
+ CONTRACT_VERSION_KEY = "recovery_contract_version"
15
+
16
+ module_function
17
+
18
+ def canonical_copy(value)
19
+ case value
20
+ when Hash
21
+ value.to_h { |key, child| [key.to_s, canonical_copy(child)] }
22
+ when Array
23
+ value.map { |child| canonical_copy(child) }
24
+ when Symbol
25
+ value.to_s
26
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
27
+ value
28
+ else
29
+ if value.respond_to?(:to_h)
30
+ canonical_copy(value.to_h)
31
+ else
32
+ raise ArgumentError,
33
+ "Recovery value is not canonically serializable: #{value.class}"
34
+ end
35
+ end
36
+ end
37
+
38
+ def build_tool_batch_snapshot(invocation)
39
+ Array(invocation.tool_invocations).map do |child|
40
+ entry = {
41
+ "tool_invocation_id" => child.id.to_s,
42
+ "tool_call_id" => child.tool_call_id&.to_s,
43
+ "tool_name" => child.tool_name.to_s,
44
+ "llm_call_id" => invocation.tool_batch_llm_call_id&.to_s,
45
+ "raw_arguments" => canonical_copy(child.raw_arguments || {}),
46
+ "arguments" => canonical_copy(child.raw_arguments || {}),
47
+ "status" => child.status.to_s
48
+ }
49
+ if child.execution_completed?
50
+ entry["result"] = canonical_copy(child.result)
51
+ end
52
+ entry.compact
53
+ end.freeze
54
+ end
55
+
56
+ def with_recovery_metadata(execution, values)
57
+ execution.with(
58
+ execution_revision: execution.execution_revision,
59
+ metadata: execution.metadata.merge(values)
60
+ )
61
+ end
62
+
63
+ def semantic_tool_id(execution_id:, llm_call_id:, tool_call_id:, tool_name:)
64
+ source = [
65
+ "tool_invocation",
66
+ execution_id.to_s,
67
+ llm_call_id.to_s,
68
+ tool_call_id.to_s,
69
+ tool_name.to_s
70
+ ].join("\0")
71
+ "tool_invocation-#{Digest::SHA256.hexdigest(source)}".freeze
72
+ end
73
+
74
+ def manifest_from_ref(agent, ref)
75
+ raw = agent.persistence.contents.fetch_json(ref)
76
+ Phronomy::Agent::LLMInputManifest.from_h(raw)
77
+ end
78
+
79
+ def materialize_projection(agent, manifest_ref)
80
+ manifest = manifest_from_ref(agent, manifest_ref)
81
+ projection = Phronomy::Agent::RubyLLMMaterializer.new(
82
+ agent: agent,
83
+ persistence: agent.persistence
84
+ ).materialize(manifest: manifest, manifest_ref: manifest_ref)
85
+ [manifest, projection]
86
+ end
87
+
88
+ def tool_calls_from_outcome(outcome)
89
+ Array(outcome.tool_calls)
90
+ end
91
+
92
+ def normalize_provider_outcome(value)
93
+ return value if value.is_a?(Phronomy::Agent::ProviderCallOutcome)
94
+
95
+ if value.is_a?(Hash)
96
+ return Phronomy::Agent::ProviderCallOutcome.from_h(value)
97
+ end
98
+
99
+ captured = Phronomy::Agent::ProviderCallOutcome.capture(value)
100
+ return captured if captured
101
+
102
+ raise ArgumentError,
103
+ "LLM Recovery :succeeded requires a Provider result or ProviderCallOutcome-compatible Hash"
104
+ end
105
+
106
+ def resolution_failure(error)
107
+ {
108
+ "class" => error.class.name.to_s,
109
+ "message" => error.message.to_s
110
+ }.freeze
111
+ end
112
+
113
+ def error_from_failure(failure)
114
+ hash = failure.to_h { |key, value| [key.to_s, value] }
115
+ Phronomy::Error.new(
116
+ "#{hash.fetch("class", "Error")}: #{hash.fetch("message", "Recovery-resolved failure")}"
117
+ )
118
+ end
119
+
120
+ def event_payload(execution, descriptor)
121
+ {
122
+ execution_id: execution.execution_id,
123
+ execution_revision: execution.execution_revision,
124
+ reason: descriptor.fetch(:reason),
125
+ subject: Phronomy::Recovery.normalize_subject(
126
+ descriptor.fetch(:subject)
127
+ ),
128
+ allowed_outcomes: Array(
129
+ descriptor.fetch(:allowed_outcomes)
130
+ ).map(&:to_sym).freeze,
131
+ facts: Phronomy::Agent::Immutable.copy(
132
+ descriptor.fetch(:facts, {})
133
+ )
134
+ }.freeze
135
+ end
136
+
137
+ def pending_llm_descriptor(execution)
138
+ llm_call_id = execution.metadata[PENDING_LLM_ID_KEY]
139
+ return unless llm_call_id
140
+
141
+ {
142
+ reason: :outcome_unknown,
143
+ subject: {
144
+ type: :llm_call,
145
+ llm_call_id: llm_call_id
146
+ },
147
+ allowed_outcomes: Phronomy::Recovery::OUTCOMES,
148
+ facts: {
149
+ manifest_ref: execution.metadata["manifest_ref"],
150
+ call_sequence: execution.llm_calls.length + 1
151
+ }.compact.freeze
152
+ }.freeze
153
+ end
154
+
155
+ def recovery_hash(execution)
156
+ value = execution.metadata[RECOVERY_METADATA_KEY]
157
+ value.is_a?(Hash) ? value : nil
158
+ end
159
+
160
+ def current_tool_descriptor(execution)
161
+ recovery = recovery_hash(execution)
162
+ return unless recovery
163
+
164
+ subjects = Array(recovery["subjects"] || recovery[:subjects])
165
+ current = subjects.find do |entry|
166
+ hash = entry.to_h { |key, value| [key.to_s, value] }
167
+ hash.fetch("state", "unresolved") == "unresolved"
168
+ end
169
+ return unless current
170
+
171
+ hash = current.to_h { |key, value| [key.to_s, value] }
172
+ {
173
+ reason: (recovery["reason"] || recovery[:reason] || "outcome_unknown").to_sym,
174
+ subject: {
175
+ type: :tool_invocation,
176
+ tool_invocation_id: hash.fetch("tool_invocation_id")
177
+ },
178
+ allowed_outcomes: Array(
179
+ recovery["allowed_outcomes"] ||
180
+ recovery[:allowed_outcomes] ||
181
+ Phronomy::Recovery::OUTCOMES
182
+ ).map(&:to_sym),
183
+ facts: {
184
+ tool_call_id: hash["tool_call_id"],
185
+ tool_name: hash["tool_name"],
186
+ arguments: hash["arguments"],
187
+ llm_call_id: hash["llm_call_id"]
188
+ }.compact
189
+ }.freeze
190
+ end
191
+
192
+ def pending_tool_subjects(execution)
193
+ Array(execution.metadata[TOOL_BATCH_METADATA_KEY]).filter_map do |entry|
194
+ hash = entry.to_h { |key, value| [key.to_s, value] }
195
+ next unless %w[authorized awaiting_approval].include?(
196
+ hash.fetch("status")
197
+ )
198
+
199
+ {
200
+ "tool_invocation_id" => hash.fetch("tool_invocation_id"),
201
+ "llm_call_id" => hash["llm_call_id"],
202
+ "tool_call_id" => hash.fetch("tool_call_id"),
203
+ "tool_name" => hash.fetch("tool_name"),
204
+ "arguments" => canonical_copy(
205
+ hash["arguments"] || hash["raw_arguments"] || {}
206
+ ),
207
+ "state" => "unresolved"
208
+ }.compact.freeze
209
+ end.freeze
210
+ end
211
+
212
+ def pending_tool_descriptor(execution)
213
+ subjects = pending_tool_subjects(execution)
214
+ return if subjects.empty?
215
+
216
+ first = subjects.first
217
+ {
218
+ reason: :outcome_unknown,
219
+ subject: {
220
+ type: :tool_invocation,
221
+ tool_invocation_id: first.fetch("tool_invocation_id")
222
+ },
223
+ allowed_outcomes: Phronomy::Recovery::OUTCOMES,
224
+ facts: {
225
+ tool_call_id: first["tool_call_id"],
226
+ tool_name: first["tool_name"],
227
+ arguments: first["arguments"],
228
+ llm_call_id: first["llm_call_id"]
229
+ }.compact
230
+ }.freeze
231
+ end
232
+
233
+ def recovery_descriptor(execution)
234
+ case execution.phase.to_sym
235
+ when :calling_llm
236
+ pending_llm_descriptor(execution)
237
+ when :resuming
238
+ approved = execution.approval_request &&
239
+ (
240
+ execution.approval_request["approved"] ||
241
+ execution.approval_request[:approved]
242
+ )
243
+ approved ? pending_tool_descriptor(execution) : nil
244
+ when :dispatching_tools
245
+ pending_tool_descriptor(execution)
246
+ when :recovery_tools
247
+ current_tool_descriptor(execution)
248
+ end
249
+ end
250
+
251
+ def build_tool_subjects(execution, llm_call_id, outcome)
252
+ tool_calls_from_outcome(outcome).map do |call|
253
+ call_hash = canonical_copy(call)
254
+ tool_call_id = call_hash.fetch("id").to_s
255
+ tool_name = call_hash.fetch("name").to_s
256
+ {
257
+ "tool_invocation_id" => semantic_tool_id(
258
+ execution_id: execution.execution_id,
259
+ llm_call_id: llm_call_id,
260
+ tool_call_id: tool_call_id,
261
+ tool_name: tool_name
262
+ ),
263
+ "llm_call_id" => llm_call_id.to_s,
264
+ "tool_call_id" => tool_call_id,
265
+ "tool_name" => tool_name,
266
+ "arguments" => canonical_copy(call_hash.fetch("arguments", {})),
267
+ "state" => "unresolved"
268
+ }.freeze
269
+ end.freeze
270
+ end
271
+
272
+ def build_recovery_hash(subjects, reason: :outcome_unknown, allowed_outcomes: Phronomy::Recovery::OUTCOMES)
273
+ {
274
+ "version" => CONTRACT_VERSION,
275
+ "reason" => reason.to_s,
276
+ "allowed_outcomes" => Array(allowed_outcomes).map(&:to_s),
277
+ "subjects" => Array(subjects).map { |entry| canonical_copy(entry) }
278
+ }.freeze
279
+ end
280
+
281
+ def update_recovery_subject(recovery, tool_invocation_id:, state:, outcome:, result_ref: nil)
282
+ copy = canonical_copy(recovery)
283
+ copy["subjects"] = Array(copy.fetch("subjects")).map do |entry|
284
+ next entry unless entry.fetch("tool_invocation_id").to_s == tool_invocation_id.to_s
285
+
286
+ entry.merge(
287
+ "state" => state.to_s,
288
+ "outcome" => outcome.to_s,
289
+ "result_ref" => result_ref
290
+ ).compact
291
+ end
292
+ copy.freeze
293
+ end
294
+
295
+ def unresolved_subjects(recovery)
296
+ Array(recovery.fetch("subjects")).select do |entry|
297
+ entry.fetch("state", "unresolved") == "unresolved"
298
+ end
299
+ end
300
+
301
+ def latest_assistant_record(execution, llm_call_id: nil)
302
+ Array(execution.working_records).reverse.find do |record|
303
+ record.kind.to_sym == :assistant_message &&
304
+ (llm_call_id.nil? || record.llm_call_id.to_s == llm_call_id.to_s)
305
+ end
306
+ end
307
+
308
+ def build_invocation_for_suspended(agent, execution, projection, main_coordinator, listener, assistant_message:)
309
+ request = execution.approval_request && Phronomy::Agent::ToolApprovalRequest.from_h(
310
+ execution.approval_request
311
+ )
312
+ assistant_record = latest_assistant_record(execution)
313
+ unless assistant_record
314
+ raise Phronomy::ExecutionRehydrationRequiredError,
315
+ "suspended execution #{execution.execution_id} has no durable assistant Tool Call message"
316
+ end
317
+
318
+ config = {
319
+ execution_id: execution.execution_id,
320
+ phronomy_execution_coordinator: main_coordinator,
321
+ phronomy_runtime_projection: projection
322
+ }.merge(agent.__coordination_config)
323
+ config = agent.__invocation_config(config)
324
+ if execution.metadata["coordination_cancel_requested"]
325
+ config = config.merge(cancellation_token: Phronomy::Concurrency::CancellationToken.new.cancel!)
326
+ end
327
+ invocation = Phronomy::Agent::AgentInvocation.new(
328
+ agent: agent,
329
+ input: projection.ask_message,
330
+ config: config,
331
+ event_listener: listener,
332
+ mode: (execution.metadata[INVOCATION_MODE_KEY] || "invoke").to_sym,
333
+ execution_id: execution.execution_id
334
+ )
335
+ chat = agent.send(:build_chat, model_config: projection.model_config)
336
+ agent.send(
337
+ :_apply_runtime_projection_to_chat,
338
+ chat,
339
+ projection,
340
+ invocation: invocation
341
+ )
342
+ if projection.ask_message
343
+ chat.messages << RubyLLM::Message.new(
344
+ role: :user,
345
+ content: projection.ask_message
346
+ )
347
+ end
348
+ chat.messages << assistant_message
349
+
350
+ invocation.chat = chat
351
+ invocation.user_message_sent = true
352
+ invocation.approval_request = request
353
+ invocation.instance_variable_set(
354
+ :@tool_batch_llm_call_id,
355
+ assistant_record.llm_call_id&.to_s
356
+ )
357
+
358
+ by_id = (request ? request.items : []).to_h do |item|
359
+ [item.tool_invocation_id.to_s, item]
360
+ end
361
+ snapshots = Array(execution.metadata[TOOL_BATCH_METADATA_KEY])
362
+ if snapshots.empty?
363
+ raise Phronomy::ExecutionRehydrationRequiredError,
364
+ "suspended execution #{execution.execution_id} predates the durable Tool batch snapshot"
365
+ end
366
+
367
+ calls_by_id = if assistant_message.tool_calls.respond_to?(:values)
368
+ assistant_message.tool_calls.values.to_h do |call|
369
+ [call.id.to_s, call]
370
+ end
371
+ else
372
+ Array(assistant_message.tool_calls).to_h do |call|
373
+ [call.id.to_s, call]
374
+ end
375
+ end
376
+
377
+ children = snapshots.map do |snapshot|
378
+ entry = snapshot.to_h { |key, value| [key.to_s, value] }
379
+ call = calls_by_id.fetch(entry.fetch("tool_call_id").to_s) do
380
+ raise Phronomy::ExecutionRehydrationRequiredError,
381
+ "Tool Call #{entry.fetch("tool_call_id")} is missing from the durable assistant message"
382
+ end
383
+ tool = chat.tools[entry.fetch("tool_name").to_sym]
384
+ child = if tool
385
+ Phronomy::Agent::ToolInvocation.new(
386
+ execution_id: execution.execution_id,
387
+ agent: agent,
388
+ tool: tool,
389
+ tool_call: call,
390
+ config: config,
391
+ id: entry.fetch("tool_invocation_id")
392
+ )
393
+ else
394
+ Phronomy::Agent::ToolInvocation.missing(
395
+ execution_id: execution.execution_id,
396
+ agent: agent,
397
+ tool_call: call,
398
+ config: config,
399
+ id: entry.fetch("tool_invocation_id")
400
+ )
401
+ end
402
+ restore_tool_snapshot!(child, entry, by_id)
403
+ child
404
+ end
405
+ invocation.tool_invocations = children
406
+ invocation
407
+ end
408
+
409
+ def restore_tool_snapshot!(child, entry, approval_items)
410
+ status = entry.fetch("status").to_sym
411
+ case status
412
+ when :awaiting_approval
413
+ child.validate! unless child.terminal?
414
+ child.instance_variable_set(:@final_decision, :require_approval)
415
+ child.mark_awaiting_approval!
416
+ when :authorized
417
+ child.validate! unless child.terminal?
418
+ child.instance_variable_set(:@final_decision, :allow)
419
+ child.mark_authorized!
420
+ when :completed
421
+ child.instance_variable_set(:@result, entry["result"])
422
+ child.instance_variable_set(:@status, :completed)
423
+ when :rejected
424
+ child.mark_rejected!
425
+ when :failed
426
+ child.mark_framework_failed!(
427
+ Phronomy::ToolError.new("durably restored Tool preflight failure")
428
+ )
429
+ when :cancelled
430
+ child.mark_cancelled!
431
+ else
432
+ raise Phronomy::ExecutionRehydrationRequiredError,
433
+ "unsupported durable Tool snapshot state: #{status.inspect}"
434
+ end
435
+
436
+ item = approval_items[child.id.to_s]
437
+ if item
438
+ child.instance_variable_set(:@facts, Phronomy::Agent::Immutable.copy(item.facts))
439
+ child.instance_variable_set(:@authorization_reason, item.reason)
440
+ end
441
+ child
442
+ end
443
+
444
+ def build_chat_for_recovery(agent, execution, projection, main_coordinator, listener, messages:)
445
+ config = {
446
+ execution_id: execution.execution_id,
447
+ phronomy_execution_coordinator: main_coordinator,
448
+ phronomy_runtime_projection: projection
449
+ }.merge(agent.__coordination_config)
450
+ config = agent.__invocation_config(config)
451
+ if execution.metadata["coordination_cancel_requested"]
452
+ config = config.merge(cancellation_token: Phronomy::Concurrency::CancellationToken.new.cancel!)
453
+ end
454
+ invocation = Phronomy::Agent::AgentInvocation.new(
455
+ agent: agent,
456
+ input: projection.ask_message,
457
+ config: config,
458
+ event_listener: listener,
459
+ mode: (execution.metadata[INVOCATION_MODE_KEY] || "invoke").to_sym,
460
+ execution_id: execution.execution_id
461
+ )
462
+ chat = agent.send(:build_chat, model_config: projection.model_config)
463
+ agent.send(
464
+ :_apply_runtime_projection_to_chat,
465
+ chat,
466
+ projection,
467
+ invocation: invocation
468
+ )
469
+ if projection.ask_message
470
+ chat.messages << RubyLLM::Message.new(
471
+ role: :user,
472
+ content: projection.ask_message
473
+ )
474
+ end
475
+
476
+ messages.each { |message| chat.messages << message }
477
+
478
+ invocation.chat = chat
479
+ invocation.user_message_sent = true
480
+ invocation
481
+ end
482
+
483
+ def provider_output_and_usage(agent, execution)
484
+ assistant = latest_assistant_record(execution)
485
+ payload = assistant ? agent.persistence.contents.fetch_json(assistant.content_ref) : {}
486
+ output = payload["content"]
487
+
488
+ call = execution.llm_calls.last
489
+ usage_hash = if call&.usage_ref
490
+ agent.persistence.contents.fetch_json(call.usage_ref)
491
+ else
492
+ {}
493
+ end
494
+ usage = Phronomy::TokenUsage.new(
495
+ input: usage_hash["input"] || usage_hash[:input],
496
+ output: usage_hash["output"] || usage_hash[:output],
497
+ cached: usage_hash["cached"] || usage_hash[:cached],
498
+ cache_creation: usage_hash["cache_creation"] || usage_hash[:cache_creation]
499
+ )
500
+ [output, usage]
501
+ end
502
+ end
503
+ end
504
+ end
@@ -8,9 +8,10 @@ module Phronomy
8
8
  :model_config, :manifest, :manifest_ref
9
9
  )
10
10
 
11
- def initialize(agent:, persistence:)
11
+ def initialize(agent:, persistence:, additional_tools: [])
12
12
  @agent = agent
13
13
  @persistence = persistence
14
+ @additional_tools = Array(additional_tools).freeze
14
15
  end
15
16
 
16
17
  def materialize(manifest:, manifest_ref:)
@@ -95,16 +96,21 @@ module Phronomy
95
96
  end
96
97
 
97
98
  def verify_tool_definitions!(manifest)
98
- tool_set = ToolDefinitionSet.build(@agent)
99
- return tool_set unless manifest.tool_definitions_ref
100
-
101
- expected = @persistence.contents.fetch(manifest.tool_definitions_ref)
102
- actual = Phronomy::CanonicalJSON.dump(tool_set.definitions)
103
- unless expected == actual
104
- raise Phronomy::ConfigurationError,
105
- "Agent tool definitions changed after manifest creation"
99
+ unless manifest.tool_definitions_ref
100
+ return ToolDefinitionSet.build(
101
+ @agent,
102
+ additional_tools: @additional_tools
103
+ )
104
+ end
105
+
106
+ expected_definitions = fetch_json(manifest.tool_definitions_ref)
107
+ additional = @additional_tools
108
+ if additional.empty?
109
+ additional = Array(@agent.__coordination_config[:phronomy_handoff_bindings]).map(&:tool_class)
106
110
  end
107
- tool_set
111
+
112
+ ToolDefinitionSet.build(@agent, additional_tools: additional)
113
+ .select_definitions(expected_definitions)
108
114
  end
109
115
 
110
116
  def fetch_json(content_ref)
@@ -153,9 +159,6 @@ module Phronomy
153
159
  model_id: payload["model_id"]
154
160
  )
155
161
 
156
- # RubyLLM normalizes Hash content during Message initialization. A
157
- # canonical message may legitimately contain structured assistant
158
- # content, so restore the captured logical value after construction.
159
162
  message.content = payload["content"] if payload.key?("content")
160
163
  message
161
164
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Selection
6
+ Candidate = Data.define(
7
+ :candidate_id,
8
+ :source_kind,
9
+ :category,
10
+ :role,
11
+ :content_ref,
12
+ :record_id,
13
+ :agent_id,
14
+ :execution_id,
15
+ :llm_call_id,
16
+ :tool_call_id,
17
+ :sequence,
18
+ :constraint,
19
+ :priority,
20
+ :metadata
21
+ ) do
22
+ def initialize(**values)
23
+ effective_constraint = values[:constraint] ||
24
+ Constraint.selectable(origin: :context_policy)
25
+ unless effective_constraint.is_a?(Constraint)
26
+ raise ArgumentError,
27
+ "Selection::Candidate constraint must be Selection::Constraint"
28
+ end
29
+
30
+ normalized = values.merge(
31
+ candidate_id: values.fetch(:candidate_id).to_s.freeze,
32
+ source_kind: values.fetch(:source_kind).to_sym,
33
+ category: values.fetch(:category).to_sym,
34
+ role: values[:role]&.to_sym,
35
+ content_ref: values.fetch(:content_ref).to_s.freeze,
36
+ record_id: values[:record_id]&.to_s&.freeze,
37
+ agent_id: values[:agent_id]&.to_s&.freeze,
38
+ execution_id: values[:execution_id]&.to_s&.freeze,
39
+ llm_call_id: values[:llm_call_id]&.to_s&.freeze,
40
+ tool_call_id: values[:tool_call_id]&.to_s&.freeze,
41
+ sequence: values[:sequence] && Integer(values[:sequence]),
42
+ constraint: effective_constraint,
43
+ priority: Integer(values[:priority] || 0),
44
+ metadata: Immutable.copy(values[:metadata] || {})
45
+ )
46
+
47
+ super(**normalized)
48
+ freeze
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Selection
6
+ CONSTRAINT_MODES = %i[required selectable forbidden].freeze
7
+
8
+ Constraint = Data.define(:mode, :origin, :reason) do
9
+ def initialize(mode:, origin:, reason: nil)
10
+ normalized_mode = mode.to_sym
11
+ unless CONSTRAINT_MODES.include?(normalized_mode)
12
+ raise ArgumentError, "unknown Selection constraint mode: #{mode.inspect}"
13
+ end
14
+
15
+ super(
16
+ mode: normalized_mode,
17
+ origin: origin.to_sym,
18
+ reason: reason&.to_s&.freeze
19
+ )
20
+ freeze
21
+ end
22
+
23
+ def required?
24
+ mode == :required
25
+ end
26
+
27
+ def selectable?
28
+ mode == :selectable
29
+ end
30
+
31
+ def forbidden?
32
+ mode == :forbidden
33
+ end
34
+
35
+ def self.required(origin:, reason: nil)
36
+ new(mode: :required, origin: origin, reason: reason)
37
+ end
38
+
39
+ def self.selectable(origin:, reason: nil)
40
+ new(mode: :selectable, origin: origin, reason: reason)
41
+ end
42
+
43
+ def self.forbidden(origin:, reason: nil)
44
+ new(mode: :forbidden, origin: origin, reason: reason)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end