phronomy 0.28.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +32 -6
  4. data/docs/application-recipes.md +229 -0
  5. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  6. data/docs/architecture/agent-iteration-budget.md +81 -0
  7. data/docs/architecture/dx05-agent-scope.md +126 -0
  8. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  9. data/docs/architecture/error-api-review.md +245 -0
  10. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  11. data/docs/architecture/generator-verifier-ownership.md +18 -109
  12. data/docs/architecture/llm-continuation-metadata.md +88 -0
  13. data/docs/architecture/lm-studio-output-failures.md +111 -0
  14. data/docs/architecture/r8-unit15.md +3 -0
  15. data/docs/architecture/r8-unit7.md +2 -1
  16. data/docs/architecture/structured-output-and-scope.md +183 -0
  17. data/docs/architecture/tracing.md +22 -4
  18. data/docs/architecture/transport-error-boundaries.md +103 -0
  19. data/docs/architecture/trust-acceptance-followup.md +87 -0
  20. data/docs/failure-reason-codes.md +45 -0
  21. data/docs/features.md +14 -2
  22. data/docs/getting-started.md +44 -43
  23. data/docs/migrations/r8-unit15.md +3 -0
  24. data/docs/ordered-event-delivery.md +54 -0
  25. data/docs/workflow-completion.md +92 -0
  26. data/gemfiles/http_minimum.gemfile +10 -0
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  29. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  30. data/lib/phronomy/agent/api/agent.rb +13 -0
  31. data/lib/phronomy/agent/async_event_api.rb +121 -88
  32. data/lib/phronomy/agent/base.rb +82 -44
  33. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  34. data/lib/phronomy/agent/context_importer.rb +4 -0
  35. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  36. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  37. data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
  38. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  39. data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
  40. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  41. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  42. data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
  43. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  44. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  45. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  46. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
  47. data/lib/phronomy/agent/execution_observation.rb +5 -5
  48. data/lib/phronomy/agent/filterable.rb +21 -15
  49. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  50. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  51. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  52. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  53. data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
  54. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
  55. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  56. data/lib/phronomy/agent/output_contract.rb +64 -0
  57. data/lib/phronomy/agent/output_error.rb +53 -0
  58. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
  59. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
  60. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  61. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
  62. data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
  63. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  64. data/lib/phronomy/agent/runtime_input.rb +15 -4
  65. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  66. data/lib/phronomy/agent/store.rb +10 -2
  67. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  68. data/lib/phronomy/common/error.rb +65 -1
  69. data/lib/phronomy/common/values/immutable.rb +33 -0
  70. data/lib/phronomy/content_store/base.rb +7 -2
  71. data/lib/phronomy/context/assembly.rb +18 -22
  72. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  73. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
  74. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  75. data/lib/phronomy/engine/fsm_session.rb +16 -1
  76. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  77. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  78. data/lib/phronomy/execution/task_result.rb +7 -2
  79. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  80. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  81. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  82. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  83. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  84. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  85. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  86. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  87. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  88. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  89. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  90. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  91. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  92. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  93. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  94. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  95. data/lib/phronomy/llm_adapter/message.rb +4 -0
  96. data/lib/phronomy/llm_adapter/request.rb +17 -2
  97. data/lib/phronomy/llm_adapter/response.rb +11 -4
  98. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  99. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  100. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  101. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  102. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  103. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  104. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  105. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  106. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  107. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  108. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  109. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  110. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  111. data/lib/phronomy/tool/base.rb +25 -23
  112. data/lib/phronomy/tracing/automatic.rb +14 -8
  113. data/lib/phronomy/tracing/base.rb +37 -7
  114. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  115. data/lib/phronomy/tracing/observation.rb +2 -1
  116. data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
  117. data/lib/phronomy/version.rb +1 -1
  118. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  119. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  120. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  121. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  122. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  123. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  124. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  125. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  126. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  127. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  128. data/lib/phronomy.rb +7 -2
  129. data/scripts/api_snapshot.rb +15 -0
  130. data/scripts/check_readme_ruby.rb +24 -29
  131. data/scripts/check_readme_runnable.rb +92 -81
  132. data/scripts/migrate_generator_verifier_result.rb +18 -0
  133. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  134. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  135. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  136. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  137. data/sig/phronomy/agent.rbs +36 -5
  138. data/sig/phronomy/embeddings/base.rbs +6 -0
  139. data/sig/phronomy/execution/execution.rbs +2 -2
  140. data/sig/phronomy/extensions.rbs +8 -2
  141. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  142. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  143. data/sig/phronomy/integrations.rbs +14 -0
  144. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  145. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  146. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  147. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  148. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  149. data/sig/phronomy/storage/contracts.rbs +2 -2
  150. data/sig/phronomy/tracing/observation.rbs +1 -1
  151. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  152. data/sig/phronomy/workflow.rbs +4 -0
  153. data/sig/phronomy.rbs +6 -0
  154. metadata +103 -5
  155. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  156. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  157. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Agent-owned two-phase release. The worker only checks durable facts; the
6
+ # EventLoop completes the exact process-local reservation after physical I/O.
7
+ # @api private
8
+ class UnloadTransition
9
+ Command = Data.define(:coordinator, :phase)
10
+ private_constant :Command
11
+
12
+ attr_reader :result
13
+
14
+ def initialize(agent:, environment:, ownership:)
15
+ @agent, @environment, @ownership = agent, environment, ownership
16
+ @result = Phronomy::TaskResult.deferred(name: "agent-unload:#{agent.agent_id}")
17
+ @mutex = Mutex.new
18
+ @finished = false
19
+ end
20
+
21
+ def start
22
+ post(:check)
23
+ @result
24
+ end
25
+
26
+ def deliver_on_event_loop(command)
27
+ if command.phase == :check
28
+ if @environment.existing_registry&.agent_execution_admitted?(@agent.agent_id)
29
+ raise Phronomy::AgentBusyError, "Agent #{@agent.agent_id.inspect} has a nonterminal execution"
30
+ end
31
+ agent_id, persistence = @agent.agent_id, @agent.persistence
32
+ task = @environment.submit(on_full: :raise) do
33
+ persistence.transaction do |tx|
34
+ tx.executions.assert_idle!(agent_id)
35
+ tx.guard_agent!(agent_id)
36
+ unless tx.retentions.list(agent_id).empty?
37
+ raise Phronomy::AgentBusyError, "Agent #{agent_id.inspect} is retained by durable coordination"
38
+ end
39
+ end
40
+ end
41
+ task.on_complete do |_value, error|
42
+ @read_error = error
43
+ task.on_physical_complete { post(:apply) }
44
+ end
45
+ else
46
+ finish(@read_error)
47
+ end
48
+ rescue => error
49
+ finish(error)
50
+ end
51
+
52
+ private
53
+
54
+ def post(phase)
55
+ accepted = @environment.registry.post(Command.new(coordinator: self, phase: phase), completion: @result)
56
+ finish(Phronomy::RuntimeShutdownError.new("EventLoop rejected Agent unload #{phase}")) unless accepted
57
+ rescue => error
58
+ finish(error)
59
+ end
60
+
61
+ def finish(error)
62
+ first = @mutex.synchronize do
63
+ next false if @finished
64
+ @finished = true
65
+ end
66
+ return unless first
67
+
68
+ begin
69
+ @ownership.finish_unload(@agent, self, success: error.nil?)
70
+ rescue => ownership_error
71
+ error ||= ownership_error
72
+ end
73
+ @agent = @environment = @ownership = nil
74
+ error ? @result.fail(error) : @result.complete(true)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Owns the execution's output promise; generic schema interpretation is injected.
8
+ # @api private
9
+ class OutputContract
10
+ FACTORIES = {}
11
+ private_constant :FACTORIES
12
+ attr_reader :schema, :digest
13
+
14
+ def self.install_factory(factory)
15
+ FACTORIES.replace(parser: factory).freeze
16
+ end
17
+
18
+ def initialize(schema)
19
+ @parser = FACTORIES.fetch(:parser).call(schema)
20
+ @schema = @parser.json_schema
21
+ @digest = Digest::SHA256.hexdigest(JSON.generate(@schema, sort_keys: true)).freeze
22
+ freeze
23
+ end
24
+
25
+ def metadata(contents)
26
+ {"output_contract" => {"version" => 1, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)), "schema_digest" => digest}}
27
+ end
28
+
29
+ def self.restore(execution, contents)
30
+ stored = execution.metadata["output_contract"]
31
+ return nil unless stored
32
+ raise ArgumentError, "unsupported Agent output contract version" unless stored.fetch("version") == 1
33
+ contract = new(contents.fetch_json(stored.fetch("schema_ref")))
34
+ unless contract.digest == stored.fetch("schema_digest")
35
+ raise Phronomy::ContentStore::IntegrityError, "Agent output schema digest mismatch"
36
+ end
37
+ contract
38
+ end
39
+
40
+ def validate(output, response:, execution_id:)
41
+ raw = response&.content
42
+ reason = if response&.refusal || response&.finish_reason == :refusal
43
+ "model_refusal"
44
+ elsif !response || !%i[stop].include?(response.finish_reason) || !response.tool_calls.empty?
45
+ %i[length content_filter].include?(response&.finish_reason) ? response.finish_reason.to_s : "unknown_finish_reason"
46
+ elsif output.nil? || raw.nil?
47
+ "missing_output"
48
+ end
49
+ if reason
50
+ raise OutputError.new(execution_id: execution_id, schema_digest: digest, reason: reason, raw_output: raw)
51
+ end
52
+ @parser.parse(output)
53
+ rescue Phronomy::ParseError => error
54
+ reason = case error.code
55
+ when "output_parser.invalid_json" then "invalid_json"
56
+ when "output_parser.schema_mismatch" then "schema_mismatch"
57
+ else raise
58
+ end
59
+ raise OutputError.new(execution_id: execution_id, schema_digest: digest, reason: reason,
60
+ violations: error.details, raw_output: raw), cause: error
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # A declared output contract could not be fulfilled. Tool effects remain facts.
6
+ # @api public
7
+ class OutputError < Phronomy::Error
8
+ CODES = {"invalid_json" => "agent.output_invalid", "schema_mismatch" => "agent.output_invalid",
9
+ "missing_output" => "agent.output_invalid", "model_refusal" => "agent.output_refused",
10
+ "length" => "agent.output_incomplete", "content_filter" => "agent.output_incomplete",
11
+ "unknown_finish_reason" => "agent.output_incomplete"}.freeze
12
+ private_constant :CODES
13
+
14
+ # @api public
15
+ attr_reader :execution_id, :schema_digest, :reason, :violations, :raw_output
16
+
17
+ # @api public
18
+ def initialize(execution_id:, schema_digest:, reason:, violations: [], raw_output: nil)
19
+ @execution_id = execution_id.to_s.dup.freeze
20
+ @schema_digest = schema_digest.to_s.dup.freeze
21
+ @reason = reason.to_s.dup.freeze
22
+ @violations = Phronomy::Values::Immutable.json(violations)
23
+ @raw_output = Phronomy::Values::Immutable.copy(raw_output)
24
+ super("Agent output contract failed: #{@reason}", code: CODES.fetch(@reason))
25
+ end
26
+
27
+ # Owner-defined facts for persistence and composition; no live exception values.
28
+ # @api private
29
+ def diagnostic(include_raw: true)
30
+ value = Phronomy::Error.diagnostic(self).merge("execution_id" => execution_id,
31
+ "schema_digest" => schema_digest, "reason" => reason, "violations" => violations)
32
+ value["raw_output"] = raw_output if include_raw
33
+ Phronomy::Values::Immutable.json(value)
34
+ end
35
+
36
+ # @api private
37
+ def self.restore(value)
38
+ error = new(execution_id: value.fetch("execution_id"), schema_digest: value.fetch("schema_digest"),
39
+ reason: value.fetch("reason"), violations: value.fetch("violations"), raw_output: value["raw_output"])
40
+ raise ArgumentError, "invalid Agent output diagnostic code" unless error.code == value.fetch("code")
41
+ if value["cause"]
42
+ begin
43
+ raise error, cause: Phronomy::Error.from_diagnostic(value.fetch("cause"))
44
+ rescue OutputError => restored
45
+ restored.set_backtrace(nil)
46
+ return restored
47
+ end
48
+ end
49
+ error
50
+ end
51
+ end
52
+ end
53
+ end
@@ -39,9 +39,9 @@ module Phronomy
39
39
  materializer = RuntimeInput.new(agent: agent, persistence: agent.persistence)
40
40
  messages = records.map { |record| materializer.materialize_journal_record(record) }.freeze
41
41
  assistant = messages.reverse.find { |message| message.role.to_sym == :assistant }
42
- output, usage = SavedContextReader.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
42
+ output, usage, response = SavedContextReader.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
43
43
  RecoveryMaterial.new(projection: projection, messages: messages,
44
- assistant_message: assistant, output: output, usage: usage,
44
+ assistant_message: assistant, output: output, usage: usage, response: response,
45
45
  cancellation_requested: agent.persistence.cancellation_requested?(agent_id: agent.agent_id, execution_id: execution.execution_id))
46
46
  end
47
47
 
@@ -52,9 +52,9 @@ module Phronomy
52
52
  environment.registry.mark_agent_execution_admission(agent.agent_id,
53
53
  execution_id: execution.execution_id, state: :recovery_required)
54
54
  deliver_resolution_required(execution, coordination_recovery_descriptor(execution))
55
- completion.complete({execution_id: execution.execution_id,
56
- execution_revision: execution.execution_revision,
57
- recovery: :resolution_required}.freeze)
55
+ agent.send(:_complete_result_task, completion, {execution_id: execution.execution_id,
56
+ execution_revision: execution.execution_revision,
57
+ recovery: :resolution_required}.freeze)
58
58
  return
59
59
  end
60
60
 
@@ -73,7 +73,7 @@ module Phronomy
73
73
  agent.send(:_phronomy_event_listener), messages: material.messages, cancellation_requested: material.cancellation_requested)
74
74
  end
75
75
  if execution.phase.to_sym == :recovery_provider_completed
76
- invocation.output, invocation.usage = material.output, material.usage
76
+ invocation.apply_llm_response!(material.response)
77
77
  end
78
78
  prepare_saved_provider_calls(execution, invocation, material.assistant_message) if action == :framework_calls
79
79
  end
@@ -38,7 +38,7 @@ module Phronomy
38
38
  end
39
39
 
40
40
  def prepare_plan(execution)
41
- root = agent.agent_root
41
+ root = agent.send(:__root_for_operation)
42
42
  manifest_ref = execution.metadata["manifest_ref"]
43
43
  base_ref = execution.metadata["base_manifest_ref"] ||
44
44
  manifest_ref
@@ -209,7 +209,7 @@ module Phronomy
209
209
  }
210
210
  )
211
211
  end
212
- command.completion.complete(agent)
212
+ agent.send(:_complete_result_task, command.completion, agent)
213
213
  return
214
214
  end
215
215
 
@@ -246,7 +246,7 @@ module Phronomy
246
246
  result: payload
247
247
  )
248
248
  end
249
- command.completion.complete(agent)
249
+ agent.send(:_complete_result_task, command.completion, agent)
250
250
  when Phronomy::Agent::RecoveryRules::RESUMABLE
251
251
  continue_resumable_on_event_loop(
252
252
  execution,
@@ -288,7 +288,7 @@ module Phronomy
288
288
  end
289
289
  end
290
290
  end
291
- command.completion.fail(caught)
291
+ agent.send(:_fail_result_task, command.completion, caught)
292
292
  # simplecov:enable
293
293
  end
294
294
 
@@ -296,6 +296,7 @@ module Phronomy
296
296
  Phronomy::Tracing::Automatic.observe_task(
297
297
  task,
298
298
  "agent.execution",
299
+ usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
299
300
  agent_id: agent.agent_id,
300
301
  execution_id: execution.execution_id,
301
302
  mode: (
@@ -317,7 +318,7 @@ module Phronomy
317
318
  if framework_batch?(execution)
318
319
  internal = Phronomy::TaskResult.deferred(name: "framework-tool-recovery:#{execution.execution_id}")
319
320
  continue_recovery_on_event_loop(execution, internal, material: material)
320
- completion.complete(agent)
321
+ agent.send(:_complete_result_task, completion, agent)
321
322
  return
322
323
  end
323
324
 
@@ -359,11 +360,11 @@ module Phronomy
359
360
  result_task: internal_task,
360
361
  error: nil
361
362
  ))
362
- completion.complete(agent)
363
+ agent.send(:_complete_result_task, completion, agent)
363
364
  when :recovery_tools_completed, :recovery_provider_completed, :recovery_resolved_failed
364
365
  internal_task = Phronomy::TaskResult.deferred(name: "agent-recovery-auto:#{execution.execution_id}")
365
366
  continue_recovery_on_event_loop(execution, internal_task, material: material)
366
- completion.complete(agent)
367
+ agent.send(:_complete_result_task, completion, agent)
367
368
  else
368
369
  raise Phronomy::ExecutionRehydrationRequiredError,
369
370
  "no automatic continuation for #{execution.phase.inspect}"
@@ -14,24 +14,22 @@ module Phronomy
14
14
  request.execution_id
15
15
  )
16
16
  unless state && state.agent.equal?(agent)
17
- request.completion.fail(
17
+ agent.send(:_fail_result_task, request.completion,
18
18
  Phronomy::ExecutionRehydrationRequiredError.new(
19
19
  "no live recovered execution #{request.execution_id}"
20
- )
21
- )
20
+ ))
22
21
  return
23
22
  end
24
23
 
25
24
  current = state.execution
26
25
  unless current.execution_revision ==
27
26
  request.expected_execution_revision
28
- request.completion.fail(
27
+ agent.send(:_fail_result_task, request.completion,
29
28
  Phronomy::Persistence::StateConflictError.new(
30
29
  "Recovery resolution revision conflict: expected " \
31
30
  "#{request.expected_execution_revision}, actual " \
32
31
  "#{current.execution_revision}"
33
- )
34
- )
32
+ ))
35
33
  return
36
34
  end
37
35
 
@@ -42,28 +40,26 @@ module Phronomy
42
40
  descriptor.fetch(:subject),
43
41
  request.subject
44
42
  )
45
- request.completion.fail(
43
+ agent.send(:_fail_result_task, request.completion,
46
44
  ArgumentError.new(
47
45
  "Recovery subject is not current for execution #{request.execution_id}"
48
- )
49
- )
46
+ ))
50
47
  return
51
48
  end
52
49
 
53
50
  unless Array(
54
51
  descriptor.fetch(:allowed_outcomes)
55
52
  ).map(&:to_sym).include?(request.outcome)
56
- request.completion.fail(
53
+ agent.send(:_fail_result_task, request.completion,
57
54
  ArgumentError.new(
58
55
  "Recovery outcome #{request.outcome.inspect} is not allowed for the current subject"
59
- )
60
- )
56
+ ))
61
57
  return
62
58
  end
63
59
 
64
60
  operation = ResolutionOperation.new(
65
61
  execution: current,
66
- root: agent.agent_root,
62
+ root: agent.send(:__root_for_operation),
67
63
  subject: request.subject,
68
64
  outcome: request.outcome,
69
65
  result: request.result,
@@ -77,6 +73,7 @@ module Phronomy
77
73
  task = @environment.submit(on_full: :raise) do
78
74
  prepare_resolution(operation)
79
75
  end
76
+ request.completion.track(task) if request.completion.respond_to?(:track)
80
77
  task.on_complete do |result, error|
81
78
  ready = ResolveReady.new(
82
79
  coordinator: self,
@@ -86,15 +83,14 @@ module Phronomy
86
83
  error: error
87
84
  )
88
85
  unless post_control(ready)
89
- request.completion.fail(
86
+ agent.send(:_fail_result_task, request.completion,
90
87
  Phronomy::RuntimeShutdownError.new(
91
88
  "EventLoop rejected Recovery resolution apply"
92
- )
93
- )
89
+ ))
94
90
  end
95
91
  end
96
92
  rescue => caught
97
- request.completion.fail(caught)
93
+ agent.send(:_fail_result_task, request.completion, caught)
98
94
  end
99
95
 
100
96
  def perform_resolution(operation)
@@ -498,7 +494,7 @@ module Phronomy
498
494
 
499
495
  continue_recovery_on_event_loop(result.execution, request.completion, material: result.material)
500
496
  rescue => caught
501
- request.completion.fail(caught)
497
+ agent.send(:_fail_result_task, request.completion, caught)
502
498
  end
503
499
  end
504
500
  end
@@ -39,7 +39,7 @@ module Phronomy
39
39
  )
40
40
  ResolutionResult = Data.define(:execution)
41
41
  ResolutionPreparation = Data.define(:execution, :material, :error)
42
- RecoveryMaterial = Data.define(:projection, :messages, :assistant_message, :output, :usage, :cancellation_requested)
42
+ RecoveryMaterial = Data.define(:projection, :messages, :assistant_message, :output, :usage, :response, :cancellation_requested)
43
43
  RecoveryPlan = Data.define(
44
44
  :execution, :root, :manifest, :base_manifest,
45
45
  :projection, :classification, :material
@@ -147,9 +147,8 @@ module Phronomy
147
147
  failure = error_present ?
148
148
  RecoverySupport.resolution_failure(error) : nil
149
149
 
150
- completion = Phronomy::TaskResult.deferred(
151
- name: "agent-recovery-resolve:#{execution_id}"
152
- )
150
+ completion = OperationResult.new(agent: agent,
151
+ name: "agent-recovery-resolve:#{execution_id}")
153
152
  command = ResolveCommand.new(
154
153
  coordinator: self,
155
154
  execution_id: execution_id.to_s.freeze,
@@ -162,18 +161,15 @@ module Phronomy
162
161
  completion: completion
163
162
  )
164
163
  unless post_control(command)
165
- completion.fail(
166
- Phronomy::RuntimeShutdownError.new(
167
- "EventLoop rejected Agent Recovery resolution"
168
- )
169
- )
164
+ agent.send(:_fail_result_task, completion,
165
+ Phronomy::RuntimeShutdownError.new("EventLoop rejected Agent Recovery resolution"))
170
166
  end
171
167
  completion
172
168
  rescue => caught
173
169
  completion ||= Phronomy::TaskResult.deferred(
174
170
  name: "agent-recovery-resolve:#{execution_id}"
175
171
  )
176
- completion.fail(caught)
172
+ agent.send(:_fail_result_task, completion, caught)
177
173
  completion
178
174
  end
179
175
 
@@ -26,17 +26,15 @@ module Phronomy
26
26
  end
27
27
 
28
28
  def resolution_failure(error)
29
- {
30
- "class" => error.class.name.to_s,
31
- "message" => error.message.to_s
32
- }.freeze
29
+ Phronomy::Error.diagnostic(error).freeze
33
30
  end
34
31
 
35
32
  def error_from_failure(failure)
36
- hash = failure.to_h { |key, value| [key.to_s, value] }
37
- Phronomy::Error.new(
38
- "#{hash.fetch("class", "Error")}: #{hash.fetch("message", "Recovery-resolved failure")}"
39
- )
33
+ if failure["class"] == "Phronomy::Agent::OutputError"
34
+ OutputError.restore(failure)
35
+ else
36
+ Phronomy::Error.from_diagnostic(failure)
37
+ end
40
38
  end
41
39
 
42
40
  def event_payload(execution, descriptor)
@@ -23,7 +23,8 @@ module Phronomy
23
23
  declared_states: InvocationTransitions::DECLARED_STATES,
24
24
  wait_state_names: InvocationTransitions::WAIT_STATES,
25
25
  external_events: InvocationTransitions::EXTERNAL_EVENTS,
26
- recursion_limit: InvocationTransitions.recursion_limit(invocation.agent.class.max_iterations),
26
+ recursion_limit: InvocationTransitions::AUTOMATIC_RECURSION_LIMIT,
27
+ recursion_limit_scope: :automatic_chain,
27
28
  event_loop: event_loop,
28
29
  resume_event: resume_event,
29
30
  resume_phase: resume_phase
@@ -5,8 +5,12 @@ module Phronomy
5
5
  class RuntimeInput
6
6
  RuntimeProjection = Data.define(
7
7
  :system, :messages, :tool_classes, :ask_message,
8
- :model_config, :manifest, :manifest_ref
9
- )
8
+ :model_config, :manifest, :manifest_ref, :response_schema
9
+ ) do
10
+ def initialize(response_schema: nil, **values)
11
+ super(**values, response_schema: response_schema)
12
+ end
13
+ end
10
14
 
11
15
  def initialize(agent:, persistence:, additional_tools: [])
12
16
  @agent = agent
@@ -45,7 +49,8 @@ module Phronomy
45
49
  ask_message: ask_message,
46
50
  model_config: Phronomy::Values::Immutable.copy(model_config),
47
51
  manifest: manifest,
48
- manifest_ref: manifest_ref
52
+ manifest_ref: manifest_ref,
53
+ response_schema: manifest.response_schema_ref && Phronomy::Values::Immutable.json(fetch_json(manifest.response_schema_ref))
49
54
  )
50
55
  end
51
56
 
@@ -148,12 +153,18 @@ module Phronomy
148
153
  end
149
154
 
150
155
  tool_calls = materialize_tool_calls(payload["tool_calls"])
156
+ metadata = payload.fetch("metadata", {})
157
+ raise ArgumentError, "canonical message metadata must be a Hash" unless metadata.is_a?(Hash)
158
+ metadata = metadata.dup
159
+ # Preserve backend-owned values without interpreting them. Old records
160
+ # stored model_id only at the top level; retain that read compatibility.
161
+ metadata["model_id"] = payload["model_id"] if !metadata.key?("model_id") && payload["model_id"]
151
162
  Phronomy::LLMAdapter::Message.new(
152
163
  role: role,
153
164
  content: payload.fetch("content", nil),
154
165
  tool_calls: tool_calls,
155
166
  tool_call_id: payload["tool_call_id"],
156
- metadata: {"model_id" => payload["model_id"]}.compact
167
+ metadata: metadata
157
168
  )
158
169
  end
159
170
 
@@ -48,7 +48,8 @@ module Phronomy
48
48
  cached: usage_hash["cached"] || usage_hash[:cached] || usage_hash["cache_read_tokens"] || usage_hash[:cache_read_tokens] || usage_hash["cached_tokens"] || usage_hash[:cached_tokens],
49
49
  cache_creation: usage_hash["cache_creation"] || usage_hash[:cache_creation] || usage_hash["cache_write_tokens"] || usage_hash[:cache_write_tokens] || usage_hash["cache_creation_tokens"] || usage_hash[:cache_creation_tokens]
50
50
  )
51
- [output, usage]
51
+ response = Phronomy::LLMAdapter::Response.from_h(payload.merge("usage" => usage.to_h))
52
+ [output, usage, response]
52
53
  end
53
54
  end
54
55
  end
@@ -79,7 +79,8 @@ module Phronomy
79
79
  execution_id: execution.execution_id, agent_id: execution.agent_id,
80
80
  status: execution.status, phase: execution.phase,
81
81
  result_ref: execution.result_ref, error_ref: execution.error_ref,
82
- result: execution.result_ref && contents.fetch_text(execution.result_ref),
82
+ result: materialize_result(execution, contents),
83
+ raw_output: execution.metadata["raw_output_ref"] && contents.fetch_text(execution.metadata.fetch("raw_output_ref")),
83
84
  error: execution.error_ref && contents.fetch_json(execution.error_ref)
84
85
  }.then { |value| Phronomy::Values::Immutable.copy(value) }
85
86
  end
@@ -97,7 +98,8 @@ module Phronomy
97
98
  assert_execution_owner!(execution, agent_id)
98
99
  ExecutionObservation.new(agent_id: execution.agent_id, execution_id: execution.execution_id,
99
100
  status: execution.status,
100
- result: execution.result_ref && records.contents.fetch_text(execution.result_ref),
101
+ result: materialize_result(execution, records.contents),
102
+ raw_output: execution.metadata["raw_output_ref"] && records.contents.fetch_text(execution.metadata.fetch("raw_output_ref")),
101
103
  error: execution.error_ref && Phronomy::Values::Immutable.copy(records.contents.fetch_json(execution.error_ref)),
102
104
  reservation: execution.metadata["reservation"] && ReservedExecution.new(agent_id: agent_id,
103
105
  execution_id: execution_id, correlation: execution.metadata["reservation"]),
@@ -226,6 +228,12 @@ module Phronomy
226
228
 
227
229
  private
228
230
 
231
+ def materialize_result(execution, contents)
232
+ return nil unless execution.result_ref
233
+ (execution.metadata["result_format"] == "json") ?
234
+ Phronomy::Values::Immutable.json(contents.fetch_json(execution.result_ref)) : contents.fetch_text(execution.result_ref)
235
+ end
236
+
229
237
  def authorized_argument_values(entry)
230
238
  # Unit 6 persists the exact validated snapshot. Older records encoded
231
239
  # legacy raw params, whose explicit optional nil meant omission.
@@ -22,14 +22,14 @@ module Phronomy
22
22
  # @return [Exception] original Application callback exception
23
23
  attr_reader :original_error
24
24
 
25
- def initialize(event_type:, original_error:, result: nil)
25
+ def initialize(event_type:, original_error:, result: nil, code: nil)
26
26
  @event_type = event_type
27
27
  @original_error = original_error
28
28
  @result = result
29
29
 
30
30
  super(
31
31
  "Stream callback failed while handling #{event_type.inspect}: " \
32
- "#{original_error.class}: #{original_error.message}"
32
+ "#{original_error.class}: #{original_error.message}", code: code
33
33
  )
34
34
  set_backtrace(original_error.backtrace)
35
35
  end
@@ -1,5 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- class Error < StandardError; end
4
+ class Error < StandardError
5
+ # Optional stable reason supplied by the owner of the failed operation.
6
+ # It does not imply retry safety or replace execution status.
7
+ # @api public
8
+ attr_reader :code
9
+
10
+ # Existing message-only construction retains StandardError behavior.
11
+ # @api public
12
+ def initialize(message = nil, code: nil)
13
+ raise ArgumentError, "error code must be a String or nil" unless code.nil? || code.is_a?(String)
14
+ @code = code&.dup&.freeze
15
+ super(message)
16
+ end
17
+
18
+ # Generic diagnostic transport. No domain-specific interpretation.
19
+ # External exceptions are not extended with this protocol.
20
+ # @api private
21
+ def self.diagnostic(error)
22
+ diagnostic = {"class" => error.class.name, "message" => error.message}
23
+ diagnostic["code"] = error.code if error.is_a?(Phronomy::Error) && error.code
24
+ cause = error.cause
25
+ diagnostic["cause"] = cause_diagnostic(cause, remaining: 3, seen: [error.object_id]) if cause
26
+ diagnostic
27
+ end
28
+
29
+ # Restores data, never a saved class name or arbitrary instance attributes.
30
+ # Restored causes are generic errors as well. Old diagnostics remain valid.
31
+ # @api private
32
+ def self.from_diagnostic(value, remaining: 3)
33
+ hash = value.to_h { |key, item| [key.to_s, item] }
34
+ error = Phronomy::Error.new(
35
+ "#{hash.fetch("class", "Error")}: #{hash.fetch("message", "Recovery-resolved failure")}",
36
+ code: hash["code"]
37
+ )
38
+ return error unless remaining.positive? && hash["cause"].is_a?(Hash) && hash["cause"].key?("class")
39
+
40
+ cause = from_diagnostic(hash["cause"], remaining: remaining - 1)
41
+ begin
42
+ raise error, cause: cause
43
+ rescue Phronomy::Error => restored
44
+ restored.set_backtrace(nil)
45
+ restored
46
+ end
47
+ end
48
+
49
+ def self.cause_diagnostic(error, remaining:, seen:)
50
+ return {"truncated" => true} if remaining.zero? || seen.include?(error.object_id)
51
+
52
+ # Bound newly added cause data; retain the existing top-level format.
53
+ fields = {"class" => error.class.name, "message" => error.message}
54
+ fields["code"] = error.code if error.is_a?(Phronomy::Error) && error.code
55
+ diagnostic = fields.to_h do |key, value|
56
+ [key, value.is_a?(String) ? value[0, 2048] : value]
57
+ end
58
+ # Never turn a truncated reason into a different, apparently valid code.
59
+ diagnostic.delete("code") if fields["code"] && fields["code"].length > 2048
60
+ diagnostic["truncated"] = true if diagnostic != fields
61
+ if error.cause
62
+ diagnostic["cause"] = cause_diagnostic(error.cause,
63
+ remaining: remaining - 1, seen: seen + [error.object_id])
64
+ end
65
+ diagnostic
66
+ end
67
+ private_class_method :cause_diagnostic
68
+ end
5
69
  end