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
@@ -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
@@ -15,24 +15,20 @@ module Phronomy
15
15
 
16
16
  scope = new(environment: ExecutionEnvironment.current)
17
17
  result = nil
18
+ primary = nil
18
19
  begin
19
- result = yield(scope)
20
- result = result.wait_result if result.is_a?(Phronomy::TaskResult)
21
- rescue Exception => primary # standard:disable Lint/RescueException
20
+ returned = yield(scope)
21
+ result = returned.is_a?(Phronomy::TaskResult) ? returned.wait_result : returned
22
+ rescue Exception => error # standard:disable Lint/RescueException
23
+ primary = error
22
24
  raise
23
25
  ensure
24
- failures = scope.send(:__close)
25
- if primary
26
- failures.each do |failure|
27
- error = failure.fetch(:error)
28
- Phronomy::RuntimeSettings.current.logger&.warn(
29
- "[Agent scope] cleanup failed agent_id=#{failure.fetch(:agent_id).inspect}: #{error.class}: #{error.message}"
30
- )
31
- rescue
32
- nil
33
- end
34
- elsif failures.any?
35
- raise ScopeCleanupError.new(failures: failures, result: result)
26
+ begin
27
+ scope.release!
28
+ rescue => cleanup
29
+ failures = cleanup.is_a?(ScopeCleanupError) ? cleanup.failures : [{agent_id: nil, error: cleanup}]
30
+ raise ScopeCleanupError.new(scope: scope, failures: failures, result: result,
31
+ primary_error: primary), cause: primary || failures.first.fetch(:error)
36
32
  end
37
33
  end
38
34
  result
@@ -83,27 +79,41 @@ module Phronomy
83
79
  Phronomy::TaskResult.failed(error)
84
80
  end
85
81
 
86
- # @api private
87
- def __close
82
+ # Release only safe owners; retain failed owners so the caller can retry.
83
+ # @api public
84
+ def release!
85
+ owns_release = false
86
+ check_blocking!
88
87
  agents = @mutex.synchronize do
89
- @state = :closing
88
+ return false if @state == :released
89
+ raise Phronomy::Error.new("Agent scope is releasing", code: "agent.scope_busy") if @state == :releasing
90
+ @state = :releasing
91
+ owns_release = true
90
92
  @condition.wait(@mutex) until @receipts.empty?
91
93
  @agents.values.dup
92
94
  end
93
95
  failures = []
94
96
  agents.each do |agent|
95
97
  agent.unload!
98
+ @mutex.synchronize { @agents.delete(agent.object_id) }
96
99
  rescue => error
97
100
  failures << {agent_id: agent.agent_id, error: error}.freeze
98
101
  end
99
102
  @mutex.synchronize do
100
- @agents.clear
101
- @environment = @ownership = @owner_token = nil
102
- @state = :closed
103
+ @state = failures.empty? ? :released : :release_failed
104
+ @environment = @ownership = @owner_token = nil if failures.empty?
105
+ end
106
+ unless failures.empty?
107
+ raise ScopeCleanupError.new(scope: self, failures: failures), cause: failures.first.fetch(:error)
108
+ end
109
+ true
110
+ ensure
111
+ # An interrupted waiter/unloader must remain retryable, without changing
112
+ # another thread's release state when this call was rejected as busy.
113
+ if owns_release
114
+ @mutex.synchronize { @state = :release_failed if @state == :releasing }
103
115
  end
104
- failures.freeze
105
116
  end
106
- private :__close
107
117
 
108
118
  private
109
119
 
@@ -6,11 +6,13 @@ module Phronomy
6
6
  # Original exceptions and the successful result remain available to the caller.
7
7
  # @api public
8
8
  class ScopeCleanupError < Phronomy::Error
9
- attr_reader :failures, :result
9
+ attr_reader :failures, :result, :primary_error, :scope
10
10
 
11
- def initialize(failures:, result:)
11
+ def initialize(scope:, failures:, result: nil, primary_error: nil)
12
12
  @failures = failures.map { |failure| failure.dup.freeze }.freeze
13
13
  @result = result
14
+ @primary_error = primary_error
15
+ @scope = scope
14
16
  super("Agent scope cleanup failed for: #{@failures.map { |f| f.fetch(:agent_id) }.join(", ")}",
15
17
  code: "agent.scope_cleanup_failed")
16
18
  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
 
@@ -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
@@ -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: (
@@ -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
@@ -30,7 +30,11 @@ module Phronomy
30
30
  end
31
31
 
32
32
  def error_from_failure(failure)
33
- Phronomy::Error.from_diagnostic(failure)
33
+ if failure["class"] == "Phronomy::Agent::OutputError"
34
+ OutputError.restore(failure)
35
+ else
36
+ Phronomy::Error.from_diagnostic(failure)
37
+ end
34
38
  end
35
39
 
36
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.
@@ -11,6 +11,39 @@ module Phronomy
11
11
  module Immutable
12
12
  module_function
13
13
 
14
+ # Normalize JSON object names without coercing values or losing duplicates.
15
+ # @api private
16
+ def json(value, ancestors: [])
17
+ if (value.is_a?(Hash) || value.is_a?(Array)) && ancestors.include?(value.object_id)
18
+ raise ArgumentError, "cyclic JSON value"
19
+ end
20
+ children = ancestors + [value.object_id]
21
+ normalized = case value
22
+ when Hash
23
+ value.each_with_object({}) do |(key, child), result|
24
+ unless key.is_a?(String) || key.is_a?(Symbol)
25
+ raise ArgumentError, "JSON object keys must be Strings or Symbols"
26
+ end
27
+ name = key.to_s
28
+ raise ArgumentError, "duplicate JSON object key: #{name}" if result.key?(name)
29
+ result[name.dup.freeze] = json(child, ancestors: children)
30
+ end
31
+ when Array then value.map { |child| json(child, ancestors: children) }
32
+ when String
33
+ text = value.encode(Encoding::UTF_8)
34
+ raise ArgumentError, "invalid UTF-8 JSON string" unless text.valid_encoding?
35
+ text.dup
36
+ when Integer, TrueClass, FalseClass, NilClass then value
37
+ when Float
38
+ raise ArgumentError, "JSON numbers must be finite" unless value.finite?
39
+ value
40
+ else raise ArgumentError, "unsupported JSON value: #{value.class}"
41
+ end
42
+ normalized.freeze
43
+ rescue EncodingError
44
+ raise ArgumentError, "invalid UTF-8 JSON string"
45
+ end
46
+
14
47
  # @api private
15
48
  def copy(value)
16
49
  case value
@@ -34,7 +34,7 @@ module Phronomy
34
34
  )
35
35
  end
36
36
 
37
- def store(prepared, contents:)
37
+ def store(prepared, contents:, response_schema_ref: nil)
38
38
  unless prepared.is_a?(Prepared)
39
39
  raise ArgumentError, "Context::Assembly#store expected Context::Assembly::Prepared"
40
40
  end
@@ -80,7 +80,7 @@ module Phronomy
80
80
  ).validate!(
81
81
  token_budget: prepared.input.token_budget,
82
82
  segments: segments,
83
- extra_values: [selected_tool_definitions]
83
+ extra_values: [selected_tool_definitions, response_schema_ref && contents.fetch_json(response_schema_ref)].compact
84
84
  )
85
85
 
86
86
  store_manifest(
@@ -90,7 +90,8 @@ module Phronomy
90
90
  segments: segments,
91
91
  model_config_ref: contents.put_json(prepared.model_config),
92
92
  tool_definitions_ref: contents.put_json(selected_tool_definitions),
93
- adapter_identity: prepared.adapter_identity
93
+ adapter_identity: prepared.adapter_identity,
94
+ response_schema_ref: response_schema_ref
94
95
  )
95
96
  end
96
97
 
@@ -117,7 +118,8 @@ module Phronomy
117
118
  segments:,
118
119
  model_config_ref:,
119
120
  tool_definitions_ref:,
120
- adapter_identity:
121
+ adapter_identity:,
122
+ response_schema_ref:
121
123
  )
122
124
  positioned = segments.each_with_index.map do |value, position|
123
125
  Phronomy::Context::LLMInputManifest::Segment.new(**value.merge(position: position))
@@ -128,6 +130,7 @@ module Phronomy
128
130
  segments: positioned,
129
131
  model_config_ref: model_config_ref,
130
132
  tool_definitions_ref: tool_definitions_ref,
133
+ response_schema_ref: response_schema_ref,
131
134
  assembly_policy_version: ASSEMBLY_POLICY_VERSION,
132
135
  ruby_llm_version: adapter_identity["sdk_version"],
133
136
  adapter_name: adapter_identity["adapter_name"]
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "faraday"
4
+
3
5
  module Phronomy
4
6
  module Embeddings
5
7
  # Embeddings adapter backed by RubyLLM.
@@ -44,7 +46,7 @@ module Phronomy
44
46
  raise AuthenticationError.new(error.message, code: "embeddings.authentication_failed"), cause: error
45
47
  rescue ::RubyLLM::RateLimitError => error
46
48
  raise RateLimitError.new(error.message, code: "embeddings.rate_limited"), cause: error
47
- rescue ::RubyLLM::Error => error
49
+ rescue ::RubyLLM::Error, ::Faraday::TimeoutError, ::Faraday::ConnectionFailed => error
48
50
  raise TransportError.new(error.message), cause: error
49
51
  end
50
52
  end
@@ -93,6 +93,7 @@ module Phronomy
93
93
  phase_machine_class:,
94
94
  recursion_limit:,
95
95
  event_loop:,
96
+ recursion_limit_scope: :session,
96
97
  resume_event: nil,
97
98
  resume_phase: nil,
98
99
  stable_observer: nil,
@@ -123,6 +124,11 @@ module Phronomy
123
124
  @external_events = external_events
124
125
  @phase_machine_class = phase_machine_class
125
126
  @recursion_limit = recursion_limit
127
+ unless %i[session automatic_chain].include?(recursion_limit_scope)
128
+ raise ArgumentError, "unknown recursion_limit_scope: #{recursion_limit_scope.inspect}"
129
+ end
130
+ @recursion_limit_scope = recursion_limit_scope
131
+ @automatic_steps = 0
126
132
  @event_loop = event_loop
127
133
  @resume_event = resume_event
128
134
  @resume_phase = resume_phase
@@ -230,7 +236,13 @@ module Phronomy
230
236
  end
231
237
 
232
238
  def fire_and_advance!(event)
233
- if @step >= @recursion_limit
239
+ automatic = event.type == :state_completed
240
+ exhausted = if @recursion_limit_scope == :automatic_chain
241
+ automatic && @automatic_steps >= @recursion_limit
242
+ else
243
+ @step >= @recursion_limit
244
+ end
245
+ if exhausted
234
246
  raise Phronomy::RecursionLimitError,
235
247
  "Recursion limit (#{@recursion_limit}) exceeded"
236
248
  end
@@ -246,6 +258,9 @@ module Phronomy
246
258
  apply_context_metadata!
247
259
  @current_state = @tracker.phase.to_sym
248
260
  @step += 1
261
+ # Only an accepted external transition starts a fresh automatic chain.
262
+ # Consumed/stale events and events with no transition cannot reset it.
263
+ @automatic_steps = automatic ? @automatic_steps + 1 : 0
249
264
  advance_or_halt
250
265
  ensure
251
266
  @tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
@@ -8,23 +8,27 @@ module Phronomy
8
8
  # still pending. Generation owns the request-to-Workflow correlation.
9
9
  # @api private
10
10
  class AgentOperation
11
- def initialize(agent_class)
11
+ def initialize(agent_class, output_schema:)
12
+ @output_schema = output_schema
12
13
  @mutex = Mutex.new
13
14
  @pending = []
14
15
  @agent = agent_class.new(on_event: method(:receive_event))
15
16
  end
16
17
 
17
- def start(input, listener:)
18
+ def start(input, listener:, config: {})
18
19
  request = Object.new
19
20
  # Workflow entry calls are serial. Keep submitted requests until their
20
21
  # own result settles: Agent admission itself may run after an earlier
21
22
  # request completes, so a pending submission is not necessarily busy.
22
23
  @mutex.synchronize { @pending << [request, listener] }
23
- task = @agent.invoke_async(input)
24
+ task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
24
25
  task.on_complete do |result, error|
25
26
  release(request)
26
- # Handoff was not a generation completion in the event-based path.
27
- next if !error && result.is_a?(Hash) && result[:handoff_request]
27
+ remember_execution(result[:execution_id]) if result.is_a?(Hash)
28
+ if !error && result.is_a?(Hash) && result[:handoff_request]
29
+ error = Phronomy::Error.new("GeneratorVerifier does not coordinate Handoff",
30
+ code: "generation.unsupported_handoff")
31
+ end
28
32
 
29
33
  event = if error
30
34
  type = if error.is_a?(Phronomy::TimeoutError)
@@ -46,10 +50,29 @@ module Phronomy
46
50
  raise
47
51
  end
48
52
 
53
+ def approve_async(request, approved:)
54
+ @agent.approve_async(request.execution_id, approval_request_id: request.id, approved: approved)
55
+ end
56
+
57
+ def cancel_async
58
+ execution_id = @mutex.synchronize { @execution_id }
59
+ @agent.cancel_async(execution_id) if execution_id
60
+ end
61
+
62
+ def unload!
63
+ @agent.unload!
64
+ end
65
+
49
66
  private
50
67
 
68
+ def remember_execution(execution_id)
69
+ @mutex.synchronize { @execution_id = execution_id } if execution_id
70
+ end
71
+
51
72
  def receive_event(event)
52
73
  return unless event.type == :approval_required
74
+ request = event.payload[:request]
75
+ remember_execution(request.execution_id) if request.respond_to?(:execution_id)
53
76
 
54
77
  listener = @mutex.synchronize { @pending.first&.last }
55
78
  listener&.call(event)