phronomy 0.29.0 → 0.30.1

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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +17 -6
  4. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  5. data/docs/architecture/agent-iteration-budget.md +81 -0
  6. data/docs/architecture/dx05-agent-scope.md +9 -2
  7. data/docs/architecture/error-api-review.md +4 -4
  8. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  9. data/docs/architecture/generator-verifier-ownership.md +18 -109
  10. data/docs/architecture/llm-continuation-metadata.md +88 -0
  11. data/docs/architecture/lm-studio-output-failures.md +111 -0
  12. data/docs/architecture/r8-unit1.md +68 -68
  13. data/docs/architecture/r8-unit15.md +3 -0
  14. data/docs/architecture/r8-unit2.md +34 -34
  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/decisions/029-semantic-completion-and-application-effect-boundary.md +1 -1
  21. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +23 -23
  22. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +99 -99
  23. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +135 -136
  24. data/docs/features.md +10 -1
  25. data/docs/migrations/r8-unit15.md +3 -0
  26. data/gemfiles/http_minimum.gemfile +6 -1
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/async_event_api.rb +16 -7
  29. data/lib/phronomy/agent/base.rb +8 -1
  30. data/lib/phronomy/agent/context_importer.rb +4 -0
  31. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  32. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  33. data/lib/phronomy/agent/execution/exact_execution.rb +9 -0
  34. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  35. data/lib/phronomy/agent/execution/execution_coordinator.rb +63 -5
  36. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  37. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  38. data/lib/phronomy/agent/execution/initial_preparation.rb +10 -1
  39. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  40. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  41. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  42. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +3 -0
  43. data/lib/phronomy/agent/execution_observation.rb +5 -5
  44. data/lib/phronomy/agent/lifecycle/scope.rb +33 -23
  45. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +4 -2
  46. data/lib/phronomy/agent/output_contract.rb +64 -0
  47. data/lib/phronomy/agent/output_error.rb +53 -0
  48. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  49. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -0
  50. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +1 -1
  51. data/lib/phronomy/agent/recovery/recovery_support.rb +5 -1
  52. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  53. data/lib/phronomy/agent/runtime_input.rb +15 -4
  54. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  55. data/lib/phronomy/agent/store.rb +10 -2
  56. data/lib/phronomy/common/values/immutable.rb +33 -0
  57. data/lib/phronomy/context/assembly.rb +7 -4
  58. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +3 -1
  59. data/lib/phronomy/engine/fsm_session.rb +16 -1
  60. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  61. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  62. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  63. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  64. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  65. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  66. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  67. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  68. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  69. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  70. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  71. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  72. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  73. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  74. data/lib/phronomy/llm_adapter/message.rb +4 -0
  75. data/lib/phronomy/llm_adapter/request.rb +17 -2
  76. data/lib/phronomy/llm_adapter/response.rb +11 -4
  77. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  78. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  79. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  80. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  81. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  82. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  83. data/lib/phronomy/tracing/automatic.rb +14 -8
  84. data/lib/phronomy/tracing/base.rb +2 -1
  85. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  86. data/lib/phronomy/tracing/observation.rb +2 -1
  87. data/lib/phronomy/tracing/open_telemetry_tracer.rb +3 -3
  88. data/lib/phronomy/version.rb +1 -1
  89. data/lib/phronomy.rb +1 -2
  90. data/scripts/api_snapshot.rb +3 -0
  91. data/scripts/migrate_generator_verifier_result.rb +18 -0
  92. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  93. data/sig/phronomy/agent.rbs +20 -7
  94. data/sig/phronomy/extensions.rbs +8 -3
  95. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  96. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  97. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  98. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  99. data/sig/phronomy/tracing/observation.rbs +1 -1
  100. data/sig/phronomy.rbs +2 -0
  101. metadata +59 -8
  102. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  103. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  104. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
  105. data/sig/phronomy/generation/low_confidence_error.rbs +0 -6
data/docs/features.md CHANGED
@@ -32,6 +32,13 @@ for production deployments.
32
32
  | **Error Taxonomy** — Provider errors translated to Phronomy transport/authentication/rate-limit/context errors | Beta |
33
33
  | **Optional failure reason codes** — `Phronomy::Error#code` and `code:` retain domain-supplied reasons in saved Agent/Team diagnostics; [meaning and compatibility](failure-reason-codes.md) | Beta |
34
34
 
35
+ The RubyLLM backends also translate identified Faraday timeout and connection
36
+ failures to the owning `LLMAdapter::TransportError` or
37
+ `Embeddings::TransportError`, retaining the original cause chain. A stream may
38
+ have delivered partial output before failure; this classification does not imply
39
+ that retry is safe. Callback failures and execution cancellation/deadlines keep
40
+ their identity. See [transport error boundaries](architecture/transport-error-boundaries.md).
41
+
35
42
  Agent definition lineage is separate from Agent instance identity. A named
36
43
  concrete Agent may declare `agent_definition version: N`; when `id:` is omitted,
37
44
  its fully-qualified Ruby class name is the stable `agent_definition_id`.
@@ -97,7 +104,7 @@ See the [migration guide](migrations/parallel-tool-chat-removal.md).
97
104
  |---|---|
98
105
  | **Workflow asynchronous pattern** — `task_state` starts a `TaskResult`, applies its successful value once to the current state entry, and then evaluates ordinary transitions; `Workflow#signal` remains the application event API | Beta |
99
106
  | **Durable Handoff** — `MultiAgent::Handoff` and `MultiAgent::HandoffRunner` persist responsibility, immutable Context and exact Target reservation in one Persistence domain; current graph wiring is required for continuation | Beta |
100
- | **GeneratorVerifier** — Generator-Verifier loop with injectable prompts/parsers | Beta |
107
+ | **GeneratorVerifier** — Generator-Verifier loop with application prompts, Agent-validated output schemas and explicit output failures | Beta |
101
108
  | **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
102
109
  | **`Phronomy::MultiAgent::TeamCoordinator`** — LLM coordinator with stateful worker Agents | Beta |
103
110
  | **`Phronomy::MultiAgent::SharedState`** — Sequential peer-agent coordination through an invocation-local findings store; see the [namespace migration](migrations/shared-state-multi-agent.md) | Experimental |
@@ -193,3 +200,5 @@ claim. See [the migration](migrations/durable-semantic-coordination-v2.md).
193
200
  turn read-only, including a current absent-but-reserved Target, without graph or
194
201
  Agent hydration. Purging an idle main Agent removes its Handoff anchor; pending
195
202
  coordination rejects purge rather than discarding its reservation.
203
+
204
+ Structured output and symmetric lifetime details: [contract and migration](architecture/structured-output-and-scope.md).
@@ -1,5 +1,8 @@
1
1
  # r8 unit15 migration
2
2
 
3
+ > Historical contract: parser injection/fallback is superseded by
4
+ > [structured output and scope](../architecture/structured-output-and-scope.md).
5
+
3
6
  ## Application impact
4
7
 
5
8
  No public API, data migration or configuration changes are required.
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  eval_gemfile "mcp_1_0.gemfile"
4
- gem "faraday", "= 2.1.0"
4
+ gem "faraday", "= 2.14.4"
5
+ gem "faraday-net_http", "= 3.4.4"
5
6
  gem "webmock", require: false
7
+
8
+ # Strict JSON decoding and schema transport use these verified minimum versions.
9
+ gem "json", "= 2.21.2"
10
+ gem "ruby_llm", "= 2.0.0"
@@ -47,7 +47,7 @@ module Phronomy
47
47
  "reservation" => correlation, "current_input_ref" => input_ref,
48
48
  "durable_context_ref" => context_ref,
49
49
  "execution_extension" => @config[:phronomy_execution_participant]&.binding&.to_h
50
- }.merge(@preparation_metadata).compact)
50
+ }.merge(@preparation_metadata).merge(@config[:phronomy_output_contract]&.metadata(records.contents) || {}).compact)
51
51
  input_record = JournalRecord.from_h(input_record.to_h.merge("execution_id" => execution_id))
52
52
  execution = execution.with(execution_revision: 0, working_records: [input_record])
53
53
  records.executions.create_active(execution)
@@ -12,12 +12,12 @@ module Phronomy
12
12
  # - approval notifications share the Agent listener as :approval_required;
13
13
  # - Recovery resolution uses resolve/resolve_async.
14
14
  module AsyncEventApi
15
- def start_reserved_async(reservation:, input:, config: {})
15
+ def start_reserved_async(reservation:, input:, config: {}, output_schema: nil)
16
16
  __with_operation(async: true) do
17
17
  unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
18
18
  raise ArgumentError, "reservation must belong to this Agent"
19
19
  end
20
- config = __invocation_config(_prepare_invocation_config(config, nil))
20
+ config = __invocation_config(_prepare_output_config(config, nil, output_schema))
21
21
  ExactExecution.start(agent: self, execution_id: reservation.execution_id, input: input,
22
22
  config: config.merge(phronomy_reservation: reservation.correlation))
23
23
  end
@@ -53,12 +53,13 @@ module Phronomy
53
53
  input,
54
54
  config: {},
55
55
  invocation_context: nil,
56
+ output_schema: nil,
56
57
  **removed_options,
57
58
  &removed_block
58
59
  )
59
60
  __with_operation do
60
61
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
61
- config = _prepare_invocation_config(config, invocation_context)
62
+ config = _prepare_output_config(config, invocation_context, output_schema)
62
63
  _check_event_loop_reentrancy(:invoke, :invoke_async)
63
64
  _start_agent_operation(
64
65
  input,
@@ -73,12 +74,13 @@ module Phronomy
73
74
  input,
74
75
  config: {},
75
76
  invocation_context: nil,
77
+ output_schema: nil,
76
78
  **removed_options,
77
79
  &removed_block
78
80
  )
79
81
  __with_operation(async: true) do
80
82
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
81
- config = _prepare_invocation_config(config, invocation_context)
83
+ config = _prepare_output_config(config, invocation_context, output_schema)
82
84
  _start_agent_operation(
83
85
  input,
84
86
  config: config,
@@ -92,13 +94,14 @@ module Phronomy
92
94
  input,
93
95
  config: {},
94
96
  invocation_context: nil,
97
+ output_schema: nil,
95
98
  **removed_options,
96
99
  &removed_block
97
100
  )
98
101
  __with_operation do
99
102
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
100
103
  listener = _required_stream_listener!(:stream)
101
- config = _prepare_invocation_config(config, invocation_context)
104
+ config = _prepare_output_config(config, invocation_context, output_schema)
102
105
  _check_event_loop_reentrancy(:stream, :stream_async)
103
106
  _start_agent_operation(
104
107
  input,
@@ -113,13 +116,14 @@ module Phronomy
113
116
  input,
114
117
  config: {},
115
118
  invocation_context: nil,
119
+ output_schema: nil,
116
120
  **removed_options,
117
121
  &removed_block
118
122
  )
119
123
  __with_operation(async: true) do
120
124
  _reject_removed_invocation_listener_arguments!(removed_options, removed_block)
121
125
  listener = _required_stream_listener!(:stream_async)
122
- config = _prepare_invocation_config(config, invocation_context)
126
+ config = _prepare_output_config(config, invocation_context, output_schema)
123
127
  _start_agent_operation(
124
128
  input,
125
129
  config: config,
@@ -143,7 +147,7 @@ module Phronomy
143
147
 
144
148
  def approve_async(execution_id, approval_request_id:, approved: true, config: {})
145
149
  __with_operation(async: true) do
146
- _reject_removed_generic_identity_keys!(config)
150
+ config = _prepare_invocation_config(config, nil)
147
151
  owner = __execution_environment.existing_registry&.agent_execution_owner(execution_id)
148
152
  coordinator = if owner&.agent&.equal?(self)
149
153
  owner.coordinator
@@ -202,6 +206,11 @@ module Phronomy
202
206
 
203
207
  private
204
208
 
209
+ def _prepare_output_config(config, invocation_context, schema)
210
+ config = _prepare_invocation_config(config, invocation_context)
211
+ schema.nil? ? config : config.merge(phronomy_output_contract: OutputContract.new(schema))
212
+ end
213
+
205
214
  def _start_agent_operation(input, config:, mode:, listener:)
206
215
  config = __invocation_config(_snapshot_durable_context(config))
207
216
  @_phronomy_execution_wiring = config.dup.freeze
@@ -101,6 +101,9 @@ module Phronomy
101
101
  end
102
102
  end
103
103
 
104
+ # Maximum logical LLM calls per execution, including the first call.
105
+ # Tool calls and SDK transport retries do not consume extra iterations.
106
+ # The limit is captured at admission; zero admits no LLM call.
104
107
  def max_iterations(val = nil)
105
108
  if val
106
109
  @max_iterations = val
@@ -727,6 +730,9 @@ module Phronomy
727
730
  def _prepare_invocation_config(config, invocation_context)
728
731
  __assert_live_agent!
729
732
  _reject_removed_generic_identity_keys!(config)
733
+ if config.keys.any? { |key| %w[output_schema response_schema phronomy_output_contract].include?(key.to_s) }
734
+ raise ArgumentError, "Use the output_schema keyword on a new Agent execution"
735
+ end
730
736
  invocation_context ?
731
737
  config.merge(invocation_context: invocation_context) : config
732
738
 
@@ -871,7 +877,8 @@ module Phronomy
871
877
  definitions = Phronomy::Tool::DefinitionSet.build(tools: invocation.tools.values).definitions
872
878
  Phronomy::LLMAdapter::Request.new(
873
879
  model_config: projection.model_config, system: projection.system,
874
- messages: invocation.messages, tools: definitions, message: projection.ask_message
880
+ messages: invocation.messages, tools: definitions, message: projection.ask_message,
881
+ response_schema: projection.response_schema
875
882
  )
876
883
  end
877
884
 
@@ -116,6 +116,10 @@ module Phronomy
116
116
  "content" => normalize(content),
117
117
  "tool_calls" => calls
118
118
  }
119
+ metadata = read_optional(message, :metadata) || {}
120
+ raise ArgumentError, "imported assistant metadata must be a Hash" unless metadata.is_a?(Hash)
121
+ Phronomy::Values::Immutable.validate_canonical_json!(metadata, label: "imported assistant metadata")
122
+ payload["metadata"] = Phronomy::Values::Immutable.copy(metadata) unless metadata.empty?
119
123
  model_id = read_optional(message, :model_id)
120
124
  payload["model_id"] = model_id.to_s if model_id
121
125
 
@@ -46,7 +46,8 @@ module Phronomy
46
46
  :rejected,
47
47
  :error
48
48
 
49
- attr_reader :execution_id,
49
+ attr_reader :final_response,
50
+ :execution_id,
50
51
  :agent,
51
52
  :config,
52
53
  :approval_policy,
@@ -73,7 +74,9 @@ module Phronomy
73
74
  @agent = agent
74
75
  @input = input
75
76
  # The caller's Hash must not become a shared mutable Runtime object.
76
- @config = config.dup
77
+ # OutputContract is admission-only; the durable Manifest owns subsequent
78
+ # schema projection. Never forward the parent promise to an Agent Tool.
79
+ @config = config.except(:phronomy_output_contract)
77
80
  resolved_execution_id = execution_id || @config[:execution_id]
78
81
  @execution_id = resolved_execution_id&.to_s&.freeze
79
82
  invocation_context = @config[:invocation_context]
@@ -269,6 +272,7 @@ module Phronomy
269
272
  raise Phronomy::Error, "LLM operation completed without a response" unless response
270
273
 
271
274
  @user_message_sent = true
275
+ @final_response = response
272
276
  @output = response.content
273
277
  @usage = response.usage
274
278
  @pending_tool_calls = []
@@ -34,6 +34,7 @@ module Phronomy
34
34
  def prepare_provider(operation)
35
35
  staged = stage_provider_call(operation)
36
36
  recorded = encode_provider_records(operation, staged)
37
+ IterationBudget.check!(recorded, default: @agent.class.max_iterations)
37
38
  assembler, prepared = prepare_provider_context(operation, recorded)
38
39
  committed, manifest, manifest_ref = commit_provider_preparation(
39
40
  operation, recorded, assembler, prepared
@@ -56,6 +57,9 @@ module Phronomy
56
57
 
57
58
  def stage_provider_call(operation)
58
59
  metadata = operation.execution.metadata.dup
60
+ metadata[IterationBudget::LIMIT_KEY] = IterationBudget.limit_for(
61
+ operation.execution, default: @agent.class.max_iterations
62
+ )
59
63
  metadata.delete(ExecutionMetadata::TOOL_BATCH_METADATA_KEY)
60
64
  metadata.delete(ExecutionMetadata::RECOVERY_METADATA_KEY)
61
65
  metadata[ExecutionMetadata::PENDING_LLM_ID_KEY] = operation.pending_llm_call_id
@@ -118,7 +122,8 @@ module Phronomy
118
122
  def commit_provider_preparation(operation, recorded, assembler, prepared)
119
123
  manifest, manifest_ref = @persistence.transaction do |tx|
120
124
  assert_local_durable_base!(tx, operation.root)
121
- Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents)
125
+ Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
126
+ response_schema_ref: recorded.metadata.dig("output_contract", "schema_ref"))
122
127
  end
123
128
  updated = execution_with_provider_manifest(recorded, manifest_ref)
124
129
  change = ExecutionChange.new(agent: @agent, current: operation.execution,
@@ -121,6 +121,12 @@ module Phronomy
121
121
  if expected && stored != expected
122
122
  raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
123
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
124
130
  ref = execution.metadata["current_input_ref"]
125
131
  @input = @agent.persistence.contents.fetch_text(ref) if @resume_only && ref
126
132
  if ref && @agent.persistence.contents.fetch_text(ref) != @input
@@ -168,6 +174,9 @@ module Phronomy
168
174
 
169
175
  def materialize(execution)
170
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
171
180
  result.merge(output: result[:result]).freeze
172
181
  end
173
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
@@ -254,6 +256,8 @@ module Phronomy
254
256
  apply_terminal_commit_on_event_loop(command)
255
257
  when DeferredTerminalCommand
256
258
  resume_deferred_terminal_on_event_loop(command)
259
+ when ExecutionCancellation::Command, SuspensionCancelledCommand
260
+ cancel_suspended_on_event_loop(command.execution_id)
257
261
  else
258
262
  raise Phronomy::Error, "unknown Agent control command: #{command.class}"
259
263
  end
@@ -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,
@@ -911,11 +916,17 @@ 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)
@@ -940,6 +951,9 @@ module Phronomy
940
951
  execution_id: state.execution_id,
941
952
  state: submitted ? :recovery_required : :suspended
942
953
  )
954
+ if !submitted && suspended_cancellation
955
+ bind_suspended_cancellation(*suspended_cancellation.first(3))
956
+ end
943
957
  end
944
958
  fail_task(request.result_task, error)
945
959
  raise unless resume_transition_started
@@ -1043,6 +1057,7 @@ module Phronomy
1043
1057
  Phronomy::Tracing::Automatic.observe_task(
1044
1058
  request.result_task,
1045
1059
  "agent.execution",
1060
+ usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
1046
1061
  agent_id: @agent.agent_id,
1047
1062
  execution_id: operation.execution_id,
1048
1063
  mode: trace_mode,
@@ -1373,6 +1388,7 @@ module Phronomy
1373
1388
  TerminalView.new(
1374
1389
  phase: invocation&.phase,
1375
1390
  output: invocation&.output,
1391
+ response: invocation&.final_response,
1376
1392
  usage: invocation&.usage,
1377
1393
  approval_request: invocation&.approval_request,
1378
1394
  rejected: invocation ? !!invocation.rejected : false,
@@ -1491,6 +1507,7 @@ module Phronomy
1491
1507
  execution_id: operation.execution_id,
1492
1508
  state: :suspended
1493
1509
  )
1510
+ bind_suspended_cancellation(operation.execution_id, state.invocation, delivery.result_task)
1494
1511
  dispatch_approval_listener(delivery.approval_listener, outcome.approval_request)
1495
1512
  callback_error = deliver_terminal(
1496
1513
  delivery.application_listener,
@@ -1507,6 +1524,46 @@ module Phronomy
1507
1524
  end
1508
1525
  end
1509
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))
1537
+ end
1538
+ end
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
+
1510
1567
  def deliver_completed_execution_on_event_loop(ready, state, environment)
1511
1568
  operation = ready.operation
1512
1569
  delivery = ready.delivery
@@ -1554,6 +1611,7 @@ module Phronomy
1554
1611
 
1555
1612
  def release_terminal_ownership(environment, execution_id, state)
1556
1613
  registry = environment.registry
1614
+ clear_suspended_cancellation if @suspended_cancellation&.first == execution_id
1557
1615
  registry.release_agent_execution(execution_id) if state
1558
1616
  registry.release_agent_execution_admission(@agent.agent_id, execution_id: execution_id)
1559
1617
  end
@@ -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(Phronomy::Error.diagnostic(error))
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,
@@ -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
@@ -169,6 +169,9 @@ module Phronomy
169
169
  "role" => "assistant",
170
170
  "content" => json_value(outcome.content),
171
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,
172
175
  "model_id" => outcome.metadata["model_id"]
173
176
  }.compact
174
177
  tx.contents.put_json(payload)