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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -1
- data/README.md +17 -6
- data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
- data/docs/architecture/agent-iteration-budget.md +81 -0
- data/docs/architecture/dx05-agent-scope.md +9 -2
- data/docs/architecture/error-api-review.md +4 -4
- data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
- data/docs/architecture/generator-verifier-ownership.md +18 -109
- data/docs/architecture/llm-continuation-metadata.md +88 -0
- data/docs/architecture/lm-studio-output-failures.md +111 -0
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit7.md +2 -1
- data/docs/architecture/structured-output-and-scope.md +183 -0
- data/docs/architecture/tracing.md +22 -4
- data/docs/architecture/transport-error-boundaries.md +103 -0
- data/docs/architecture/trust-acceptance-followup.md +87 -0
- data/docs/features.md +10 -1
- data/docs/migrations/r8-unit15.md +3 -0
- data/gemfiles/http_minimum.gemfile +6 -1
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/async_event_api.rb +16 -7
- data/lib/phronomy/agent/base.rb +8 -1
- data/lib/phronomy/agent/context_importer.rb +4 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +9 -0
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +63 -5
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
- data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +10 -1
- data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
- data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +3 -0
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/lifecycle/scope.rb +33 -23
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +4 -2
- data/lib/phronomy/agent/output_contract.rb +64 -0
- data/lib/phronomy/agent/output_error.rb +53 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +1 -1
- data/lib/phronomy/agent/recovery/recovery_support.rb +5 -1
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
- data/lib/phronomy/agent/runtime_input.rb +15 -4
- data/lib/phronomy/agent/saved_context_reader.rb +2 -1
- data/lib/phronomy/agent/store.rb +10 -2
- data/lib/phronomy/common/values/immutable.rb +33 -0
- data/lib/phronomy/context/assembly.rb +7 -4
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +3 -1
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
- data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
- data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
- data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
- data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
- data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
- data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
- data/lib/phronomy/generation/generator_verifier.rb +170 -85
- data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
- data/lib/phronomy/llm_adapter/message.rb +4 -0
- data/lib/phronomy/llm_adapter/request.rb +17 -2
- data/lib/phronomy/llm_adapter/response.rb +11 -4
- data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
- data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
- data/lib/phronomy/multi_agent/shared_state.rb +1 -0
- data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
- data/lib/phronomy/output_parser/parse_error.rb +10 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
- data/lib/phronomy/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +2 -1
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/tracing/observation.rb +2 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +3 -3
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +1 -2
- data/scripts/api_snapshot.rb +3 -0
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent.rbs +20 -7
- data/sig/phronomy/extensions.rbs +8 -3
- data/sig/phronomy/generation/generator_verifier.rbs +70 -0
- data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
- data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
- data/sig/phronomy/llm_adapter/values.rbs +8 -2
- data/sig/phronomy/tracing/observation.rbs +1 -1
- data/sig/phronomy.rbs +2 -0
- metadata +59 -8
- data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
- data/lib/phronomy/generation/low_confidence_error.rb +0 -14
- data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
- 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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
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 :
|
|
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 [
|
|
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
|
|
75
|
-
output: usage
|
|
76
|
-
total: (usage
|
|
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
|
-
#
|
|
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
|
|
48
|
-
span.set_attribute("llm.usage.output_tokens", usage
|
|
49
|
-
total = (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)
|
|
50
50
|
span.set_attribute("llm.usage.total_tokens", total)
|
|
51
51
|
end
|
|
52
52
|
end
|
data/lib/phronomy/version.rb
CHANGED
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
|
|
data/scripts/api_snapshot.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
data/sig/phronomy/agent.rbs
CHANGED
|
@@ -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
|
|
15
|
+
attr_reader failures: Array[{ agent_id: String?, error: Exception }]
|
|
15
16
|
attr_reader result: untyped
|
|
16
|
-
|
|
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]
|
data/sig/phronomy/extensions.rbs
CHANGED
|
@@ -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,
|
|
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:
|
|
20
|
-
def trace: [A] (String name, ?input: untyped, **untyped meta) { (untyped span) -> [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
|
|