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
@@ -18,7 +18,7 @@ module Phronomy
18
18
  @agent, @id, @input, @config = agent, execution_id.to_s.freeze, input, config.freeze
19
19
  @resume_only = resume_only
20
20
  @environment = @agent.__execution_environment
21
- @completion = Phronomy::TaskResult.deferred(name: "exact-execution:#{@id}")
21
+ @completion = OperationResult.new(agent: @agent, name: "exact-execution:#{@id}")
22
22
  end
23
23
 
24
24
  def start
@@ -43,13 +43,14 @@ module Phronomy
43
43
  [:absent, nil]
44
44
  end
45
45
  end
46
+ @completion.track(preparation)
46
47
  preparation.on_complete do |prepared, error|
47
48
  if error
48
- @completion.fail(error)
49
+ @completion.settle(error: error)
49
50
  else
50
51
  disposition, result = prepared
51
52
  case disposition
52
- when :terminal then @completion.complete(result)
53
+ when :terminal then @completion.settle(result)
53
54
  when :absent
54
55
  source = @agent.send(:_start_agent_operation, @input,
55
56
  config: @config.merge(phronomy_reserved_execution_id: @id, phronomy_exact_observers: [@completion].freeze),
@@ -58,15 +59,15 @@ module Phronomy
58
59
  when :active
59
60
  command = Wait.new(coordinator: self, agent: @agent, execution_id: @id)
60
61
  posted = @environment.registry.post(command, completion: @completion)
61
- @completion.fail(Phronomy::RuntimeShutdownError.new("Exact execution observer rejected")) unless posted
62
+ @completion.settle(error: Phronomy::RuntimeShutdownError.new("Exact execution observer rejected")) unless posted
62
63
  end
63
64
  end
64
65
  rescue => failure
65
- @completion.fail(failure)
66
+ @completion.settle(error: failure)
66
67
  end
67
68
  @completion
68
69
  rescue => failure
69
- @completion.fail(failure)
70
+ @completion.settle(error: failure)
70
71
  @completion
71
72
  end
72
73
 
@@ -101,7 +102,7 @@ module Phronomy
101
102
  waiter.on_complete { |_result, failure| reconcile(failure) }
102
103
  @environment.registry.register_agent_completion_waiter(@id, waiter)
103
104
  rescue => failure
104
- @completion.fail(failure)
105
+ @completion.settle(error: failure)
105
106
  end
106
107
 
107
108
  private
@@ -120,6 +121,12 @@ module Phronomy
120
121
  if expected && stored != expected
121
122
  raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
122
123
  end
124
+ unless @resume_only
125
+ expected_digest = @config[:phronomy_output_contract]&.digest
126
+ if expected_digest != execution.metadata.dig("output_contract", "schema_digest")
127
+ raise Phronomy::Persistence::StateConflictError, "Reserved execution output schema mismatch"
128
+ end
129
+ end
123
130
  ref = execution.metadata["current_input_ref"]
124
131
  @input = @agent.persistence.contents.fetch_text(ref) if @resume_only && ref
125
132
  if ref && @agent.persistence.contents.fetch_text(ref) != @input
@@ -157,15 +164,19 @@ module Phronomy
157
164
  ))
158
165
  end
159
166
  end
167
+ @completion.track(task)
160
168
  task.on_complete do |result, error|
161
- error ? @completion.fail(error) : @completion.complete(result)
169
+ error ? @completion.settle(error: error) : @completion.settle(result)
162
170
  end
163
171
  rescue => error
164
- @completion.fail(error)
172
+ @completion.settle(error: error)
165
173
  end
166
174
 
167
175
  def materialize(execution)
168
176
  result = @agent.persistence.result(execution.execution_id)
177
+ if result[:error].is_a?(Hash) && result[:error]["class"] == "Phronomy::Agent::OutputError"
178
+ raise RecoverySupport.error_from_failure(result[:error])
179
+ end
169
180
  result.merge(output: result[:result]).freeze
170
181
  end
171
182
  end
@@ -7,7 +7,6 @@ module Phronomy
7
7
  # @api private
8
8
  class ExecutionCancellation
9
9
  Command = Data.define(:coordinator, :execution_id, :agent_id)
10
- private_constant :Command
11
10
 
12
11
  def self.signal(execution_id, agent_id, environment:)
13
12
  command = Command.new(coordinator: new(environment), execution_id: execution_id, agent_id: agent_id)
@@ -22,6 +21,7 @@ module Phronomy
22
21
  state = @environment.registry.agent_execution_state(command.execution_id)
23
22
  return unless state && state.agent.agent_id == command.agent_id
24
23
  state.invocation&.config&.fetch(:cancellation_token, nil)&.cancel!
24
+ state.coordinator.deliver_on_event_loop(command)
25
25
  end
26
26
  end
27
27
  end
@@ -74,6 +74,8 @@ module Phronomy
74
74
  TerminalCommitReady = Data.define(
75
75
  :coordinator, :operation, :delivery, :outcome, :error
76
76
  )
77
+ SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
78
+
77
79
  DeferredTerminalCommand = Data.define(
78
80
  :coordinator, :execution_id, :result_task, :invocation,
79
81
  :source_error, :fsm_session_id
@@ -93,7 +95,7 @@ module Phronomy
93
95
  )
94
96
  @agent.send(:__assert_live_agent!)
95
97
  @agent.send(:_reject_removed_generic_identity_keys!, config)
96
- result_task = Phronomy::TaskResult.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
98
+ result_task = OperationResult.new(agent: @agent, name: "agent-#{@agent.agent_id}-#{mode}")
97
99
  controls = Phronomy::InvocationControls.attach(result_task,
98
100
  invocation_context: config[:invocation_context],
99
101
  cancellation_token: config[:cancellation_token])
@@ -127,9 +129,8 @@ module Phronomy
127
129
  )
128
130
  @agent.send(:__assert_live_agent!)
129
131
  @agent.send(:_reject_removed_generic_identity_keys!, config)
130
- result_task = Phronomy::TaskResult.deferred(
131
- name: "agent-approval-resume:#{execution_id}"
132
- )
132
+ result_task = OperationResult.new(agent: @agent,
133
+ name: "agent-approval-resume:#{execution_id}")
133
134
  command = ResumeCommand.new(
134
135
  coordinator: self,
135
136
  execution_id: execution_id.to_s.freeze,
@@ -255,6 +256,8 @@ module Phronomy
255
256
  apply_terminal_commit_on_event_loop(command)
256
257
  when DeferredTerminalCommand
257
258
  resume_deferred_terminal_on_event_loop(command)
259
+ when ExecutionCancellation::Command, SuspensionCancelledCommand
260
+ cancel_suspended_on_event_loop(command.execution_id)
258
261
  else
259
262
  raise Phronomy::Error, "unknown Agent control command: #{command.class}"
260
263
  end
@@ -269,7 +272,7 @@ module Phronomy
269
272
  def begin_start_on_event_loop(request)
270
273
  environment = @agent.__execution_environment
271
274
  request.controls.check_start!
272
- root = @agent.agent_root
275
+ root = @agent.send(:__root_for_operation)
273
276
  if root.lifecycle_status == :closed
274
277
  deliver_start_failure_on_event_loop(
275
278
  request,
@@ -298,6 +301,7 @@ module Phronomy
298
301
  @initial_preparation.prepare(operation)
299
302
  end
300
303
  submitted = true
304
+ request.result_task.track(task) if request.result_task.respond_to?(:track)
301
305
  task.on_complete do |result, error|
302
306
  ready = InitialPreparationReady.new(
303
307
  coordinator: self,
@@ -428,6 +432,7 @@ module Phronomy
428
432
  Phronomy::Tracing::Automatic.observe_task(
429
433
  request.result_task,
430
434
  "agent.execution",
435
+ usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
431
436
  input: request.input,
432
437
  agent_id: @agent.agent_id,
433
438
  execution_id: prepared.execution.execution_id,
@@ -519,7 +524,7 @@ module Phronomy
519
524
 
520
525
  operation = InitialPreparation::RecoveryCommand.new(
521
526
  execution: execution,
522
- root: @agent.agent_root,
527
+ root: @agent.send(:__root_for_operation),
523
528
  journal_records: @agent.send(:_journal_records_snapshot)
524
529
  )
525
530
  task = environment.submit(on_full: :raise) do
@@ -717,7 +722,7 @@ module Phronomy
717
722
  execution_id: state.execution_id,
718
723
  fsm_session_id: fsm_session_id.to_s.freeze,
719
724
  expected_execution_revision: state.execution.execution_revision,
720
- root: @agent.agent_root,
725
+ root: @agent.send(:__root_for_operation),
721
726
  journal_records: @agent.send(:_journal_records_snapshot),
722
727
  execution: state.execution,
723
728
  base_manifest: state.base_manifest,
@@ -739,7 +744,7 @@ module Phronomy
739
744
  execution_id: state.execution_id,
740
745
  fsm_session_id: fsm_session_id.to_s.freeze,
741
746
  expected_execution_revision: state.execution.execution_revision,
742
- root: @agent.agent_root,
747
+ root: @agent.send(:__root_for_operation),
743
748
  execution: state.execution,
744
749
  runtime_snapshot: invocation.runtime_snapshot,
745
750
  tool_batch_snapshot: tool_batch_snapshot,
@@ -911,16 +916,23 @@ module Phronomy
911
916
  operation = capture_approval_resume(state, request)
912
917
  resume_transition_started = false
913
918
  submitted = false
914
- environment.registry.mark_agent_execution_admission(
915
- @agent.agent_id,
916
- execution_id: state.execution_id,
917
- state: :resuming
918
- )
919
+ begin
920
+ environment.registry.mark_agent_execution_admission(
921
+ @agent.agent_id,
922
+ execution_id: state.execution_id,
923
+ state: :resuming,
924
+ expected_state: :suspended
925
+ )
926
+ rescue Phronomy::AgentBusyError => error
927
+ return fail_task(request.result_task, error)
928
+ end
929
+ suspended_cancellation = clear_suspended_cancellation
919
930
  resume_transition_started = true
920
931
  task = environment.submit(on_full: :raise) do
921
932
  @approval_resume_commit.commit(operation)
922
933
  end
923
934
  submitted = true
935
+ request.result_task.track(task) if request.result_task.respond_to?(:track)
924
936
  task.on_complete do |result, error|
925
937
  ready = ResumeCommitReady.new(
926
938
  coordinator: self,
@@ -939,6 +951,9 @@ module Phronomy
939
951
  execution_id: state.execution_id,
940
952
  state: submitted ? :recovery_required : :suspended
941
953
  )
954
+ if !submitted && suspended_cancellation
955
+ bind_suspended_cancellation(*suspended_cancellation.first(3))
956
+ end
942
957
  end
943
958
  fail_task(request.result_task, error)
944
959
  raise unless resume_transition_started
@@ -953,7 +968,7 @@ module Phronomy
953
968
  ResumeCommitCommand.new(
954
969
  execution_id: state.execution_id,
955
970
  expected_execution_revision: state.execution.execution_revision,
956
- root: @agent.agent_root,
971
+ root: @agent.send(:__root_for_operation),
957
972
  execution: state.execution,
958
973
  approval_request_id: request.approval_request_id,
959
974
  approved: request.approved,
@@ -1042,6 +1057,7 @@ module Phronomy
1042
1057
  Phronomy::Tracing::Automatic.observe_task(
1043
1058
  request.result_task,
1044
1059
  "agent.execution",
1060
+ usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
1045
1061
  agent_id: @agent.agent_id,
1046
1062
  execution_id: operation.execution_id,
1047
1063
  mode: trace_mode,
@@ -1254,7 +1270,7 @@ module Phronomy
1254
1270
 
1255
1271
  operation = build_terminal_operation(
1256
1272
  execution: state.execution,
1257
- root: @agent.agent_root,
1273
+ root: @agent.send(:__root_for_operation),
1258
1274
  journal_records: @agent.send(:_journal_records_snapshot),
1259
1275
  invocation: invocation,
1260
1276
  source_error: source_error,
@@ -1338,6 +1354,7 @@ module Phronomy
1338
1354
  # worker boundary. TaskResult/listener delivery state stays outside it.
1339
1355
  @outcome_committer.commit_outcome(operation)
1340
1356
  end
1357
+ delivery.result_task.track(task) if delivery.result_task.respond_to?(:track)
1341
1358
  task.on_complete do |outcome, error|
1342
1359
  ready = TerminalCommitReady.new(
1343
1360
  coordinator: self,
@@ -1371,6 +1388,7 @@ module Phronomy
1371
1388
  TerminalView.new(
1372
1389
  phase: invocation&.phase,
1373
1390
  output: invocation&.output,
1391
+ response: invocation&.final_response,
1374
1392
  usage: invocation&.usage,
1375
1393
  approval_request: invocation&.approval_request,
1376
1394
  rejected: invocation ? !!invocation.rejected : false,
@@ -1440,7 +1458,7 @@ module Phronomy
1440
1458
  if operation.execution.metadata["reservation"] || operation.execution.metadata["execution_extension"]
1441
1459
  release_terminal_ownership(environment, operation.execution_id, state)
1442
1460
  environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
1443
- task.fail(ready.error)
1461
+ fail_task(task, ready.error)
1444
1462
  end
1445
1463
  return
1446
1464
  end
@@ -1477,7 +1495,7 @@ module Phronomy
1477
1495
  delivery = ready.delivery
1478
1496
  outcome = ready.outcome
1479
1497
  release_terminal_ownership(environment, operation.execution_id, state)
1480
- environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
1498
+ environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
1481
1499
  end
1482
1500
 
1483
1501
  def suspend_execution_on_event_loop(ready, state, environment)
@@ -1489,6 +1507,7 @@ module Phronomy
1489
1507
  execution_id: operation.execution_id,
1490
1508
  state: :suspended
1491
1509
  )
1510
+ bind_suspended_cancellation(operation.execution_id, state.invocation, delivery.result_task)
1492
1511
  dispatch_approval_listener(delivery.approval_listener, outcome.approval_request)
1493
1512
  callback_error = deliver_terminal(
1494
1513
  delivery.application_listener,
@@ -1501,10 +1520,50 @@ module Phronomy
1501
1520
  outcome.result
1502
1521
  )
1503
1522
  Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
1504
- task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
1523
+ fail_task(task, Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
1524
+ end
1525
+ end
1526
+
1527
+ # Suspension closes the FSM session, but not the logical execution.
1528
+ # Keep cancellation connected until approval resumes or the owner terminates.
1529
+ def bind_suspended_cancellation(execution_id, invocation, result_task)
1530
+ clear_suspended_cancellation
1531
+ subscriptions = Phronomy::Concurrency::Subscriptions.new
1532
+ @suspended_cancellation = [execution_id, invocation, result_task, subscriptions]
1533
+ subscriptions.result(result_task) { subscriptions.close }
1534
+ environment = @agent.__execution_environment
1535
+ subscriptions.cancellation(invocation.config[:cancellation_token]) do
1536
+ post_control(environment, SuspensionCancelledCommand.new(coordinator: self, execution_id: execution_id))
1505
1537
  end
1506
1538
  end
1507
1539
 
1540
+ def clear_suspended_cancellation
1541
+ record = @suspended_cancellation
1542
+ @suspended_cancellation = nil
1543
+ record&.last&.close
1544
+ record
1545
+ end
1546
+
1547
+ def cancel_suspended_on_event_loop(execution_id)
1548
+ record = @suspended_cancellation
1549
+ return unless record && record.first == execution_id
1550
+ state = @agent.__execution_environment.registry.agent_execution_state(execution_id)
1551
+ return unless state && state.execution.status == :suspended &&
1552
+ state.invocation.equal?(record[1]) && state.fsm_session_id.nil?
1553
+
1554
+ @agent.__execution_environment.registry.mark_agent_execution_admission(
1555
+ @agent.agent_id, execution_id: execution_id, state: :cancelling,
1556
+ expected_state: :suspended
1557
+ )
1558
+ clear_suspended_cancellation
1559
+ finish_on_event_loop(execution_id, record[2], state.invocation,
1560
+ Phronomy::CancellationError.new("Agent execution cancelled during approval"),
1561
+ fsm_session_id: nil)
1562
+ rescue Phronomy::AgentBusyError
1563
+ # A resume/terminal transition already owns this suspended boundary.
1564
+ nil
1565
+ end
1566
+
1508
1567
  def deliver_completed_execution_on_event_loop(ready, state, environment)
1509
1568
  operation = ready.operation
1510
1569
  delivery = ready.delivery
@@ -1552,6 +1611,7 @@ module Phronomy
1552
1611
 
1553
1612
  def release_terminal_ownership(environment, execution_id, state)
1554
1613
  registry = environment.registry
1614
+ clear_suspended_cancellation if @suspended_cancellation&.first == execution_id
1555
1615
  registry.release_agent_execution(execution_id) if state
1556
1616
  registry.release_agent_execution_admission(@agent.agent_id, execution_id: execution_id)
1557
1617
  end
@@ -1695,6 +1755,7 @@ module Phronomy
1695
1755
  end
1696
1756
  if execution_error
1697
1757
  if execution_error.is_a?(Phronomy::CancellationError)
1758
+ result_task.finish_operation! if result_task.respond_to?(:finish_operation!)
1698
1759
  result_task.cancel!(execution_error)
1699
1760
  else
1700
1761
  fail_task(result_task, execution_error)
@@ -13,8 +13,12 @@ module Phronomy
13
13
  TerminalView = Data.define(
14
14
  :phase, :output, :usage, :approval_request, :rejected,
15
15
  :input_blocked, :output_blocked, :block_error,
16
- :invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested
17
- )
16
+ :invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested, :response
17
+ ) do
18
+ def initialize(response: nil, **values)
19
+ super(**values, response: response)
20
+ end
21
+ end
18
22
  Command = Data.define(
19
23
  :execution_id, :fsm_session_id, :expected_execution_revision,
20
24
  :root, :journal_records, :execution, :runtime_snapshot,
@@ -36,6 +40,18 @@ module Phronomy
36
40
  view = operation.terminal_view
37
41
  error = view.callback_failure&.to_stream_callback_error || view.source_error ||
38
42
  view.block_error || view.invocation_error
43
+ if !error && !view.rejected && !view.handoff && view.phase != :suspended
44
+ begin
45
+ contract = OutputContract.restore(operation.execution, @persistence.contents)
46
+ if contract
47
+ output = contract.validate(view.output, response: view.response, execution_id: operation.execution_id)
48
+ view = view.with(output: output)
49
+ operation = operation.with(terminal_view: view)
50
+ end
51
+ rescue => validation_error
52
+ error = validation_error
53
+ end
54
+ end
39
55
  change = ExecutionChange.new(agent: @agent, current: operation.execution,
40
56
  root: operation.root, kind: :terminal, terminal: view, config: operation.wiring) do |tx, state, receipt, pending|
41
57
  if pending
@@ -147,7 +163,9 @@ module Phronomy
147
163
  current = operation.execution
148
164
  view = operation.terminal_view
149
165
  encoded_records, call_records = encode_runtime_records(tx, operation, context_candidate: true)
150
- output_ref = tx.contents.put_text(view.output.to_s)
166
+ structured = current.metadata.key?("output_contract") && !view.rejected
167
+ output_ref = tx.contents.put_text(structured ? JSON.generate(view.output) : view.output.to_s)
168
+ output_metadata = structured ? {"result_format" => "json", "raw_output_ref" => tx.contents.put_text(view.response.content)} : {}
151
169
  records = current.working_records + encoded_records + completion_records(operation, output_ref)
152
170
  completed = current.with(
153
171
  status: view.rejected ? :rejected : :completed,
@@ -156,6 +174,7 @@ module Phronomy
156
174
  llm_calls: current.llm_calls + call_records,
157
175
  approval_request: nil,
158
176
  result_ref: output_ref,
177
+ metadata: current.metadata.merge(output_metadata),
159
178
  terminal_reason: view.rejected ? "rejected" : "completed"
160
179
  )
161
180
  [records, completed]
@@ -187,7 +206,7 @@ module Phronomy
187
206
  def encode_failure(tx, operation, error)
188
207
  current = operation.execution
189
208
  encoded_records, call_records = encode_runtime_records(tx, operation, context_candidate: false)
190
- error_ref = tx.contents.put_json("class" => error.class.name, "message" => error.message)
209
+ error_ref = tx.contents.put_json(error.is_a?(OutputError) ? error.diagnostic : Phronomy::Error.diagnostic(error))
191
210
  terminal_status = ExecutionFailure.status_for(error)
192
211
  records = failure_records(operation, encoded_records, terminal_status, error_ref)
193
212
  failed = current.with(
@@ -252,6 +271,7 @@ module Phronomy
252
271
  def completed_outcome(view, execution, root, appended, messages)
253
272
  result = result_base(execution, root).merge(
254
273
  output: view.output,
274
+ raw_output: (execution.metadata["result_format"] == "json") ? view.response.content : nil,
255
275
  rejected: view.rejected || nil,
256
276
  usage: view.usage,
257
277
  messages: messages
@@ -145,7 +145,7 @@ module Phronomy
145
145
  end
146
146
 
147
147
  # @api private
148
- def mark_agent_execution_admission(agent_id, execution_id:, state:)
148
+ def mark_agent_execution_admission(agent_id, execution_id:, state:, expected_state: nil)
149
149
  assert_event_loop_thread!
150
150
  key = agent_id.to_s
151
151
  execution_key = execution_id.to_s
@@ -161,6 +161,10 @@ module Phronomy
161
161
  unless current.execution_id.to_s == execution_key
162
162
  raise Phronomy::Error, "stale Agent admission state update for #{key.inspect}"
163
163
  end
164
+ if expected_state && current.state != expected_state
165
+ raise Phronomy::AgentBusyError,
166
+ "Agent #{key.inspect} is #{current.state}, expected #{expected_state}"
167
+ end
164
168
  @agent_admissions[key] = AgentAdmission.new(
165
169
  agent_id: current.agent_id,
166
170
  owner_token: current.owner_token,
@@ -105,6 +105,13 @@ module Phronomy
105
105
  def prepare_admitted(input:, config:, execution:, active_root:, journal_records:)
106
106
  current_execution = execution
107
107
  begin
108
+ # Legacy executions acquire a limit at their first resumed preparation.
109
+ current_execution = execution.with(
110
+ execution_revision: execution.execution_revision,
111
+ metadata: execution.metadata.merge(IterationBudget::LIMIT_KEY =>
112
+ IterationBudget.limit_for(execution, default: @agent.class.max_iterations))
113
+ )
114
+ IterationBudget.check!(current_execution, default: @agent.class.max_iterations)
108
115
  filtered_input = filter_input(input, config)
109
116
  staged = stage_filtered_input(current_execution, active_root, filtered_input)
110
117
  assembler, prepared = prepare_context(staged, active_root, filtered_input, config, journal_records)
@@ -184,7 +191,8 @@ module Phronomy
184
191
  active = manifest = manifest_ref = nil
185
192
  @persistence.transaction do |tx|
186
193
  assert_local_durable_base!(tx, root)
187
- manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents)
194
+ manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
195
+ response_schema_ref: staged.metadata.dig("output_contract", "schema_ref"))
188
196
  active = execution_with_initial_manifest(staged, manifest_ref)
189
197
  tx.executions.save(
190
198
  staged.execution_id,
@@ -222,6 +230,7 @@ module Phronomy
222
230
  def initial_preparation_metadata(mode, replayable)
223
231
  {
224
232
  "preparation_replayable" => !!replayable,
233
+ IterationBudget::LIMIT_KEY => IterationBudget.validate_limit(@agent.class.max_iterations),
225
234
  ExecutionMetadata::CONTRACT_VERSION_KEY => ExecutionMetadata::CONTRACT_VERSION,
226
235
  ExecutionMetadata::INVOCATION_MODE_KEY => mode.to_s,
227
236
  ExecutionMetadata::PENDING_LLM_ID_KEY => SecureRandom.uuid.to_s.freeze,
@@ -262,10 +271,7 @@ module Phronomy
262
271
  translated_error = error
263
272
  failed = next_root = appended = nil
264
273
  @persistence.transaction do |tx|
265
- error_ref = tx.contents.put_json(
266
- "class" => translated_error.class.name,
267
- "message" => translated_error.message
268
- )
274
+ error_ref = tx.contents.put_json(Phronomy::Error.diagnostic(translated_error))
269
275
  status = ExecutionFailure.status_for(translated_error)
270
276
  records = preparation_failure_records(execution, root, status, error_ref)
271
277
  appended = tx.journals.append(
@@ -197,7 +197,7 @@ module Phronomy
197
197
 
198
198
  def self.finish_provider_trace(trace_handle, response, error)
199
199
  Phronomy::Tracing::Automatic.finish(trace_handle,
200
- output: response, usage: response&.usage, error: error)
200
+ output: response, usage: response&.usage&.to_h&.freeze, error: error)
201
201
  end
202
202
  private_class_method :finish_provider_trace
203
203
 
@@ -25,6 +25,10 @@ module Phronomy
25
25
  recording_tool_results suspended output_filtering handed_off completed blocked failed
26
26
  ].freeze
27
27
 
28
+ # An automatic chain cannot visit more phases than an acyclic traversal.
29
+ # Logical LLM turns are bounded independently by IterationBudget.
30
+ AUTOMATIC_RECURSION_LIMIT = DECLARED_STATES.length
31
+
28
32
  WAIT_STATES = %i[suspended].freeze
29
33
 
30
34
  TOOL_EVENTS = %i[
@@ -115,10 +119,6 @@ module Phronomy
115
119
  guard = definition[:guard]
116
120
  !guard || !!(context && guard.call(context))
117
121
  end
118
-
119
- def self.recursion_limit(max_iterations)
120
- 12 + ((max_iterations || 10) * 8)
121
- end
122
122
  end
123
123
  end
124
124
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Agent policy over existing execution facts. No Engine steps, provider
6
+ # retries, Tool events or context-selected messages contribute to this count.
7
+ # @api private
8
+ module IterationBudget
9
+ LIMIT_KEY = "agent_max_iterations"
10
+
11
+ module_function
12
+
13
+ def validate_limit(value)
14
+ unless value.is_a?(Integer) && value >= 0
15
+ raise ArgumentError, "Agent max_iterations must be a non-negative Integer"
16
+ end
17
+ value
18
+ end
19
+
20
+ def limit_for(execution, default:)
21
+ validate_limit(execution.metadata.fetch(LIMIT_KEY) { default })
22
+ end
23
+
24
+ def check!(execution, default:)
25
+ limit = limit_for(execution, default: default)
26
+ completed = execution.llm_calls.length
27
+ if completed >= limit
28
+ raise Phronomy::RecursionLimitError,
29
+ "Agent max_iterations (#{limit}) exceeded after #{completed} LLM calls"
30
+ end
31
+ limit
32
+ end
33
+ end
34
+ end
35
+ end
@@ -29,10 +29,7 @@ module Phronomy
29
29
  output_ref = assistant_output_ref(tx, outcome)
30
30
  assistant_ref = assistant_message_ref(tx, outcome)
31
31
  error_ref = if call_error
32
- tx.contents.put_json(
33
- "class" => call_error.class.name,
34
- "message" => call_error.message
35
- )
32
+ tx.contents.put_json(Phronomy::Error.diagnostic(call_error))
36
33
  end
37
34
  usage_ref = if outcome && outcome.usage.to_h.values.any? { |value| !value.nil? }
38
35
  tx.contents.put_json(json_value(outcome.usage.to_h))
@@ -172,6 +169,9 @@ module Phronomy
172
169
  "role" => "assistant",
173
170
  "content" => json_value(outcome.content),
174
171
  "tool_calls" => json_value(Array(outcome.tool_calls)),
172
+ "finish_reason" => outcome.finish_reason.to_s,
173
+ "refusal" => outcome.refusal,
174
+ "metadata" => outcome.metadata,
175
175
  "model_id" => outcome.metadata["model_id"]
176
176
  }.compact
177
177
  tx.contents.put_json(payload)
@@ -5,14 +5,14 @@ module Phronomy
5
5
  # Stable domain observation; does not expose repositories or FSM phases.
6
6
  # @api public
7
7
  ExecutionObservation = Data.define(:agent_id, :execution_id, :status, :result,
8
- :error, :reservation, :transfer_receipt, :cancellation_requested) do
9
- def initialize(**values)
10
- super(**values.transform_values { |value| Phronomy::Values::Immutable.copy(value) })
8
+ :error, :reservation, :transfer_receipt, :cancellation_requested, :raw_output) do
9
+ def initialize(raw_output: nil, **values)
10
+ super(raw_output: Phronomy::Values::Immutable.copy(raw_output), **values.transform_values { |value| Phronomy::Values::Immutable.copy(value) })
11
11
  end
12
12
 
13
13
  def self.from_result(value)
14
14
  new(agent_id: value.fetch(:agent_id), execution_id: value.fetch(:execution_id), status: value.fetch(:status),
15
- result: value[:result], error: value[:error], reservation: nil, transfer_receipt: nil, cancellation_requested: false)
15
+ result: value[:result], raw_output: value[:raw_output], error: value[:error], reservation: nil, transfer_receipt: nil, cancellation_requested: false)
16
16
  end
17
17
 
18
18
  def absent? = status == :absent
@@ -28,7 +28,7 @@ module Phronomy
28
28
 
29
29
  def to_result
30
30
  Phronomy::Values::Immutable.copy(execution_id: execution_id, agent_id: agent_id,
31
- status: status, result: result, output: result, error: error)
31
+ status: status, result: result, output: result, raw_output: raw_output, error: error)
32
32
  end
33
33
  end
34
34
  end