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
@@ -0,0 +1,512 @@
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)
309
+ 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
+ materializer = Phronomy::Agent::RubyLLMMaterializer.new(
319
+ agent: agent,
320
+ persistence: agent.persistence
321
+ )
322
+ assistant_message = materializer.materialize_journal_record(
323
+ assistant_record
324
+ )
325
+
326
+ config = {
327
+ execution_id: execution.execution_id,
328
+ phronomy_execution_coordinator: main_coordinator,
329
+ phronomy_runtime_projection: projection
330
+ }
331
+ invocation = Phronomy::Agent::AgentInvocation.new(
332
+ agent: agent,
333
+ input: projection.ask_message,
334
+ config: config,
335
+ event_listener: listener,
336
+ mode: (execution.metadata[INVOCATION_MODE_KEY] || "invoke").to_sym,
337
+ execution_id: execution.execution_id
338
+ )
339
+ chat = agent.send(:build_chat, model_config: projection.model_config)
340
+ agent.send(
341
+ :_apply_runtime_projection_to_chat,
342
+ chat,
343
+ projection,
344
+ invocation: invocation
345
+ )
346
+ if projection.ask_message
347
+ chat.messages << RubyLLM::Message.new(
348
+ role: :user,
349
+ content: projection.ask_message
350
+ )
351
+ end
352
+ chat.messages << assistant_message
353
+
354
+ invocation.chat = chat
355
+ invocation.user_message_sent = true
356
+ invocation.approval_request = request
357
+ invocation.instance_variable_set(
358
+ :@tool_batch_llm_call_id,
359
+ assistant_record.llm_call_id&.to_s
360
+ )
361
+
362
+ by_id = request.items.to_h do |item|
363
+ [item.tool_invocation_id.to_s, item]
364
+ end
365
+ snapshots = Array(execution.metadata[TOOL_BATCH_METADATA_KEY])
366
+ if snapshots.empty?
367
+ raise Phronomy::ExecutionRehydrationRequiredError,
368
+ "suspended execution #{execution.execution_id} predates the durable Tool batch snapshot"
369
+ end
370
+
371
+ calls_by_id = if assistant_message.tool_calls.respond_to?(:values)
372
+ assistant_message.tool_calls.values.to_h do |call|
373
+ [call.id.to_s, call]
374
+ end
375
+ else
376
+ Array(assistant_message.tool_calls).to_h do |call|
377
+ [call.id.to_s, call]
378
+ end
379
+ end
380
+
381
+ children = snapshots.map do |snapshot|
382
+ entry = snapshot.to_h { |key, value| [key.to_s, value] }
383
+ call = calls_by_id.fetch(entry.fetch("tool_call_id").to_s) do
384
+ raise Phronomy::ExecutionRehydrationRequiredError,
385
+ "Tool Call #{entry.fetch("tool_call_id")} is missing from the durable assistant message"
386
+ end
387
+ tool = chat.tools[entry.fetch("tool_name").to_sym]
388
+ child = if tool
389
+ Phronomy::Agent::ToolInvocation.new(
390
+ execution_id: execution.execution_id,
391
+ agent: agent,
392
+ tool: tool,
393
+ tool_call: call,
394
+ config: config,
395
+ id: entry.fetch("tool_invocation_id")
396
+ )
397
+ else
398
+ Phronomy::Agent::ToolInvocation.missing(
399
+ execution_id: execution.execution_id,
400
+ agent: agent,
401
+ tool_call: call,
402
+ config: config,
403
+ id: entry.fetch("tool_invocation_id")
404
+ )
405
+ end
406
+ restore_tool_snapshot!(child, entry, by_id)
407
+ child
408
+ end
409
+ invocation.tool_invocations = children
410
+ invocation
411
+ end
412
+
413
+ def restore_tool_snapshot!(child, entry, approval_items)
414
+ status = entry.fetch("status").to_sym
415
+ case status
416
+ when :awaiting_approval
417
+ child.validate! unless child.terminal?
418
+ child.instance_variable_set(:@final_decision, :require_approval)
419
+ child.mark_awaiting_approval!
420
+ when :authorized
421
+ child.validate! unless child.terminal?
422
+ child.instance_variable_set(:@final_decision, :allow)
423
+ child.mark_authorized!
424
+ when :completed
425
+ child.instance_variable_set(:@result, entry["result"])
426
+ child.instance_variable_set(:@status, :completed)
427
+ when :rejected
428
+ child.mark_rejected!
429
+ when :failed
430
+ child.mark_framework_failed!(
431
+ Phronomy::ToolError.new("durably restored Tool preflight failure")
432
+ )
433
+ when :cancelled
434
+ child.mark_cancelled!
435
+ else
436
+ raise Phronomy::ExecutionRehydrationRequiredError,
437
+ "unsupported durable Tool snapshot state: #{status.inspect}"
438
+ end
439
+
440
+ item = approval_items[child.id.to_s]
441
+ if item
442
+ child.instance_variable_set(:@facts, Phronomy::Agent::Immutable.copy(item.facts))
443
+ child.instance_variable_set(:@authorization_reason, item.reason)
444
+ end
445
+ child
446
+ end
447
+
448
+ def build_chat_for_recovery(agent, execution, projection, main_coordinator, listener)
449
+ config = {
450
+ execution_id: execution.execution_id,
451
+ phronomy_execution_coordinator: main_coordinator,
452
+ phronomy_runtime_projection: projection
453
+ }
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
+ materializer = Phronomy::Agent::RubyLLMMaterializer.new(
477
+ agent: agent,
478
+ persistence: agent.persistence
479
+ )
480
+ Array(execution.working_records).each do |record|
481
+ next unless %i[assistant_message tool_message].include?(record.kind.to_sym)
482
+
483
+ chat.messages << materializer.materialize_journal_record(record)
484
+ end
485
+
486
+ invocation.chat = chat
487
+ invocation.user_message_sent = true
488
+ invocation
489
+ end
490
+
491
+ def provider_output_and_usage(agent, execution)
492
+ assistant = latest_assistant_record(execution)
493
+ payload = assistant ? agent.persistence.contents.fetch_json(assistant.content_ref) : {}
494
+ output = payload["content"]
495
+
496
+ call = execution.llm_calls.last
497
+ usage_hash = if call&.usage_ref
498
+ agent.persistence.contents.fetch_json(call.usage_ref)
499
+ else
500
+ {}
501
+ end
502
+ usage = Phronomy::TokenUsage.new(
503
+ input: usage_hash["input"] || usage_hash[:input],
504
+ output: usage_hash["output"] || usage_hash[:output],
505
+ cached: usage_hash["cached"] || usage_hash[:cached],
506
+ cache_creation: usage_hash["cache_creation"] || usage_hash[:cache_creation]
507
+ )
508
+ [output, usage]
509
+ end
510
+ end
511
+ end
512
+ 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,27 @@ 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
+ )
106
104
  end
107
- tool_set
105
+
106
+ expected_definitions = fetch_json(manifest.tool_definitions_ref)
107
+ additional = @additional_tools
108
+ if additional.empty? && defined?(Phronomy::MultiAgent::HandoffCapabilityFactory)
109
+ ordinary_names = ToolDefinitionSet.build(@agent).definitions
110
+ .map { |definition| definition.fetch("name") }
111
+ additional = Array(expected_definitions).filter_map do |definition|
112
+ name = definition.fetch("name")
113
+ next if ordinary_names.include?(name)
114
+ Phronomy::MultiAgent::HandoffCapabilityFactory.lookup(name)&.tool_class
115
+ end
116
+ end
117
+
118
+ ToolDefinitionSet.build(@agent, additional_tools: additional)
119
+ .select_definitions(expected_definitions)
108
120
  end
109
121
 
110
122
  def fetch_json(content_ref)
@@ -153,9 +165,6 @@ module Phronomy
153
165
  model_id: payload["model_id"]
154
166
  )
155
167
 
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
168
  message.content = payload["content"] if payload.key?("content")
160
169
  message
161
170
  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