phronomy 0.29.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -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-unit15.md +3 -0
  13. data/docs/architecture/r8-unit7.md +2 -1
  14. data/docs/architecture/structured-output-and-scope.md +183 -0
  15. data/docs/architecture/tracing.md +22 -4
  16. data/docs/architecture/transport-error-boundaries.md +103 -0
  17. data/docs/architecture/trust-acceptance-followup.md +87 -0
  18. data/docs/features.md +10 -1
  19. data/docs/migrations/r8-unit15.md +3 -0
  20. data/gemfiles/http_minimum.gemfile +6 -1
  21. data/lib/phronomy/agent/admission.rb +1 -1
  22. data/lib/phronomy/agent/async_event_api.rb +16 -7
  23. data/lib/phronomy/agent/base.rb +8 -1
  24. data/lib/phronomy/agent/context_importer.rb +4 -0
  25. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  26. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  27. data/lib/phronomy/agent/execution/exact_execution.rb +9 -0
  28. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  29. data/lib/phronomy/agent/execution/execution_coordinator.rb +63 -5
  30. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  31. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  32. data/lib/phronomy/agent/execution/initial_preparation.rb +10 -1
  33. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  34. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  35. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  36. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +3 -0
  37. data/lib/phronomy/agent/execution_observation.rb +5 -5
  38. data/lib/phronomy/agent/lifecycle/scope.rb +33 -23
  39. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +4 -2
  40. data/lib/phronomy/agent/output_contract.rb +64 -0
  41. data/lib/phronomy/agent/output_error.rb +53 -0
  42. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  43. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -0
  44. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +1 -1
  45. data/lib/phronomy/agent/recovery/recovery_support.rb +5 -1
  46. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  47. data/lib/phronomy/agent/runtime_input.rb +15 -4
  48. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  49. data/lib/phronomy/agent/store.rb +10 -2
  50. data/lib/phronomy/common/values/immutable.rb +33 -0
  51. data/lib/phronomy/context/assembly.rb +7 -4
  52. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +3 -1
  53. data/lib/phronomy/engine/fsm_session.rb +16 -1
  54. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  55. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  56. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  57. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  58. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  59. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  60. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  61. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  62. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  63. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  64. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  65. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  66. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  67. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  68. data/lib/phronomy/llm_adapter/message.rb +4 -0
  69. data/lib/phronomy/llm_adapter/request.rb +17 -2
  70. data/lib/phronomy/llm_adapter/response.rb +11 -4
  71. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  72. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  73. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  74. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  75. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  76. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  77. data/lib/phronomy/tracing/automatic.rb +14 -8
  78. data/lib/phronomy/tracing/base.rb +2 -1
  79. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  80. data/lib/phronomy/tracing/observation.rb +2 -1
  81. data/lib/phronomy/tracing/open_telemetry_tracer.rb +3 -3
  82. data/lib/phronomy/version.rb +1 -1
  83. data/lib/phronomy.rb +1 -2
  84. data/scripts/api_snapshot.rb +3 -0
  85. data/scripts/migrate_generator_verifier_result.rb +18 -0
  86. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  87. data/sig/phronomy/agent.rbs +20 -7
  88. data/sig/phronomy/extensions.rbs +8 -3
  89. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  90. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  91. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  92. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  93. data/sig/phronomy/tracing/observation.rbs +1 -1
  94. data/sig/phronomy.rbs +2 -0
  95. metadata +59 -8
  96. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  97. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  98. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
  99. data/sig/phronomy/generation/low_confidence_error.rbs +0 -6
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module LLMAdapter
5
+ # RubyLLM 2.0 drops refusal fields when creating streaming Chunk values.
6
+ # Use its Protocol subclass seam on this operation's Provider only; never
7
+ # patch global SDK classes or retain an application callback across calls.
8
+ # @api private
9
+ class StructuredResponseCapture
10
+ attr_reader :refusal, :raw_finish_reason
11
+
12
+ def bind(chat)
13
+ capture = self
14
+ protocols = chat.provider.protocols.transform_values do |base|
15
+ Class.new(base) do
16
+ define_method(:build_chunk) do |data|
17
+ capture.record(data)
18
+ super(data)
19
+ end
20
+ end
21
+ end.freeze
22
+ chat.provider.define_singleton_method(:protocols) { protocols }
23
+ end
24
+
25
+ def record(data)
26
+ choice = data.dig("choices", 0) || {}
27
+ @raw_finish_reason = choice["finish_reason"] || data.dig("delta", "stop_reason") ||
28
+ data.dig("candidates", 0, "finishReason") || @raw_finish_reason
29
+ delta = choice.dig("delta", "refusal")
30
+ delta ||= data["delta"] if data["type"] == "response.refusal.delta"
31
+ @refusal = (@refusal || "") + delta if delta.is_a?(String)
32
+ final = data["response"]
33
+ return unless final.is_a?(Hash)
34
+ refusals = Array(final["output"]).flat_map { |item| Array(item["content"]) }
35
+ .select { |item| item["type"] == "refusal" }.map { |item| item["refusal"] }
36
+ @refusal = refusals.join("\n") unless refusals.empty?
37
+ @raw_finish_reason = final.dig("incomplete_details", "reason") || final["status"] || @raw_finish_reason
38
+ end
39
+ end
40
+ end
41
+ end
@@ -7,6 +7,10 @@ require_relative "../tool/call_request"
7
7
  module Phronomy
8
8
  module LLMAdapter
9
9
  # Provider-independent conversation value, with no executable capabilities.
10
+ # Metadata is immutable JSON. Backend-owned continuation values travel with
11
+ # the message through Agent persistence/import; only their owning backend
12
+ # interprets their format and replay compatibility. They grant no authority
13
+ # and are not answer content or arbitrary provider request options.
10
14
  # @api public
11
15
  class Message
12
16
  attr_reader :role, :content, :tool_calls, :tool_call_id, :metadata
@@ -7,10 +7,10 @@ module Phronomy
7
7
  # Immutable input for one model turn, including inert Tool definitions.
8
8
  # @api public
9
9
  class Request
10
- attr_reader :model_config, :system, :messages, :tools, :message
10
+ attr_reader :model_config, :system, :messages, :tools, :message, :response_schema
11
11
 
12
12
  # @api public
13
- def initialize(model_config: {}, system: nil, messages: [], tools: [], message: nil)
13
+ def initialize(model_config: {}, system: nil, messages: [], tools: [], message: nil, response_schema: nil)
14
14
  if !(model_config.is_a?(Hash) && (system.nil? || system.is_a?(String)) && (message.nil? || message.is_a?(String)))
15
15
  raise ArgumentError, "invalid LLM request configuration or text"
16
16
  end
@@ -36,6 +36,11 @@ module Phronomy
36
36
  names = normalized_tools.map { |tool| tool.fetch("name") }
37
37
  raise ArgumentError, "duplicate Tool definition name" unless names.uniq == names
38
38
  config = normalize_keys(model_config)
39
+ @response_schema = response_schema.nil? ? nil : Phronomy::Values::Immutable.json(response_schema)
40
+ if @response_schema && !@response_schema.is_a?(Hash)
41
+ raise ArgumentError, "response_schema must be a JSON object or nil"
42
+ end
43
+ reject_schema_options!(config) if @response_schema
39
44
  Phronomy::Values::Immutable.validate_canonical_json!(config, label: "LLM model configuration")
40
45
  max = config["max_output_tokens"]
41
46
  if !max.nil? && (!max.is_a?(Integer) || max <= 0)
@@ -64,6 +69,16 @@ module Phronomy
64
69
 
65
70
  private
66
71
 
72
+ def reject_schema_options!(value)
73
+ return unless value.is_a?(Hash)
74
+ value.each do |key, child|
75
+ if %w[schema output_schema response_schema response_format responseJsonSchema responseSchema].include?(key.to_s)
76
+ raise ArgumentError, "model configuration cannot override response_schema"
77
+ end
78
+ reject_schema_options!(child)
79
+ end
80
+ end
81
+
67
82
  def normalize_keys(value)
68
83
  value.each_with_object({}) do |(key, child), result|
69
84
  unless key.is_a?(String) || key.is_a?(Symbol)
@@ -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
@@ -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,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
+ )
@@ -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
@@ -86,7 +86,8 @@ module Phronomy
86
86
  #
87
87
  # @param span [Object] the span returned by #start_span
88
88
  # @param output [Object, nil] successful output value
89
- # @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.
90
91
  # @param error [Exception, nil] exception if the block raised
91
92
  # @api public
92
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
@@ -44,9 +44,9 @@ module Phronomy
44
44
  else
45
45
  span.set_attribute("phronomy.output", output.to_s) if output
46
46
  if usage
47
- span.set_attribute("llm.usage.input_tokens", usage.input)
48
- span.set_attribute("llm.usage.output_tokens", usage.output)
49
- total = (usage.input || 0) + (usage.output || 0)
47
+ span.set_attribute("llm.usage.input_tokens", usage[:input])
48
+ span.set_attribute("llm.usage.output_tokens", usage[:output])
49
+ total = (usage[:input] || 0) + (usage[:output] || 0)
50
50
  span.set_attribute("llm.usage.total_tokens", total)
51
51
  end
52
52
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.29.0"
4
+ VERSION = "0.30.0"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -12,6 +12,7 @@ loader.inflector.inflect("fsm_protocol" => "FSMProtocol")
12
12
  loader.inflector.inflect("llm_adapter" => "LLMAdapter")
13
13
  loader.inflector.inflect("llm_operation_result" => "LLMOperationResult")
14
14
  loader.inflector.inflect("ruby_llm" => "RubyLLM")
15
+ loader.inflector.inflect("lm_studio" => "LMStudio")
15
16
  loader.inflector.inflect("canonical_json" => "CanonicalJSON")
16
17
  loader.inflector.inflect("llm_call_record" => "LLMCallRecord")
17
18
  loader.inflector.inflect("llm_input_manifest" => "LLMInputManifest")
@@ -60,7 +61,6 @@ loader.ignore(
60
61
  "#{__dir__}/phronomy/runtime_composition/global_configuration.rb",
61
62
  "#{__dir__}/phronomy/agent/composition",
62
63
  "#{__dir__}/phronomy/runtime_composition/agent_defaults.rb",
63
- "#{__dir__}/phronomy/runtime_composition/generation_defaults.rb",
64
64
  "#{__dir__}/phronomy/runtime_composition/evaluation_defaults.rb",
65
65
  "#{__dir__}/phronomy/runtime_composition/workflow_defaults.rb",
66
66
  "#{__dir__}/phronomy/runtime_composition/multi_agent_defaults.rb",
@@ -116,7 +116,6 @@ require_relative "phronomy/agent/api/agent"
116
116
  require_relative "phronomy/runtime_composition/agent_defaults"
117
117
  require_relative "phronomy/runtime_composition/multi_agent_defaults"
118
118
  require_relative "phronomy/runtime_composition/workflow_defaults"
119
- require_relative "phronomy/runtime_composition/generation_defaults"
120
119
  require_relative "phronomy/runtime_composition/evaluation_defaults"
121
120
  require_relative "phronomy/agent/composition/run_once"
122
121
 
@@ -23,6 +23,7 @@ PUBLIC_API_ENTRIES = [
23
23
  Phronomy::AgentUnloadedError,
24
24
  Phronomy::Agent,
25
25
  Phronomy::Agent::Scope,
26
+ Phronomy::Agent::OutputError,
26
27
  Phronomy::Agent::ScopeCleanupError,
27
28
  Phronomy::MultiAgent::TeamNotFoundError,
28
29
  Phronomy::MultiAgent::TeamBusyError,
@@ -57,6 +58,8 @@ PUBLIC_API_ENTRIES = [
57
58
  Phronomy::Tool::Operation,
58
59
  Phronomy::LLMAdapter::Base,
59
60
  Phronomy::LLMAdapter::Request,
61
+ Phronomy::LLMAdapter::StructuredOutputUnsupportedError,
62
+ Phronomy::OutputParser::JsonSchemaParser,
60
63
  Phronomy::LLMAdapter::Message,
61
64
  Phronomy::LLMAdapter::Response,
62
65
  Phronomy::LLMAdapter::StreamChunk,
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Explicit one-time conversion of a pre-structured-output Result JSON object.
5
+ # Never infer old formats in the runtime reader; never overwrite the source.
6
+ require_relative "../lib/phronomy"
7
+
8
+ abort "Usage: ruby scripts/migrate_generator_verifier_result.rb OLD.json NEW.json" unless ARGV.length == 2
9
+ source, destination = ARGV
10
+ value = JSON.parse(File.read(source), allow_duplicate_key: false, allow_nan: false,
11
+ allow_comments: false, allow_invalid_escape: false, allow_control_characters: false)
12
+ abort "Expected an old Result object without failure" unless value.is_a?(Hash) && !value.key?("failure")
13
+ value["failure"] = nil
14
+ result = Phronomy::GeneratorVerifier::Result.from_h(value)
15
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL) do |file|
16
+ file.write(JSON.pretty_generate(result.to_h))
17
+ file.write("\n")
18
+ end
@@ -2,7 +2,7 @@ module Phronomy
2
2
 
3
3
  module Agent
4
4
  type execution_wiring = { ?phronomy_execution_participant: _ExecutionParticipant, ?phronomy_control_bindings: Array[_ControlCapability], ?phronomy_transfer_context: TransferContext, ?phronomy_admission: _AdmissionOwner, ?phronomy_reservation: Hash[String, json_value] }
5
- type execution_result = { agent_id: String, execution_id: String, status: Symbol, result: String?, output: String?, error: Hash[String, json_value]? }
5
+ type execution_result = { agent_id: String, execution_id: String, status: Symbol, result: json_value, output: json_value, ?raw_output: String?, error: Hash[String, json_value]? }
6
6
 
7
7
  class ReservedExecution < Data
8
8
  attr_reader agent_id: String
@@ -15,12 +15,13 @@ module Phronomy
15
15
  attr_reader agent_id: String
16
16
  attr_reader execution_id: String
17
17
  attr_reader status: Symbol
18
- attr_reader result: String?
18
+ attr_reader result: json_value
19
+ attr_reader raw_output: String?
19
20
  attr_reader error: Hash[String, json_value]?
20
21
  attr_reader reservation: ReservedExecution?
21
22
  attr_reader transfer_receipt: Hash[String, json_value]?
22
23
  attr_reader cancellation_requested: bool
23
- def self.new: (agent_id: String, execution_id: String, status: Symbol, result: String?, error: Hash[String, json_value]?, reservation: ReservedExecution?, transfer_receipt: Hash[String, json_value]?, cancellation_requested: bool) -> instance
24
+ def self.new: (agent_id: String, execution_id: String, status: Symbol, result: json_value, ?raw_output: String?, error: Hash[String, json_value]?, reservation: ReservedExecution?, transfer_receipt: Hash[String, json_value]?, cancellation_requested: bool) -> instance
24
25
  def self.from_result: (execution_result) -> instance
25
26
  def absent?: () -> bool
26
27
  def terminal?: () -> bool
@@ -4,6 +4,7 @@ module Phronomy
4
4
  def self.open_scope: [R] () { (Scope) -> (R | Phronomy::TaskResult[R]) } -> R
5
5
 
6
6
  class Scope
7
+ def release!: () -> bool
7
8
  def create: (singleton(Base) agent_class, **untyped options) ?{ (untyped event) -> void } -> Base
8
9
  def create_async: (singleton(Base) agent_class, **untyped options) ?{ (untyped event) -> void } -> Phronomy::TaskResult[Base]
9
10
  def load: (singleton(Base) agent_class, String agent_id, persistence: Store, **untyped options) ?{ (untyped event) -> void } -> Base
@@ -11,9 +12,21 @@ module Phronomy
11
12
  end
12
13
 
13
14
  class ScopeCleanupError < Phronomy::Error
14
- attr_reader failures: Array[{ agent_id: String, error: Exception }]
15
+ attr_reader failures: Array[{ agent_id: String?, error: Exception }]
15
16
  attr_reader result: untyped
16
- def initialize: (failures: Array[{ agent_id: String, error: Exception }], result: untyped) -> void
17
+ attr_reader scope: Scope
18
+ attr_reader primary_error: Exception?
19
+ def initialize: (scope: Scope, failures: Array[{ agent_id: String?, error: Exception }], ?result: untyped, ?primary_error: Exception?) -> void
20
+ end
21
+
22
+ class OutputError < Phronomy::Error
23
+ attr_reader execution_id: String
24
+ attr_reader schema_digest: String
25
+ attr_reader reason: String
26
+ attr_reader violations: Array[Hash[String, untyped]]
27
+ attr_reader raw_output: untyped
28
+ def initialize: (execution_id: String, schema_digest: String, reason: String | Symbol,
29
+ ?violations: Array[Hash[String, untyped]], ?raw_output: untyped) -> void
17
30
  end
18
31
 
19
32
  interface _BeforeLLMInputHook
@@ -95,7 +108,7 @@ module Phronomy
95
108
  attr_reader persistence: Store
96
109
  def knowledge_snapshot: () -> Array[KnowledgeItem]
97
110
  def event_listener: () -> (^(Object) -> void)?
98
- def start_reserved_async: (reservation: ReservedExecution, input: String, ?config: Hash[Symbol, Object]) -> TaskResult[execution_result]
111
+ def start_reserved_async: (reservation: ReservedExecution, input: String, ?config: Hash[Symbol, Object], ?output_schema: Hash[String | Symbol, untyped]?) -> TaskResult[execution_result]
99
112
  def resume_async: (String execution_id, ?config: Hash[Symbol, Object]) -> TaskResult[execution_result]
100
113
  def resume: (String execution_id, ?config: Hash[Symbol, Object]) -> execution_result
101
114
  def cancel_async: (String execution_id) -> TaskResult[ExecutionObservation]
@@ -103,10 +116,10 @@ module Phronomy
103
116
 
104
117
  # Public operation methods no longer accept per-invocation event listeners
105
118
  # or event-listener blocks.
106
- def invoke: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped) -> Hash[Symbol, untyped]
107
- def invoke_async: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped) -> TaskResult[Hash[Symbol, untyped]]
108
- def stream: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped) -> Hash[Symbol, untyped]
109
- def stream_async: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped) -> TaskResult[Hash[Symbol, untyped]]
119
+ def invoke: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped, ?output_schema: Hash[String | Symbol, untyped]?) -> Hash[Symbol, untyped]
120
+ def invoke_async: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped, ?output_schema: Hash[String | Symbol, untyped]?) -> TaskResult[Hash[Symbol, untyped]]
121
+ def stream: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped, ?output_schema: Hash[String | Symbol, untyped]?) -> Hash[Symbol, untyped]
122
+ def stream_async: (untyped input, ?config: Hash[untyped, untyped], ?invocation_context: untyped, ?output_schema: Hash[String | Symbol, untyped]?) -> TaskResult[Hash[Symbol, untyped]]
110
123
 
111
124
  def approve: (String execution_id, approval_request_id: String, ?approved: bool, ?config: Hash[untyped, untyped]) -> untyped
112
125
  def approve_async: (String execution_id, approval_request_id: String, ?approved: bool, ?config: Hash[untyped, untyped]) -> TaskResult[untyped]
@@ -6,6 +6,11 @@ module Phronomy
6
6
  end
7
7
 
8
8
  module OutputParser
9
+ class JsonSchemaParser < Base
10
+ attr_reader json_schema: Hash[String, untyped]
11
+ def initialize: (schema: Hash[String | Symbol, untyped]) -> void
12
+ def parse: (String text) -> Hash[String, json_value]
13
+ end
9
14
  class Base
10
15
  def parse: (String text) -> untyped
11
16
  def invoke: (untyped input, ?config: Hash[untyped, untyped]) -> untyped
@@ -14,10 +19,10 @@ module Phronomy
14
19
 
15
20
  module Tracing
16
21
  class Base
17
- def self.trace_with: [A] (untyped tracer, String name, ?record_output: bool, ?attributes: Hash[Symbol, untyped]) { (untyped span) -> [A, untyped] } -> A
22
+ def self.trace_with: [A] (untyped tracer, String name, ?record_output: bool, ?attributes: Hash[Symbol, untyped]) { (untyped span) -> [A, Hash[Symbol, Integer?]?] } -> A
18
23
  def start_span: (String name, **untyped attributes) -> untyped
19
- def finish_span: (untyped span, ?output: untyped, ?usage: untyped, ?error: Exception?) -> untyped
20
- def trace: [A] (String name, ?input: untyped, **untyped meta) { (untyped span) -> [A, untyped] } -> A
24
+ def finish_span: (untyped span, ?output: untyped, ?usage: Hash[Symbol, Integer?]?, ?error: Exception?) -> untyped
25
+ def trace: [A] (String name, ?input: untyped, **untyped meta) { (untyped span) -> [A, Hash[Symbol, Integer?]?] } -> A
21
26
  end
22
27
  end
23
28