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
@@ -8,13 +8,20 @@ module Phronomy
8
8
  # Completed assistant turn. Tool requests do not authorize execution.
9
9
  # @api public
10
10
  class Response < Message
11
- attr_reader :usage
11
+ attr_reader :usage, :finish_reason, :refusal
12
12
 
13
13
  # @api public
14
- def initialize(content: nil, tool_calls: [], usage: Phronomy::LLMAdapter::TokenUsage.new, metadata: {}, role: :assistant)
14
+ def initialize(content: nil, tool_calls: [], usage: Phronomy::LLMAdapter::TokenUsage.new, metadata: {}, role: :assistant,
15
+ finish_reason: :unknown, refusal: nil)
15
16
  raise ArgumentError, "LLM response must be an assistant message" unless role == :assistant || role == "assistant"
16
17
  raise ArgumentError, "LLM usage must be LLMAdapter::TokenUsage" unless usage.is_a?(Phronomy::LLMAdapter::TokenUsage)
17
18
  @usage = usage
19
+ unless %w[stop tool_calls length content_filter refusal unknown].include?(finish_reason.to_s)
20
+ raise ArgumentError, "invalid LLM finish reason"
21
+ end
22
+ raise ArgumentError, "refusal must be a String or nil" unless refusal.nil? || refusal.is_a?(String)
23
+ @finish_reason = finish_reason.to_sym
24
+ @refusal = refusal&.dup&.freeze
18
25
  super(role: :assistant, content: content, tool_calls: tool_calls, metadata: metadata)
19
26
  end
20
27
 
@@ -24,12 +31,12 @@ module Phronomy
24
31
  new(role: source.fetch("role", "assistant"), content: source["content"],
25
32
  tool_calls: source.fetch("tool_calls", []).map { |call| Phronomy::Tool::CallRequest.from_h(call) },
26
33
  usage: Phronomy::LLMAdapter::TokenUsage.new(**source.fetch("usage", {}).transform_keys(&:to_sym)),
27
- metadata: source.fetch("metadata", {}))
34
+ metadata: source.fetch("metadata", {}), finish_reason: source.fetch("finish_reason", "unknown"), refusal: source["refusal"])
28
35
  end
29
36
 
30
37
  # @api public
31
38
  def to_h
32
- super.merge("usage" => usage.to_h.transform_keys(&:to_s))
39
+ super.merge("usage" => usage.to_h.transform_keys(&:to_s), "finish_reason" => finish_reason.to_s, "refusal" => refusal)
33
40
  end
34
41
  end
35
42
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # The selected native schema path cannot implement the requested contract.
6
+ # @api public
7
+ class StructuredOutputUnsupportedError < Phronomy::Error
8
+ # @api public
9
+ def initialize(message = "Native structured output is unsupported")
10
+ super(message, code: "llm.structured_output_unsupported")
11
+ end
12
+ end
13
+ end
14
+ end
@@ -4,6 +4,10 @@ require_relative "../common/error"
4
4
 
5
5
  module Phronomy
6
6
  module LLMAdapter
7
+ # A provider communication/generation failure. Optional reason code
8
+ # "llm.provider_output_parse_failed" means the provider rejected its own
9
+ # generated output format. It is not application JSON/schema validation,
10
+ # does not imply a particular HTTP status, and does not authorize replay.
7
11
  class TransportError < Phronomy::Error; end
8
12
  end
9
13
  end
@@ -12,7 +12,7 @@ module Phronomy
12
12
  Phronomy::Persistence::StorageBoundary.call do
13
13
  raise Phronomy::Storage::SerializationError, "create_active requires an active TeamExecution" unless execution.active?
14
14
  record = Codec.encode_team_execution(execution)
15
- admission do
15
+ admission(execution.team_id) do
16
16
  @view.atomic do |bound|
17
17
  entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).insert(key: execution.team_execution_id.to_s,
18
18
  revision: Integer(execution.execution_revision), attributes: attributes(execution), record: record)
@@ -42,7 +42,7 @@ module Phronomy
42
42
  raise Phronomy::Storage::SerializationError, "Execution identity mismatch"
43
43
  end
44
44
  record = Codec.encode_team_execution(execution)
45
- admission do
45
+ admission(execution.team_id) do
46
46
  @view.atomic do |bound|
47
47
  entry = bound.records(Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS).replace(key: team_execution_id.to_s,
48
48
  expected_revision: expected, next_revision: revision, attributes: attributes(execution),
@@ -92,7 +92,7 @@ module Phronomy
92
92
  conditions: [condition])
93
93
  rescue Phronomy::Storage::ConditionFailedError => error
94
94
  raise unless error.condition.equal?(condition)
95
- raise Phronomy::AgentBusyError, error.message
95
+ raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
96
96
  end
97
97
  end
98
98
 
@@ -100,11 +100,11 @@ module Phronomy
100
100
 
101
101
  def attributes(execution) = {owner: execution.team_id.to_s, active: execution.active?}
102
102
 
103
- def admission
103
+ def admission(team_id)
104
104
  yield
105
105
  rescue Phronomy::Storage::UniqueConstraintError => error
106
106
  raise unless error.resource == Phronomy::MultiAgent::Persistence::StorageSchema::EXECUTIONS.id && error.constraint == :one_active_owner
107
- raise Phronomy::AgentBusyError, error.message
107
+ raise Phronomy::MultiAgent::TeamBusyError.new(error.message, team_id: team_id), cause: error
108
108
  end
109
109
 
110
110
  def decode(entry, team_execution_id, owner: nil, revision: nil, active: nil)
@@ -179,6 +179,7 @@ module Phronomy
179
179
  # instrumentation.
180
180
  instrumented_def = instrumented_definition_for(researcher_class)
181
181
  Class.new(researcher_class) do
182
+ model researcher_class.model
182
183
  agent_definition(
183
184
  id: instrumented_def.fetch(:id),
184
185
  version: instrumented_def.fetch(:version)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../common/error"
4
+
5
+ module Phronomy
6
+ module MultiAgent
7
+ # Describes the Team itself, not a child Agent.
8
+ # @api public
9
+ class TeamBusyError < Phronomy::Error
10
+ attr_reader :team_id
11
+
12
+ def initialize(message = nil, team_id:)
13
+ @team_id = team_id.to_s.dup.freeze
14
+ super(message || "Team #{@team_id}: busy", code: "team.busy")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -188,11 +188,22 @@ module Phronomy
188
188
  begin
189
189
  store.transaction { |tx| tx.teams.create(intended) }
190
190
  rescue => error
191
- confirmed = store.teams.load(id)
191
+ begin
192
+ confirmed = store.teams.load(id)
193
+ rescue
194
+ # A failed readback cannot turn an unknown create outcome into
195
+ # a claim that the Team is absent or safe to create again.
196
+ raise error, cause: error.cause
197
+ end
192
198
  raise error unless confirmed.to_h == intended.to_h
193
199
  end
194
200
  end
195
- root = store.teams.load(id)
201
+ begin
202
+ root = store.teams.load(id)
203
+ rescue Phronomy::Persistence::NotFoundError => error
204
+ raise if create || error.is_a?(TeamNotFoundError)
205
+ raise TeamNotFoundError.new(team_id: id), cause: error
206
+ end
196
207
  unless root.team_definition_id == definition.fetch(:id) && root.team_definition_version == definition.fetch(:version)
197
208
  raise Phronomy::ConfigurationError, "Team #{id} definition mismatch"
198
209
  end
@@ -238,7 +249,7 @@ module Phronomy
238
249
  begin
239
250
  persistence.transaction do |tx, scope|
240
251
  root = tx.teams.load(team_id)
241
- raise Phronomy::AgentBusyError, "Team #{team_id} has an active run; use resume" unless tx.team_executions.list_active(team_id).empty?
252
+ raise TeamBusyError.new("Team #{team_id} has an active run; use resume", team_id: team_id) unless tx.team_executions.list_active(team_id).empty?
242
253
  intended = TeamExecution.new(team_execution_id: id, team_id: team_id,
243
254
  execution_revision: 0, status: "active", phase: "coordinator", input_ref: tx.contents.put_text(input),
244
255
  coordinator: {"agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid, "state" => "reserved"},
@@ -341,7 +352,7 @@ module Phronomy
341
352
  output = Phronomy::Values::Serializable.convert(aggregate ? aggregate.call(values) : values, unsupported_message: "Team result is not canonically serializable")
342
353
  Phronomy::CanonicalJSON.dump(output)
343
354
  rescue => error
344
- return terminal_value(finish_error(id, {"class" => error.class.name.to_s, "message" => error.message.to_s}))
355
+ return terminal_value(finish_error(id, Phronomy::Error.diagnostic(error)))
345
356
  end
346
357
  # Aggregate may be replayed only until the canonical outcome commits.
347
358
  completed = update(id) do |fresh, tx, scope|
@@ -465,7 +476,7 @@ module Phronomy
465
476
  raise Phronomy::CancellationError, "Team run #{execution.team_execution_id} cancelled" if execution.status == "cancelled"
466
477
  if execution.error_ref
467
478
  failure = persistence.contents.fetch_json(execution.error_ref)
468
- raise Phronomy::Error, "#{failure.fetch("class")}: #{failure.fetch("message")}"
479
+ raise Phronomy::Error.from_diagnostic(failure)
469
480
  end
470
481
  persistence.contents.fetch_json(execution.result_ref)
471
482
  end
@@ -514,7 +525,7 @@ module Phronomy
514
525
  Phronomy::Execution.submit(on_full: :raise) do
515
526
  team.send(:apply_operation, run_id, key, operation, arguments)
516
527
  rescue OperationRejected => error
517
- raise Phronomy::ConfigurationError, error.message
528
+ raise Phronomy::ConfigurationError.new(error.message, code: error.code)
518
529
  rescue Phronomy::CancellationError
519
530
  raise
520
531
  rescue => error
@@ -551,7 +562,9 @@ module Phronomy
551
562
  name = entry.name
552
563
  values = entry.arguments
553
564
  if name == "enqueue_task"
554
- raise OperationRejected, "Cannot enqueue after finalize" if metadata["finalized"]
565
+ if metadata["finalized"]
566
+ raise OperationRejected.new("Cannot enqueue after finalize", code: "team.enqueue_after_finalize")
567
+ end
555
568
  task = {"id" => Digest::SHA256.hexdigest(entry_id)[0, 32], "description" => values.fetch("description"), "metadata" => values["metadata"]}
556
569
  tasks << task
557
570
  output = "Task ##{tasks.length} enqueued: #{task.fetch("description")}"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/errors"
4
+
5
+ module Phronomy
6
+ module MultiAgent
7
+ # Describes the Team itself, not a child Agent.
8
+ # @api public
9
+ class TeamNotFoundError < Phronomy::Persistence::NotFoundError
10
+ attr_reader :team_id
11
+
12
+ def initialize(message = nil, team_id:)
13
+ @team_id = team_id.to_s.dup.freeze
14
+ super(message || "Team #{@team_id}: not_found", code: "team.not_found")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "json_schemer"
5
+ require "uri"
6
+ require_relative "../common/values/immutable"
7
+
8
+ module Phronomy
9
+ module OutputParser
10
+ # Strict JSON decoding and validation, without extraction or value coercion.
11
+ # @api public
12
+ class JsonSchemaParser < Base
13
+ KEYWORDS = %w[$schema $defs $ref title description type enum const anyOf
14
+ properties required additionalProperties items minItems maxItems minimum
15
+ maximum exclusiveMinimum exclusiveMaximum multipleOf minLength maxLength pattern].freeze
16
+ private_constant :KEYWORDS
17
+
18
+ # @api public
19
+ attr_reader :json_schema
20
+
21
+ # @api public
22
+ def initialize(schema:)
23
+ @json_schema = Phronomy::Values::Immutable.json(schema)
24
+ unless @json_schema.is_a?(Hash) && @json_schema["type"] == "object"
25
+ raise ArgumentError, "output schema must declare a root object"
26
+ end
27
+ validate_node!(@json_schema, [])
28
+ options = {ref_resolver: ->(_uri) { raise ArgumentError, "external output schema reference" }}
29
+ unless JSONSchemer.valid_schema?(@json_schema, **options)
30
+ raise ArgumentError, "invalid output JSON Schema"
31
+ end
32
+ @validator = JSONSchemer.schema(@json_schema, **options)
33
+ freeze
34
+ end
35
+
36
+ # @api public
37
+ def parse(text)
38
+ value = decode(text)
39
+ violations = @validator.validate(value).map do |error|
40
+ {"pointer" => error.fetch("data_pointer", ""),
41
+ "schema_pointer" => error.fetch("schema_pointer", ""),
42
+ "keyword" => error.fetch("type", "schema")}
43
+ end
44
+ unless violations.empty?
45
+ raise Phronomy::ParseError.new("Output does not satisfy its JSON schema",
46
+ code: "output_parser.schema_mismatch", details: violations)
47
+ end
48
+ value
49
+ end
50
+
51
+ private
52
+
53
+ def decode(text)
54
+ raise JSON::ParserError, "output must be JSON text" unless text.is_a?(String)
55
+ Phronomy::Values::Immutable.json(JSON.parse(text, allow_duplicate_key: false, allow_nan: false, allow_comments: false, allow_invalid_escape: false, allow_control_characters: false))
56
+ rescue JSON::ParserError, ArgumentError
57
+ raise Phronomy::ParseError.new("Output is not strict JSON",
58
+ code: "output_parser.invalid_json", details: [{"pointer" => "", "keyword" => "json"}]), cause: nil
59
+ end
60
+
61
+ def validate_node!(node, ancestors)
62
+ return if node == true || node == false
63
+ raise ArgumentError, "invalid output schema node" unless node.is_a?(Hash)
64
+ raise ArgumentError, "cyclic output schema reference" if ancestors.include?(node.object_id)
65
+ ancestors += [node.object_id]
66
+ unknown = node.keys - KEYWORDS
67
+ raise ArgumentError, "unsupported output schema keywords: #{unknown.join(", ")}" unless unknown.empty?
68
+ if node.key?("$schema") && node["$schema"] != "https://json-schema.org/draft/2020-12/schema"
69
+ raise ArgumentError, "output schemas require JSON Schema 2020-12"
70
+ end
71
+ if node.key?("pattern")
72
+ raise ArgumentError, "pattern must be a String" unless node["pattern"].is_a?(String)
73
+ begin
74
+ Regexp.new(node["pattern"])
75
+ rescue RegexpError
76
+ raise ArgumentError, "invalid output schema pattern"
77
+ end
78
+ end
79
+ %w[properties $defs].each do |key|
80
+ next unless node.key?(key)
81
+ raise ArgumentError, "#{key} must be an object" unless node[key].is_a?(Hash)
82
+ node[key].each_value { |child| validate_node!(child, ancestors) }
83
+ end
84
+ if node.key?("anyOf")
85
+ raise ArgumentError, "anyOf must be an array" unless node["anyOf"].is_a?(Array)
86
+ node["anyOf"].each { |child| validate_node!(child, ancestors) }
87
+ end
88
+ %w[items additionalProperties].each do |key|
89
+ validate_node!(node[key], ancestors) if node.key?(key)
90
+ end
91
+ validate_node!(resolve_reference(node["$ref"]), ancestors) if node.key?("$ref")
92
+ end
93
+
94
+ def resolve_reference(reference)
95
+ valid = reference.is_a?(String) && (reference == "#" || reference.start_with?("#/"))
96
+ unless valid
97
+ raise ArgumentError, "output schemas support local JSON Pointer references only"
98
+ end
99
+ URI::DEFAULT_PARSER.unescape(reference.delete_prefix("#")).split("/", -1).drop(1).reduce(@json_schema) do |node, token|
100
+ raise ArgumentError, "invalid output schema JSON Pointer" if token.match?(/~(?![01])/)
101
+ key = token.gsub("~1", "/").gsub("~0", "~")
102
+ if node.is_a?(Hash) && node.key?(key)
103
+ node.fetch(key)
104
+ elsif node.is_a?(Array) && key.match?(/\A(?:0|[1-9][0-9]*)\z/) && key.to_i < node.length
105
+ node.fetch(key.to_i)
106
+ else
107
+ raise ArgumentError, "output schema reference does not exist"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -3,5 +3,14 @@
3
3
  require_relative "../common/error"
4
4
 
5
5
  module Phronomy
6
- class ParseError < Error; end
6
+ class ParseError < Error
7
+ # @api public
8
+ attr_reader :details
9
+
10
+ # @api public
11
+ def initialize(message = nil, code: nil, details: [])
12
+ @details = Phronomy::Values::Immutable.json(details)
13
+ super(message, code: code)
14
+ end
15
+ end
7
16
  end
@@ -9,3 +9,7 @@ Phronomy::Agent::DefaultPersistence.install_factory(
9
9
  )
10
10
 
11
11
  Phronomy::Agent::ExecutionEnvironment.install_provider(-> { Phronomy::Agent::EngineEnvironment.new })
12
+
13
+ Phronomy::Agent::OutputContract.install_factory(
14
+ ->(schema) { Phronomy::OutputParser::JsonSchemaParser.new(schema: schema) }
15
+ )
@@ -7,9 +7,9 @@ module Phronomy
7
7
  class ConditionFailedError < ConflictError
8
8
  attr_reader :condition
9
9
  # @api public
10
- def initialize(condition)
10
+ def initialize(condition, code: nil)
11
11
  @condition = condition
12
- super("storage condition failed: #{condition.class.name}")
12
+ super("storage condition failed: #{condition.class.name}", code: code)
13
13
  end
14
14
  end
15
15
  end
@@ -7,10 +7,10 @@ module Phronomy
7
7
  class UniqueConstraintError < ConflictError
8
8
  attr_reader :resource, :constraint
9
9
  # @api public
10
- def initialize(resource:, constraint:)
10
+ def initialize(resource:, constraint:, code: nil)
11
11
  @resource = resource.id
12
12
  @constraint = constraint
13
- super("unique constraint #{resource.id}.#{constraint} failed")
13
+ super("unique constraint #{resource.id}.#{constraint} failed", code: code)
14
14
  end
15
15
  end
16
16
  end
@@ -84,7 +84,7 @@ RSpec.shared_examples "a Team execution repository" do
84
84
  it "atomically admits at most one active run per Team" do
85
85
  stores.multi_agent.team_executions.create_active(coordination_run)
86
86
  other = coordination_run.with(team_execution_id: SecureRandom.uuid, execution_revision: 0)
87
- expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::AgentBusyError)
87
+ expect { stores.multi_agent.team_executions.create_active(other) }.to raise_error(Phronomy::MultiAgent::TeamBusyError)
88
88
  expect(stores.multi_agent.team_executions.list_active(coordination_team.team_id).map(&:team_execution_id)).to eq([coordination_run.team_execution_id])
89
89
  end
90
90
 
@@ -383,8 +383,9 @@ module Phronomy
383
383
  if schema_error
384
384
  case self.class.on_schema_error
385
385
  when :raise
386
- raise Phronomy::ToolError,
387
- "#{self.class.name} schema error: #{schema_error}"
386
+ raise Phronomy::ToolError.new(
387
+ "#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
388
+ )
388
389
  else
389
390
  return "Schema validation failed: #{schema_error}"
390
391
  end
@@ -399,17 +400,12 @@ module Phronomy
399
400
  raise
400
401
  rescue => error
401
402
  if self.class.on_error == :suppress
402
- msg = "[Phronomy] Tool #{self.class.name} suppressed error: " \
403
- "#{error.class}: #{error.message}"
404
- if Phronomy::RuntimeSettings.current.logger
405
- Phronomy::RuntimeSettings.current.logger.warn(msg)
406
- else
407
- warn msg
408
- end
403
+ report_suppressed_error(error)
409
404
  "Tool error suppressed: #{error.message}"
410
405
  else
411
- raise Phronomy::ToolError,
412
- "#{self.class.name} execution failed: #{error.message}"
406
+ raise Phronomy::ToolError.new(
407
+ "#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
408
+ ), cause: error
413
409
  end
414
410
  end
415
411
 
@@ -462,7 +458,9 @@ module Phronomy
462
458
  if schema_error
463
459
  return Phronomy::AsyncOperation.capture(name: "#{operation_name}-schema") do
464
460
  if self.class.on_schema_error == :raise
465
- raise Phronomy::ToolError, "#{self.class.name} schema error: #{schema_error}"
461
+ raise Phronomy::ToolError.new(
462
+ "#{self.class.name} schema error: #{schema_error}", code: "tool.invalid_arguments"
463
+ )
466
464
  end
467
465
  "Schema validation failed: #{schema_error}"
468
466
  end
@@ -472,8 +470,9 @@ module Phronomy
472
470
  transform: method(:truncate_result_if_needed)) do
473
471
  source = yield(validated_args || {})
474
472
  unless source.respond_to?(:on_complete)
475
- raise Phronomy::ToolError,
476
- "#{self.class.name} asynchronous execution must return a completion handle"
473
+ raise Phronomy::ToolError.new(
474
+ "#{self.class.name} asynchronous execution must return a completion handle", code: "tool.invalid_async_result"
475
+ )
477
476
  end
478
477
  source
479
478
  end
@@ -488,23 +487,26 @@ module Phronomy
488
487
  end
489
488
 
490
489
  if self.class.on_error == :suppress
491
- message = "[Phronomy] Tool #{self.class.name} suppressed error: " \
492
- "#{error.class}: #{error.message}"
493
- if Phronomy::RuntimeSettings.current.logger
494
- Phronomy::RuntimeSettings.current.logger.warn(message)
495
- else
496
- warn message
497
- end
490
+ report_suppressed_error(error)
498
491
  "Tool error suppressed: #{error.message}"
499
492
  else
500
493
  wrapped = Phronomy::ToolError.new(
501
- "#{self.class.name} execution failed: #{error.message}"
494
+ "#{self.class.name} execution failed: #{error.message}", code: "tool.execution_failed"
502
495
  )
503
496
  wrapped.set_backtrace(error.backtrace)
504
- raise wrapped
497
+ raise wrapped, cause: error
505
498
  end
506
499
  end
507
500
 
501
+ def report_suppressed_error(error)
502
+ message = "[Phronomy] Tool #{self.class.name} suppressed error: #{error.class}: #{error.message}"
503
+ logger = Phronomy::RuntimeSettings.current.logger
504
+ logger ? logger.warn(message) : warn(message)
505
+ rescue
506
+ # A diagnostic failure must not change the selected suppression policy.
507
+ nil
508
+ end
509
+
508
510
  def execute_accepts_cancellation_token?
509
511
  method(:execute).parameters.any? do |type, name|
510
512
  name == :cancellation_token && %i[key keyreq].include?(type)
@@ -65,23 +65,23 @@ module Phronomy
65
65
  # simplecov:enable
66
66
  end
67
67
 
68
- def observe_task(task, name, input: nil, **metadata)
68
+ def observe_task(task, name, input: nil, usage: nil, **metadata)
69
69
  handle = start(name, input: input, **metadata)
70
70
  task.on_complete do |value, error|
71
71
  finish(
72
72
  handle,
73
73
  output: value,
74
- usage: task_usage(value),
74
+ usage: observed_usage(usage, value, handle),
75
75
  error: error
76
76
  )
77
77
  end
78
78
  task
79
79
  end
80
80
 
81
- def trace(name, input: nil, **metadata)
81
+ def trace(name, input: nil, usage: nil, **metadata)
82
82
  handle = start(name, input: input, **metadata)
83
83
  result = yield
84
- finish(handle, output: result, usage: task_usage(result))
84
+ finish(handle, output: result, usage: observed_usage(usage, result, handle))
85
85
  result
86
86
  rescue => operation_error
87
87
  finish(handle, error: operation_error) if defined?(handle)
@@ -122,12 +122,18 @@ module Phronomy
122
122
  end
123
123
  private_class_method :traced_error
124
124
 
125
- def task_usage(value)
126
- return nil unless value.is_a?(Hash)
125
+ # The owner of the observed result supplies its projection. Tracing never
126
+ # interprets Agent results or LLM value objects. Projection is diagnostic:
127
+ # its failure must not replace the completed operation's result/error.
128
+ def observed_usage(project, value, handle)
129
+ return nil unless handle && project
127
130
 
128
- value[:usage] || value["usage"]
131
+ project.call(value)
132
+ rescue => error
133
+ report_failure(:usage, handle.name, error, include_message: handle.trace_pii)
134
+ nil
129
135
  end
130
- private_class_method :task_usage
136
+ private_class_method :observed_usage
131
137
 
132
138
  def submit_finish(handle, attributes)
133
139
  Phronomy::Blocking.call_async do
@@ -34,15 +34,44 @@ module Phronomy
34
34
  # @return [Object] the block's return value
35
35
  # @api public
36
36
  def trace(name, input: nil, **meta)
37
- span = start_span(name, input: input, **meta)
38
- result, usage = yield span
39
- finish_span(span, output: result, usage: usage)
37
+ Base.trace_with(self, name, attributes: {input: input, **meta}) { |span| yield span }
38
+ end
39
+
40
+ # Use the public start/finish SPI, including duck-typed tracers.
41
+ # Start failures do not finish; finish is attempted once after execution.
42
+ # A secondary finish/reporting failure cannot replace the block's error.
43
+ # Set record_output: false for metadata-only operations.
44
+ # @api public
45
+ def self.trace_with(tracer, name, record_output: true, attributes: {})
46
+ span = tracer.start_span(name, **attributes)
47
+ begin
48
+ result, usage = yield span
49
+ rescue => primary
50
+ begin
51
+ tracer.finish_span(span, error: primary)
52
+ rescue => secondary
53
+ report_secondary(secondary)
54
+ end
55
+ raise
56
+ end
57
+ if record_output
58
+ tracer.finish_span(span, output: result, usage: usage)
59
+ else
60
+ tracer.finish_span(span)
61
+ end
40
62
  result
41
- rescue => e
42
- finish_span(span, error: e)
43
- raise
44
63
  end
45
64
 
65
+ def self.report_secondary(error)
66
+ Phronomy::RuntimeSettings.current.logger&.warn(
67
+ "[Phronomy::Tracing] secondary finish failure: #{error.class}: #{error.message}"
68
+ )
69
+ rescue
70
+ # Diagnostics are best effort; preserve the primary business failure.
71
+ nil
72
+ end
73
+ private_class_method :report_secondary
74
+
46
75
  # Start a new span. Must return an object that will be passed to #finish_span.
47
76
  #
48
77
  # @param name [String]
@@ -57,7 +86,8 @@ module Phronomy
57
86
  #
58
87
  # @param span [Object] the span returned by #start_span
59
88
  # @param output [Object, nil] successful output value
60
- # @param usage [Phronomy::LLMAdapter::TokenUsage, nil] token usage for this span
89
+ # @param usage [Hash{Symbol => Integer, nil}, nil] observed token counts;
90
+ # :input, :output, :cached, :cache_creation. The caller projects its domain value.
61
91
  # @param error [Exception, nil] exception if the block raised
62
92
  # @api public
63
93
  def finish_span(span, output: nil, usage: nil, error: nil)
@@ -71,9 +71,9 @@ module Phronomy
71
71
  body[:statusMessage] = error.message if error
72
72
  if usage
73
73
  body[:usage] = {
74
- input: usage.input,
75
- output: usage.output,
76
- total: (usage.input || 0) + (usage.output || 0)
74
+ input: usage[:input],
75
+ output: usage[:output],
76
+ total: (usage[:input] || 0) + (usage[:output] || 0)
77
77
  }
78
78
  end
79
79
  ingest([{type: "span-create", body: body}])
@@ -5,7 +5,8 @@ module Phronomy
5
5
  module Observation
6
6
  # Convenience wrapper that delegates to the global tracer.
7
7
  # Yields a span; the block must return [result, usage] where usage is a
8
- # Phronomy::LLMAdapter::TokenUsage or nil. Returns only the result value.
8
+ # Hash of observed token counts or nil. Returns only the result value.
9
+ # Domain objects must be projected by the caller before entering Tracing.
9
10
  #
10
11
  # When +trace_pii+ is disabled, both the input and the output (LLM response,
11
12
  # tool result) are replaced with the literal string "[REDACTED]" before being