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
@@ -13,7 +13,7 @@ module Phronomy
13
13
  class JournalRecord
14
14
  ATTRIBUTES = %i[
15
15
  record_id agent_id sequence execution_id llm_call_id kind channel role content_ref
16
- parent_id causation_id correlation_id visibility context_generation
16
+ parent_id causation_id visibility context_generation
17
17
  context_candidate occurred_at metadata
18
18
  ].freeze
19
19
 
@@ -31,7 +31,6 @@ module Phronomy
31
31
  content_ref: nil,
32
32
  parent_id: nil,
33
33
  causation_id: nil,
34
- correlation_id: nil,
35
34
  visibility: :agent,
36
35
  context_generation: 0,
37
36
  context_candidate: false,
@@ -52,14 +51,28 @@ module Phronomy
52
51
  self.class.from_h(to_h.merge("sequence" => sequence))
53
52
  end
54
53
 
54
+ # Current semantic payload representation. Persistence format identity and
55
+ # compatibility validation are owned by Persistence::DurableCodec.
55
56
  def to_h
56
57
  ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
57
58
  end
58
59
 
60
+ # Historical durable fields are not accepted here. Explicit migration must
61
+ # convert them to the current semantic payload first.
59
62
  def self.from_h(hash)
63
+ source = hash.to_h { |key, value| [key.to_s, value] }
64
+ expected = ATTRIBUTES.map(&:to_s).sort
65
+ actual = source.keys.sort
66
+ unless actual == expected
67
+ missing = expected - actual
68
+ unknown = actual - expected
69
+ raise ArgumentError,
70
+ "JournalRecord payload schema mismatch: " \
71
+ "missing=#{missing.inspect}, unknown=#{unknown.inspect}"
72
+ end
73
+
60
74
  new(**ATTRIBUTES.to_h do |name|
61
- key = hash.key?(name.to_s) ? name.to_s : name
62
- [name, hash[key]]
75
+ [name, source.fetch(name.to_s)]
63
76
  end)
64
77
  end
65
78
  end
@@ -5,7 +5,7 @@ module Phronomy
5
5
  # Immutable metadata passed to before_llm_input hooks. Runtime objects such
6
6
  # as RubyLLM::Chat, messages and the mutable Agent instance are not exposed.
7
7
  LLMInputBuildContext = Data.define(
8
- :agent_id, :agent_definition_id, :definition_version,
8
+ :agent_id, :agent_definition_id, :agent_definition_version,
9
9
  :config, :call_sequence
10
10
  ) do
11
11
  def initialize(**values)
@@ -2,19 +2,98 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
+ # ContentStore-backed durable codec boundary for one Provider input manifest.
6
+ # It is intentionally separate from Persistence::DurableRecord.
5
7
  class LLMInputManifest
6
- VERSION = 1
8
+ VERSION = "0.1"
7
9
  CALL_MODES = %i[ask complete].freeze
10
+ SEGMENT_DELIVERIES = %w[ask_argument chat_message].freeze
8
11
 
9
12
  Segment = Data.define(
10
13
  :position, :category, :role, :content_ref, :delivery,
11
14
  :tool_call_id, :metadata
12
15
  ) do
13
16
  def initialize(**values)
17
+ delivery = values[:delivery]
18
+ if delivery.nil? || !SEGMENT_DELIVERIES.include?(delivery.to_s)
19
+ raise ArgumentError, "unknown Segment delivery: #{delivery.inspect}"
20
+ end
14
21
  super(**values.merge(metadata: Immutable.copy(values[:metadata] || {})))
15
22
  freeze
16
23
  end
17
24
 
25
+ def self.from_h(hash)
26
+ source = LLMInputManifest.send(
27
+ :strict_source!,
28
+ hash,
29
+ required: %w[position category content_ref delivery metadata],
30
+ optional: %w[role tool_call_id],
31
+ label: "LLMInputManifest segment"
32
+ )
33
+ label = "LLMInputManifest segment"
34
+ position = LLMInputManifest.send(
35
+ :require_nonnegative_integer_field!,
36
+ source,
37
+ "position",
38
+ label: label
39
+ )
40
+ category = LLMInputManifest.send(
41
+ :require_nonempty_string_field!,
42
+ source,
43
+ "category",
44
+ label: label
45
+ )
46
+ role = LLMInputManifest.send(
47
+ :require_optional_string_field!,
48
+ source,
49
+ "role",
50
+ label: label
51
+ )
52
+ content_ref = LLMInputManifest.send(
53
+ :require_nonempty_string_field!,
54
+ source,
55
+ "content_ref",
56
+ label: label
57
+ )
58
+ delivery = LLMInputManifest.send(
59
+ :require_nonempty_string_field!,
60
+ source,
61
+ "delivery",
62
+ label: label
63
+ )
64
+ unless SEGMENT_DELIVERIES.include?(delivery)
65
+ raise Phronomy::Persistence::SerializationError,
66
+ "LLMInputManifest segment delivery must be one of #{SEGMENT_DELIVERIES.inspect}, got #{delivery.inspect}"
67
+ end
68
+ tool_call_id = LLMInputManifest.send(
69
+ :require_optional_string_field!,
70
+ source,
71
+ "tool_call_id",
72
+ label: label
73
+ )
74
+ metadata = LLMInputManifest.send(
75
+ :require_hash_field!,
76
+ source,
77
+ "metadata",
78
+ label: label
79
+ )
80
+
81
+ new(
82
+ position: position,
83
+ category: category.to_sym,
84
+ role: role&.to_sym,
85
+ content_ref: content_ref,
86
+ delivery: delivery.to_sym,
87
+ tool_call_id: tool_call_id,
88
+ metadata: metadata
89
+ )
90
+ rescue Phronomy::Persistence::SerializationError
91
+ raise
92
+ rescue => error
93
+ raise Phronomy::Persistence::SerializationError,
94
+ "invalid LLMInputManifest segment: #{error.class}: #{error.message}"
95
+ end
96
+
18
97
  def to_h
19
98
  {
20
99
  "position" => position,
@@ -28,6 +107,15 @@ module Phronomy
28
107
  end
29
108
  end
30
109
 
110
+ REQUIRED_KEYS = %w[
111
+ version call_sequence call_mode assembly_policy_version segments
112
+ model_config_ref
113
+ ].freeze
114
+ OPTIONAL_KEYS = %w[
115
+ tool_definitions_ref response_schema_ref ruby_llm_version
116
+ adapter_name adapter_version
117
+ ].freeze
118
+
31
119
  attr_reader :version, :call_sequence, :call_mode,
32
120
  :assembly_policy_version, :segments,
33
121
  :model_config_ref, :tool_definitions_ref, :response_schema_ref,
@@ -46,7 +134,7 @@ module Phronomy
46
134
  adapter_version: nil,
47
135
  version: VERSION
48
136
  )
49
- @version = Integer(version)
137
+ @version = String(version).freeze
50
138
  @call_sequence = Integer(call_sequence)
51
139
  @call_mode = call_mode.to_sym
52
140
  @assembly_policy_version = Integer(assembly_policy_version)
@@ -61,6 +149,93 @@ module Phronomy
61
149
  freeze
62
150
  end
63
151
 
152
+ # Current-format-only durable decoder. Historical format conversion is an
153
+ # explicit migration operation and is never attempted here. Unlike the
154
+ # Ruby construction API, this decoder never coerces durable field types.
155
+ def self.from_h(hash)
156
+ source = strict_source!(
157
+ hash,
158
+ required: REQUIRED_KEYS,
159
+ optional: OPTIONAL_KEYS,
160
+ label: "LLMInputManifest"
161
+ )
162
+ label = "LLMInputManifest"
163
+ version = source.fetch("version")
164
+ unless version.is_a?(String) && version == VERSION
165
+ raise Phronomy::Persistence::SerializationError,
166
+ "unsupported LLMInputManifest version: #{version.inspect}; " \
167
+ "current version is #{VERSION.inspect}"
168
+ end
169
+
170
+ call_sequence = require_positive_integer_field!(
171
+ source,
172
+ "call_sequence",
173
+ label: label
174
+ )
175
+ call_mode = require_enum_string_field!(
176
+ source,
177
+ "call_mode",
178
+ CALL_MODES.map(&:to_s),
179
+ label: label
180
+ )
181
+ assembly_policy_version = require_integer_field!(
182
+ source,
183
+ "assembly_policy_version",
184
+ label: label
185
+ )
186
+ segments = require_array_field!(source, "segments", label: label)
187
+ .map { |segment| Segment.from_h(segment) }
188
+ model_config_ref = require_nonempty_string_field!(
189
+ source,
190
+ "model_config_ref",
191
+ label: label
192
+ )
193
+ tool_definitions_ref = require_optional_string_field!(
194
+ source,
195
+ "tool_definitions_ref",
196
+ label: label
197
+ )
198
+ response_schema_ref = require_optional_string_field!(
199
+ source,
200
+ "response_schema_ref",
201
+ label: label
202
+ )
203
+ ruby_llm_version = require_optional_string_field!(
204
+ source,
205
+ "ruby_llm_version",
206
+ label: label
207
+ )
208
+ adapter_name = require_optional_string_field!(
209
+ source,
210
+ "adapter_name",
211
+ label: label
212
+ )
213
+ adapter_version = require_optional_string_field!(
214
+ source,
215
+ "adapter_version",
216
+ label: label
217
+ )
218
+
219
+ new(
220
+ version: version,
221
+ call_sequence: call_sequence,
222
+ call_mode: call_mode.to_sym,
223
+ assembly_policy_version: assembly_policy_version,
224
+ segments: segments,
225
+ model_config_ref: model_config_ref,
226
+ tool_definitions_ref: tool_definitions_ref,
227
+ response_schema_ref: response_schema_ref,
228
+ ruby_llm_version: ruby_llm_version,
229
+ adapter_name: adapter_name,
230
+ adapter_version: adapter_version
231
+ )
232
+ rescue Phronomy::Persistence::SerializationError
233
+ raise
234
+ rescue => error
235
+ raise Phronomy::Persistence::SerializationError,
236
+ "invalid LLMInputManifest: #{error.class}: #{error.message}"
237
+ end
238
+
64
239
  def referenced_content_refs
65
240
  ([model_config_ref, tool_definitions_ref, response_schema_ref] +
66
241
  segments.map(&:content_ref)).compact.uniq.freeze
@@ -82,9 +257,109 @@ module Phronomy
82
257
  }.compact
83
258
  end
84
259
 
260
+ class << self
261
+ private
262
+
263
+ def strict_source!(hash, required:, optional:, label:)
264
+ unless hash.is_a?(Hash)
265
+ raise Phronomy::Persistence::SerializationError,
266
+ "#{label} must be a Hash"
267
+ end
268
+ unless hash.keys.all? { |key| key.is_a?(String) }
269
+ raise Phronomy::Persistence::SerializationError,
270
+ "#{label} keys must all be String"
271
+ end
272
+
273
+ actual = hash.keys
274
+ missing = required - actual
275
+ unknown = actual - (required + optional)
276
+ unless missing.empty? && unknown.empty?
277
+ details = []
278
+ details << "missing=#{missing.inspect}" unless missing.empty?
279
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
280
+ raise Phronomy::Persistence::SerializationError,
281
+ "#{label} schema mismatch (#{details.join(", ")})"
282
+ end
283
+ Phronomy::CanonicalJSON.dump(hash)
284
+ hash
285
+ rescue ArgumentError => error
286
+ raise Phronomy::Persistence::SerializationError,
287
+ "#{label} is not canonical JSON compatible: #{error.message}"
288
+ end
289
+
290
+ def require_integer_field!(hash, key, label:)
291
+ value = hash.fetch(key)
292
+ return value if value.is_a?(Integer)
293
+
294
+ raise Phronomy::Persistence::SerializationError,
295
+ "#{label} #{key} must be an Integer"
296
+ end
297
+
298
+ def require_positive_integer_field!(hash, key, label:)
299
+ value = hash.fetch(key)
300
+ return value if value.is_a?(Integer) && value.positive?
301
+
302
+ raise Phronomy::Persistence::SerializationError,
303
+ "#{label} #{key} must be a positive Integer"
304
+ end
305
+
306
+ def require_nonnegative_integer_field!(hash, key, label:)
307
+ value = hash.fetch(key)
308
+ return value if value.is_a?(Integer) && value >= 0
309
+
310
+ raise Phronomy::Persistence::SerializationError,
311
+ "#{label} #{key} must be a non-negative Integer"
312
+ end
313
+
314
+ def require_nonempty_string_field!(hash, key, label:)
315
+ value = hash.fetch(key)
316
+ return value if value.is_a?(String) && !value.empty?
317
+
318
+ raise Phronomy::Persistence::SerializationError,
319
+ "#{label} #{key} must be a non-empty String"
320
+ end
321
+
322
+ def require_optional_string_field!(hash, key, label:)
323
+ value = hash[key]
324
+ return value if value.nil? || value.is_a?(String)
325
+
326
+ raise Phronomy::Persistence::SerializationError,
327
+ "#{label} #{key} must be a String or nil"
328
+ end
329
+
330
+ def require_enum_string_field!(hash, key, allowed, label:)
331
+ value = hash.fetch(key)
332
+ return value if value.is_a?(String) && allowed.include?(value)
333
+
334
+ raise Phronomy::Persistence::SerializationError,
335
+ "#{label} #{key} must be one of #{allowed.inspect}"
336
+ end
337
+
338
+ def require_array_field!(hash, key, label:)
339
+ value = hash.fetch(key)
340
+ return value if value.is_a?(Array)
341
+
342
+ raise Phronomy::Persistence::SerializationError,
343
+ "#{label} #{key} must be an Array"
344
+ end
345
+
346
+ def require_hash_field!(hash, key, label:)
347
+ value = hash.fetch(key)
348
+ return value if value.is_a?(Hash)
349
+
350
+ raise Phronomy::Persistence::SerializationError,
351
+ "#{label} #{key} must be a Hash"
352
+ end
353
+ end
354
+
85
355
  private
86
356
 
87
357
  def validate!
358
+ unless version == VERSION
359
+ raise Phronomy::ConfigurationError,
360
+ "unsupported LLMInputManifest version: #{version.inspect}; " \
361
+ "current version is #{VERSION.inspect}"
362
+ end
88
363
  raise ArgumentError, "invalid manifest call mode: #{call_mode.inspect}" unless CALL_MODES.include?(call_mode)
89
364
  raise ArgumentError, "call_sequence must be positive" unless call_sequence.positive?
90
365
  expected = (0...segments.length).to_a
@@ -2,20 +2,25 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Immutable carrier for one LLM adapter operation outcome.
5
+ # Immutable carrier for one Provider operation outcome.
6
6
  #
7
- # Worker/timer threads create this value without mutating AgentInvocation.
8
- # AgentInvocationSessionBuilder posts it as :llm_completed or :llm_failed,
9
- # and AgentInvocation applies it on the EventLoop thread.
7
+ # The Provider Call identity is allocated on EventLoop before transport starts.
8
+ # Worker completion carries that semantic identity back to the owning
9
+ # FSMSession, which validates it against the currently active call before
10
+ # applying any live state.
10
11
  #
11
12
  # @api private
12
13
  class LLMOperationResult
13
- attr_reader :response, :error, :streaming
14
+ attr_reader :llm_call_id, :response, :error, :streaming
14
15
 
15
- def initialize(response: nil, error: nil, streaming: false)
16
+ def initialize(llm_call_id:, response: nil, error: nil, streaming: false)
17
+ id = llm_call_id&.to_s
18
+ raise ArgumentError, "LLMOperationResult requires llm_call_id" if id.nil? || id.empty?
19
+
20
+ @llm_call_id = id.freeze
16
21
  @response = response
17
22
  @error = error
18
- @streaming = streaming
23
+ @streaming = !!streaming
19
24
  freeze
20
25
  end
21
26
  end
@@ -4,12 +4,6 @@ require "state_machines"
4
4
 
5
5
  module Phronomy
6
6
  module Agent
7
- # Compiles AgentInvocation phase topology.
8
- #
9
- # Async completion is represented by explicit FSM events. This builder does
10
- # not await Tasks or register Task callbacks.
11
- #
12
- # @api private
13
7
  class PhaseMachineBuilder
14
8
  TOOL_EVENTS = %i[
15
9
  tool_authorized
@@ -43,6 +37,7 @@ module Phronomy
43
37
  state :recording_tool_results
44
38
  state :suspended
45
39
  state :output_filtering
40
+ state :handed_off
46
41
  state :completed
47
42
  state :blocked
48
43
  state :failed
@@ -70,7 +65,6 @@ module Phronomy
70
65
  if: ->(machine) { machine.context&.ready_to_dispatch? }
71
66
  transition evaluating_tools: :waiting_for_tools
72
67
 
73
- transition dispatching_tools: :evaluating_tools
74
68
  transition recording_tool_results: :calling_llm
75
69
 
76
70
  transition output_filtering: :completed,
@@ -80,6 +74,12 @@ module Phronomy
80
74
  end
81
75
 
82
76
  event :llm_completed do
77
+ transition calling_llm: :failed,
78
+ if: ->(machine) { machine.context&.callback_failed? }
79
+ transition calling_llm: :failed,
80
+ if: ->(machine) { machine.context&.handoff_failed? }
81
+ transition calling_llm: :handed_off,
82
+ if: ->(machine) { machine.context&.handoff_requested? }
83
83
  transition calling_llm: :starting_tools,
84
84
  if: ->(machine) { machine.context&.tool_call_pending? }
85
85
  transition calling_llm: :output_filtering
@@ -89,6 +89,18 @@ module Phronomy
89
89
  transition calling_llm: :failed
90
90
  end
91
91
 
92
+ event :llm_setup_failed do
93
+ transition calling_llm: :failed
94
+ end
95
+
96
+ event :tool_setup_failed do
97
+ transition dispatching_tools: :failed
98
+ end
99
+
100
+ event :tool_dispatch_prepared do
101
+ transition dispatching_tools: :evaluating_tools
102
+ end
103
+
92
104
  TOOL_EVENTS.each do |event_name|
93
105
  event event_name do
94
106
  transition waiting_for_tools: :evaluating_tools
@@ -3,11 +3,10 @@
3
3
  module Phronomy
4
4
  module Agent
5
5
  # Immutable, Phronomy-owned snapshot of one completed Provider assistant output.
6
- # It is captured before Agent-owned Tool execution begins, so durable logging
7
- # never depends on RubyLLM's later control flow or Application callbacks.
8
6
  ProviderCallOutcome = Data.define(:role, :content, :tool_calls, :usage, :metadata) do
9
7
  def self.capture(message)
10
8
  return if message.nil?
9
+ return message if message.is_a?(self)
11
10
 
12
11
  calls = if message.respond_to?(:tool_calls) && message.tool_calls
13
12
  source = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
@@ -32,6 +31,17 @@ module Phronomy
32
31
  )
33
32
  end
34
33
 
34
+ def self.from_h(hash)
35
+ source = hash.to_h { |key, value| [key.to_s, value] }
36
+ new(
37
+ role: source.fetch("role", "assistant"),
38
+ content: source["content"],
39
+ tool_calls: source.fetch("tool_calls", []),
40
+ usage: source.fetch("usage", {}),
41
+ metadata: source.fetch("metadata", {})
42
+ )
43
+ end
44
+
35
45
  def self.tool_call_hash(call)
36
46
  return call.to_h if call.respond_to?(:to_h)
37
47
 
@@ -54,11 +64,7 @@ module Phronomy
54
64
  when String, Integer, Float, TrueClass, FalseClass, NilClass
55
65
  value
56
66
  else
57
- if value.respond_to?(:to_h)
58
- normalize(value.to_h)
59
- else
60
- value.to_s
61
- end
67
+ value.respond_to?(:to_h) ? normalize(value.to_h) : value.to_s
62
68
  end
63
69
  end
64
70
  private_class_method :normalize
@@ -78,6 +84,16 @@ module Phronomy
78
84
  freeze
79
85
  end
80
86
 
87
+ def to_h
88
+ {
89
+ "role" => role&.to_s,
90
+ "content" => content,
91
+ "tool_calls" => tool_calls,
92
+ "usage" => usage,
93
+ "metadata" => metadata
94
+ }.compact
95
+ end
96
+
81
97
  def content_present?
82
98
  !content.nil? && !(content.respond_to?(:empty?) && content.empty?)
83
99
  end