phronomy 0.29.0 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -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-unit1.md +68 -68
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit2.md +34 -34
- 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/decisions/029-semantic-completion-and-application-effect-boundary.md +1 -1
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +23 -23
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +99 -99
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +135 -136
- 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
|
@@ -5,14 +5,14 @@ module Phronomy
|
|
|
5
5
|
# Stable domain observation; does not expose repositories or FSM phases.
|
|
6
6
|
# @api public
|
|
7
7
|
ExecutionObservation = Data.define(:agent_id, :execution_id, :status, :result,
|
|
8
|
-
:error, :reservation, :transfer_receipt, :cancellation_requested) do
|
|
9
|
-
def initialize(**values)
|
|
10
|
-
super(**values.transform_values { |value| Phronomy::Values::Immutable.copy(value) })
|
|
8
|
+
:error, :reservation, :transfer_receipt, :cancellation_requested, :raw_output) do
|
|
9
|
+
def initialize(raw_output: nil, **values)
|
|
10
|
+
super(raw_output: Phronomy::Values::Immutable.copy(raw_output), **values.transform_values { |value| Phronomy::Values::Immutable.copy(value) })
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def self.from_result(value)
|
|
14
14
|
new(agent_id: value.fetch(:agent_id), execution_id: value.fetch(:execution_id), status: value.fetch(:status),
|
|
15
|
-
result: value[:result], error: value[:error], reservation: nil, transfer_receipt: nil, cancellation_requested: false)
|
|
15
|
+
result: value[:result], raw_output: value[:raw_output], error: value[:error], reservation: nil, transfer_receipt: nil, cancellation_requested: false)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def absent? = status == :absent
|
|
@@ -28,7 +28,7 @@ module Phronomy
|
|
|
28
28
|
|
|
29
29
|
def to_result
|
|
30
30
|
Phronomy::Values::Immutable.copy(execution_id: execution_id, agent_id: agent_id,
|
|
31
|
-
status: status, result: result, output: result, error: error)
|
|
31
|
+
status: status, result: result, output: result, raw_output: raw_output, error: error)
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
end
|
|
@@ -15,24 +15,20 @@ module Phronomy
|
|
|
15
15
|
|
|
16
16
|
scope = new(environment: ExecutionEnvironment.current)
|
|
17
17
|
result = nil
|
|
18
|
+
primary = nil
|
|
18
19
|
begin
|
|
19
|
-
|
|
20
|
-
result =
|
|
21
|
-
rescue Exception =>
|
|
20
|
+
returned = yield(scope)
|
|
21
|
+
result = returned.is_a?(Phronomy::TaskResult) ? returned.wait_result : returned
|
|
22
|
+
rescue Exception => error # standard:disable Lint/RescueException
|
|
23
|
+
primary = error
|
|
22
24
|
raise
|
|
23
25
|
ensure
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
)
|
|
31
|
-
rescue
|
|
32
|
-
nil
|
|
33
|
-
end
|
|
34
|
-
elsif failures.any?
|
|
35
|
-
raise ScopeCleanupError.new(failures: failures, result: result)
|
|
26
|
+
begin
|
|
27
|
+
scope.release!
|
|
28
|
+
rescue => cleanup
|
|
29
|
+
failures = cleanup.is_a?(ScopeCleanupError) ? cleanup.failures : [{agent_id: nil, error: cleanup}]
|
|
30
|
+
raise ScopeCleanupError.new(scope: scope, failures: failures, result: result,
|
|
31
|
+
primary_error: primary), cause: primary || failures.first.fetch(:error)
|
|
36
32
|
end
|
|
37
33
|
end
|
|
38
34
|
result
|
|
@@ -83,27 +79,41 @@ module Phronomy
|
|
|
83
79
|
Phronomy::TaskResult.failed(error)
|
|
84
80
|
end
|
|
85
81
|
|
|
86
|
-
#
|
|
87
|
-
|
|
82
|
+
# Release only safe owners; retain failed owners so the caller can retry.
|
|
83
|
+
# @api public
|
|
84
|
+
def release!
|
|
85
|
+
owns_release = false
|
|
86
|
+
check_blocking!
|
|
88
87
|
agents = @mutex.synchronize do
|
|
89
|
-
@state
|
|
88
|
+
return false if @state == :released
|
|
89
|
+
raise Phronomy::Error.new("Agent scope is releasing", code: "agent.scope_busy") if @state == :releasing
|
|
90
|
+
@state = :releasing
|
|
91
|
+
owns_release = true
|
|
90
92
|
@condition.wait(@mutex) until @receipts.empty?
|
|
91
93
|
@agents.values.dup
|
|
92
94
|
end
|
|
93
95
|
failures = []
|
|
94
96
|
agents.each do |agent|
|
|
95
97
|
agent.unload!
|
|
98
|
+
@mutex.synchronize { @agents.delete(agent.object_id) }
|
|
96
99
|
rescue => error
|
|
97
100
|
failures << {agent_id: agent.agent_id, error: error}.freeze
|
|
98
101
|
end
|
|
99
102
|
@mutex.synchronize do
|
|
100
|
-
@
|
|
101
|
-
@environment = @ownership = @owner_token = nil
|
|
102
|
-
|
|
103
|
+
@state = failures.empty? ? :released : :release_failed
|
|
104
|
+
@environment = @ownership = @owner_token = nil if failures.empty?
|
|
105
|
+
end
|
|
106
|
+
unless failures.empty?
|
|
107
|
+
raise ScopeCleanupError.new(scope: self, failures: failures), cause: failures.first.fetch(:error)
|
|
108
|
+
end
|
|
109
|
+
true
|
|
110
|
+
ensure
|
|
111
|
+
# An interrupted waiter/unloader must remain retryable, without changing
|
|
112
|
+
# another thread's release state when this call was rejected as busy.
|
|
113
|
+
if owns_release
|
|
114
|
+
@mutex.synchronize { @state = :release_failed if @state == :releasing }
|
|
103
115
|
end
|
|
104
|
-
failures.freeze
|
|
105
116
|
end
|
|
106
|
-
private :__close
|
|
107
117
|
|
|
108
118
|
private
|
|
109
119
|
|
|
@@ -6,11 +6,13 @@ module Phronomy
|
|
|
6
6
|
# Original exceptions and the successful result remain available to the caller.
|
|
7
7
|
# @api public
|
|
8
8
|
class ScopeCleanupError < Phronomy::Error
|
|
9
|
-
attr_reader :failures, :result
|
|
9
|
+
attr_reader :failures, :result, :primary_error, :scope
|
|
10
10
|
|
|
11
|
-
def initialize(failures:, result:)
|
|
11
|
+
def initialize(scope:, failures:, result: nil, primary_error: nil)
|
|
12
12
|
@failures = failures.map { |failure| failure.dup.freeze }.freeze
|
|
13
13
|
@result = result
|
|
14
|
+
@primary_error = primary_error
|
|
15
|
+
@scope = scope
|
|
14
16
|
super("Agent scope cleanup failed for: #{@failures.map { |f| f.fetch(:agent_id) }.join(", ")}",
|
|
15
17
|
code: "agent.scope_cleanup_failed")
|
|
16
18
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Owns the execution's output promise; generic schema interpretation is injected.
|
|
8
|
+
# @api private
|
|
9
|
+
class OutputContract
|
|
10
|
+
FACTORIES = {}
|
|
11
|
+
private_constant :FACTORIES
|
|
12
|
+
attr_reader :schema, :digest
|
|
13
|
+
|
|
14
|
+
def self.install_factory(factory)
|
|
15
|
+
FACTORIES.replace(parser: factory).freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(schema)
|
|
19
|
+
@parser = FACTORIES.fetch(:parser).call(schema)
|
|
20
|
+
@schema = @parser.json_schema
|
|
21
|
+
@digest = Digest::SHA256.hexdigest(JSON.generate(@schema, sort_keys: true)).freeze
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def metadata(contents)
|
|
26
|
+
{"output_contract" => {"version" => 1, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)), "schema_digest" => digest}}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.restore(execution, contents)
|
|
30
|
+
stored = execution.metadata["output_contract"]
|
|
31
|
+
return nil unless stored
|
|
32
|
+
raise ArgumentError, "unsupported Agent output contract version" unless stored.fetch("version") == 1
|
|
33
|
+
contract = new(contents.fetch_json(stored.fetch("schema_ref")))
|
|
34
|
+
unless contract.digest == stored.fetch("schema_digest")
|
|
35
|
+
raise Phronomy::ContentStore::IntegrityError, "Agent output schema digest mismatch"
|
|
36
|
+
end
|
|
37
|
+
contract
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def validate(output, response:, execution_id:)
|
|
41
|
+
raw = response&.content
|
|
42
|
+
reason = if response&.refusal || response&.finish_reason == :refusal
|
|
43
|
+
"model_refusal"
|
|
44
|
+
elsif !response || !%i[stop].include?(response.finish_reason) || !response.tool_calls.empty?
|
|
45
|
+
%i[length content_filter].include?(response&.finish_reason) ? response.finish_reason.to_s : "unknown_finish_reason"
|
|
46
|
+
elsif output.nil? || raw.nil?
|
|
47
|
+
"missing_output"
|
|
48
|
+
end
|
|
49
|
+
if reason
|
|
50
|
+
raise OutputError.new(execution_id: execution_id, schema_digest: digest, reason: reason, raw_output: raw)
|
|
51
|
+
end
|
|
52
|
+
@parser.parse(output)
|
|
53
|
+
rescue Phronomy::ParseError => error
|
|
54
|
+
reason = case error.code
|
|
55
|
+
when "output_parser.invalid_json" then "invalid_json"
|
|
56
|
+
when "output_parser.schema_mismatch" then "schema_mismatch"
|
|
57
|
+
else raise
|
|
58
|
+
end
|
|
59
|
+
raise OutputError.new(execution_id: execution_id, schema_digest: digest, reason: reason,
|
|
60
|
+
violations: error.details, raw_output: raw), cause: error
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# A declared output contract could not be fulfilled. Tool effects remain facts.
|
|
6
|
+
# @api public
|
|
7
|
+
class OutputError < Phronomy::Error
|
|
8
|
+
CODES = {"invalid_json" => "agent.output_invalid", "schema_mismatch" => "agent.output_invalid",
|
|
9
|
+
"missing_output" => "agent.output_invalid", "model_refusal" => "agent.output_refused",
|
|
10
|
+
"length" => "agent.output_incomplete", "content_filter" => "agent.output_incomplete",
|
|
11
|
+
"unknown_finish_reason" => "agent.output_incomplete"}.freeze
|
|
12
|
+
private_constant :CODES
|
|
13
|
+
|
|
14
|
+
# @api public
|
|
15
|
+
attr_reader :execution_id, :schema_digest, :reason, :violations, :raw_output
|
|
16
|
+
|
|
17
|
+
# @api public
|
|
18
|
+
def initialize(execution_id:, schema_digest:, reason:, violations: [], raw_output: nil)
|
|
19
|
+
@execution_id = execution_id.to_s.dup.freeze
|
|
20
|
+
@schema_digest = schema_digest.to_s.dup.freeze
|
|
21
|
+
@reason = reason.to_s.dup.freeze
|
|
22
|
+
@violations = Phronomy::Values::Immutable.json(violations)
|
|
23
|
+
@raw_output = Phronomy::Values::Immutable.copy(raw_output)
|
|
24
|
+
super("Agent output contract failed: #{@reason}", code: CODES.fetch(@reason))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Owner-defined facts for persistence and composition; no live exception values.
|
|
28
|
+
# @api private
|
|
29
|
+
def diagnostic(include_raw: true)
|
|
30
|
+
value = Phronomy::Error.diagnostic(self).merge("execution_id" => execution_id,
|
|
31
|
+
"schema_digest" => schema_digest, "reason" => reason, "violations" => violations)
|
|
32
|
+
value["raw_output"] = raw_output if include_raw
|
|
33
|
+
Phronomy::Values::Immutable.json(value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @api private
|
|
37
|
+
def self.restore(value)
|
|
38
|
+
error = new(execution_id: value.fetch("execution_id"), schema_digest: value.fetch("schema_digest"),
|
|
39
|
+
reason: value.fetch("reason"), violations: value.fetch("violations"), raw_output: value["raw_output"])
|
|
40
|
+
raise ArgumentError, "invalid Agent output diagnostic code" unless error.code == value.fetch("code")
|
|
41
|
+
if value["cause"]
|
|
42
|
+
begin
|
|
43
|
+
raise error, cause: Phronomy::Error.from_diagnostic(value.fetch("cause"))
|
|
44
|
+
rescue OutputError => restored
|
|
45
|
+
restored.set_backtrace(nil)
|
|
46
|
+
return restored
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
error
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -39,9 +39,9 @@ module Phronomy
|
|
|
39
39
|
materializer = RuntimeInput.new(agent: agent, persistence: agent.persistence)
|
|
40
40
|
messages = records.map { |record| materializer.materialize_journal_record(record) }.freeze
|
|
41
41
|
assistant = messages.reverse.find { |message| message.role.to_sym == :assistant }
|
|
42
|
-
output, usage = SavedContextReader.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
|
|
42
|
+
output, usage, response = SavedContextReader.provider_output_and_usage(agent, execution) if execution.phase.to_sym == :recovery_provider_completed
|
|
43
43
|
RecoveryMaterial.new(projection: projection, messages: messages,
|
|
44
|
-
assistant_message: assistant, output: output, usage: usage,
|
|
44
|
+
assistant_message: assistant, output: output, usage: usage, response: response,
|
|
45
45
|
cancellation_requested: agent.persistence.cancellation_requested?(agent_id: agent.agent_id, execution_id: execution.execution_id))
|
|
46
46
|
end
|
|
47
47
|
|
|
@@ -73,7 +73,7 @@ module Phronomy
|
|
|
73
73
|
agent.send(:_phronomy_event_listener), messages: material.messages, cancellation_requested: material.cancellation_requested)
|
|
74
74
|
end
|
|
75
75
|
if execution.phase.to_sym == :recovery_provider_completed
|
|
76
|
-
invocation.
|
|
76
|
+
invocation.apply_llm_response!(material.response)
|
|
77
77
|
end
|
|
78
78
|
prepare_saved_provider_calls(execution, invocation, material.assistant_message) if action == :framework_calls
|
|
79
79
|
end
|
|
@@ -296,6 +296,7 @@ module Phronomy
|
|
|
296
296
|
Phronomy::Tracing::Automatic.observe_task(
|
|
297
297
|
task,
|
|
298
298
|
"agent.execution",
|
|
299
|
+
usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
|
|
299
300
|
agent_id: agent.agent_id,
|
|
300
301
|
execution_id: execution.execution_id,
|
|
301
302
|
mode: (
|
|
@@ -39,7 +39,7 @@ module Phronomy
|
|
|
39
39
|
)
|
|
40
40
|
ResolutionResult = Data.define(:execution)
|
|
41
41
|
ResolutionPreparation = Data.define(:execution, :material, :error)
|
|
42
|
-
RecoveryMaterial = Data.define(:projection, :messages, :assistant_message, :output, :usage, :cancellation_requested)
|
|
42
|
+
RecoveryMaterial = Data.define(:projection, :messages, :assistant_message, :output, :usage, :response, :cancellation_requested)
|
|
43
43
|
RecoveryPlan = Data.define(
|
|
44
44
|
:execution, :root, :manifest, :base_manifest,
|
|
45
45
|
:projection, :classification, :material
|
|
@@ -30,7 +30,11 @@ module Phronomy
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def error_from_failure(failure)
|
|
33
|
-
Phronomy::
|
|
33
|
+
if failure["class"] == "Phronomy::Agent::OutputError"
|
|
34
|
+
OutputError.restore(failure)
|
|
35
|
+
else
|
|
36
|
+
Phronomy::Error.from_diagnostic(failure)
|
|
37
|
+
end
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def event_payload(execution, descriptor)
|
|
@@ -23,7 +23,8 @@ module Phronomy
|
|
|
23
23
|
declared_states: InvocationTransitions::DECLARED_STATES,
|
|
24
24
|
wait_state_names: InvocationTransitions::WAIT_STATES,
|
|
25
25
|
external_events: InvocationTransitions::EXTERNAL_EVENTS,
|
|
26
|
-
recursion_limit: InvocationTransitions
|
|
26
|
+
recursion_limit: InvocationTransitions::AUTOMATIC_RECURSION_LIMIT,
|
|
27
|
+
recursion_limit_scope: :automatic_chain,
|
|
27
28
|
event_loop: event_loop,
|
|
28
29
|
resume_event: resume_event,
|
|
29
30
|
resume_phase: resume_phase
|
|
@@ -5,8 +5,12 @@ module Phronomy
|
|
|
5
5
|
class RuntimeInput
|
|
6
6
|
RuntimeProjection = Data.define(
|
|
7
7
|
:system, :messages, :tool_classes, :ask_message,
|
|
8
|
-
:model_config, :manifest, :manifest_ref
|
|
9
|
-
)
|
|
8
|
+
:model_config, :manifest, :manifest_ref, :response_schema
|
|
9
|
+
) do
|
|
10
|
+
def initialize(response_schema: nil, **values)
|
|
11
|
+
super(**values, response_schema: response_schema)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
10
14
|
|
|
11
15
|
def initialize(agent:, persistence:, additional_tools: [])
|
|
12
16
|
@agent = agent
|
|
@@ -45,7 +49,8 @@ module Phronomy
|
|
|
45
49
|
ask_message: ask_message,
|
|
46
50
|
model_config: Phronomy::Values::Immutable.copy(model_config),
|
|
47
51
|
manifest: manifest,
|
|
48
|
-
manifest_ref: manifest_ref
|
|
52
|
+
manifest_ref: manifest_ref,
|
|
53
|
+
response_schema: manifest.response_schema_ref && Phronomy::Values::Immutable.json(fetch_json(manifest.response_schema_ref))
|
|
49
54
|
)
|
|
50
55
|
end
|
|
51
56
|
|
|
@@ -148,12 +153,18 @@ module Phronomy
|
|
|
148
153
|
end
|
|
149
154
|
|
|
150
155
|
tool_calls = materialize_tool_calls(payload["tool_calls"])
|
|
156
|
+
metadata = payload.fetch("metadata", {})
|
|
157
|
+
raise ArgumentError, "canonical message metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
158
|
+
metadata = metadata.dup
|
|
159
|
+
# Preserve backend-owned values without interpreting them. Old records
|
|
160
|
+
# stored model_id only at the top level; retain that read compatibility.
|
|
161
|
+
metadata["model_id"] = payload["model_id"] if !metadata.key?("model_id") && payload["model_id"]
|
|
151
162
|
Phronomy::LLMAdapter::Message.new(
|
|
152
163
|
role: role,
|
|
153
164
|
content: payload.fetch("content", nil),
|
|
154
165
|
tool_calls: tool_calls,
|
|
155
166
|
tool_call_id: payload["tool_call_id"],
|
|
156
|
-
metadata:
|
|
167
|
+
metadata: metadata
|
|
157
168
|
)
|
|
158
169
|
end
|
|
159
170
|
|
|
@@ -48,7 +48,8 @@ module Phronomy
|
|
|
48
48
|
cached: usage_hash["cached"] || usage_hash[:cached] || usage_hash["cache_read_tokens"] || usage_hash[:cache_read_tokens] || usage_hash["cached_tokens"] || usage_hash[:cached_tokens],
|
|
49
49
|
cache_creation: usage_hash["cache_creation"] || usage_hash[:cache_creation] || usage_hash["cache_write_tokens"] || usage_hash[:cache_write_tokens] || usage_hash["cache_creation_tokens"] || usage_hash[:cache_creation_tokens]
|
|
50
50
|
)
|
|
51
|
-
|
|
51
|
+
response = Phronomy::LLMAdapter::Response.from_h(payload.merge("usage" => usage.to_h))
|
|
52
|
+
[output, usage, response]
|
|
52
53
|
end
|
|
53
54
|
end
|
|
54
55
|
end
|
data/lib/phronomy/agent/store.rb
CHANGED
|
@@ -79,7 +79,8 @@ module Phronomy
|
|
|
79
79
|
execution_id: execution.execution_id, agent_id: execution.agent_id,
|
|
80
80
|
status: execution.status, phase: execution.phase,
|
|
81
81
|
result_ref: execution.result_ref, error_ref: execution.error_ref,
|
|
82
|
-
result: execution
|
|
82
|
+
result: materialize_result(execution, contents),
|
|
83
|
+
raw_output: execution.metadata["raw_output_ref"] && contents.fetch_text(execution.metadata.fetch("raw_output_ref")),
|
|
83
84
|
error: execution.error_ref && contents.fetch_json(execution.error_ref)
|
|
84
85
|
}.then { |value| Phronomy::Values::Immutable.copy(value) }
|
|
85
86
|
end
|
|
@@ -97,7 +98,8 @@ module Phronomy
|
|
|
97
98
|
assert_execution_owner!(execution, agent_id)
|
|
98
99
|
ExecutionObservation.new(agent_id: execution.agent_id, execution_id: execution.execution_id,
|
|
99
100
|
status: execution.status,
|
|
100
|
-
result: execution
|
|
101
|
+
result: materialize_result(execution, records.contents),
|
|
102
|
+
raw_output: execution.metadata["raw_output_ref"] && records.contents.fetch_text(execution.metadata.fetch("raw_output_ref")),
|
|
101
103
|
error: execution.error_ref && Phronomy::Values::Immutable.copy(records.contents.fetch_json(execution.error_ref)),
|
|
102
104
|
reservation: execution.metadata["reservation"] && ReservedExecution.new(agent_id: agent_id,
|
|
103
105
|
execution_id: execution_id, correlation: execution.metadata["reservation"]),
|
|
@@ -226,6 +228,12 @@ module Phronomy
|
|
|
226
228
|
|
|
227
229
|
private
|
|
228
230
|
|
|
231
|
+
def materialize_result(execution, contents)
|
|
232
|
+
return nil unless execution.result_ref
|
|
233
|
+
(execution.metadata["result_format"] == "json") ?
|
|
234
|
+
Phronomy::Values::Immutable.json(contents.fetch_json(execution.result_ref)) : contents.fetch_text(execution.result_ref)
|
|
235
|
+
end
|
|
236
|
+
|
|
229
237
|
def authorized_argument_values(entry)
|
|
230
238
|
# Unit 6 persists the exact validated snapshot. Older records encoded
|
|
231
239
|
# legacy raw params, whose explicit optional nil meant omission.
|
|
@@ -11,6 +11,39 @@ module Phronomy
|
|
|
11
11
|
module Immutable
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
+
# Normalize JSON object names without coercing values or losing duplicates.
|
|
15
|
+
# @api private
|
|
16
|
+
def json(value, ancestors: [])
|
|
17
|
+
if (value.is_a?(Hash) || value.is_a?(Array)) && ancestors.include?(value.object_id)
|
|
18
|
+
raise ArgumentError, "cyclic JSON value"
|
|
19
|
+
end
|
|
20
|
+
children = ancestors + [value.object_id]
|
|
21
|
+
normalized = case value
|
|
22
|
+
when Hash
|
|
23
|
+
value.each_with_object({}) do |(key, child), result|
|
|
24
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
25
|
+
raise ArgumentError, "JSON object keys must be Strings or Symbols"
|
|
26
|
+
end
|
|
27
|
+
name = key.to_s
|
|
28
|
+
raise ArgumentError, "duplicate JSON object key: #{name}" if result.key?(name)
|
|
29
|
+
result[name.dup.freeze] = json(child, ancestors: children)
|
|
30
|
+
end
|
|
31
|
+
when Array then value.map { |child| json(child, ancestors: children) }
|
|
32
|
+
when String
|
|
33
|
+
text = value.encode(Encoding::UTF_8)
|
|
34
|
+
raise ArgumentError, "invalid UTF-8 JSON string" unless text.valid_encoding?
|
|
35
|
+
text.dup
|
|
36
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
37
|
+
when Float
|
|
38
|
+
raise ArgumentError, "JSON numbers must be finite" unless value.finite?
|
|
39
|
+
value
|
|
40
|
+
else raise ArgumentError, "unsupported JSON value: #{value.class}"
|
|
41
|
+
end
|
|
42
|
+
normalized.freeze
|
|
43
|
+
rescue EncodingError
|
|
44
|
+
raise ArgumentError, "invalid UTF-8 JSON string"
|
|
45
|
+
end
|
|
46
|
+
|
|
14
47
|
# @api private
|
|
15
48
|
def copy(value)
|
|
16
49
|
case value
|
|
@@ -34,7 +34,7 @@ module Phronomy
|
|
|
34
34
|
)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def store(prepared, contents:)
|
|
37
|
+
def store(prepared, contents:, response_schema_ref: nil)
|
|
38
38
|
unless prepared.is_a?(Prepared)
|
|
39
39
|
raise ArgumentError, "Context::Assembly#store expected Context::Assembly::Prepared"
|
|
40
40
|
end
|
|
@@ -80,7 +80,7 @@ module Phronomy
|
|
|
80
80
|
).validate!(
|
|
81
81
|
token_budget: prepared.input.token_budget,
|
|
82
82
|
segments: segments,
|
|
83
|
-
extra_values: [selected_tool_definitions]
|
|
83
|
+
extra_values: [selected_tool_definitions, response_schema_ref && contents.fetch_json(response_schema_ref)].compact
|
|
84
84
|
)
|
|
85
85
|
|
|
86
86
|
store_manifest(
|
|
@@ -90,7 +90,8 @@ module Phronomy
|
|
|
90
90
|
segments: segments,
|
|
91
91
|
model_config_ref: contents.put_json(prepared.model_config),
|
|
92
92
|
tool_definitions_ref: contents.put_json(selected_tool_definitions),
|
|
93
|
-
adapter_identity: prepared.adapter_identity
|
|
93
|
+
adapter_identity: prepared.adapter_identity,
|
|
94
|
+
response_schema_ref: response_schema_ref
|
|
94
95
|
)
|
|
95
96
|
end
|
|
96
97
|
|
|
@@ -117,7 +118,8 @@ module Phronomy
|
|
|
117
118
|
segments:,
|
|
118
119
|
model_config_ref:,
|
|
119
120
|
tool_definitions_ref:,
|
|
120
|
-
adapter_identity
|
|
121
|
+
adapter_identity:,
|
|
122
|
+
response_schema_ref:
|
|
121
123
|
)
|
|
122
124
|
positioned = segments.each_with_index.map do |value, position|
|
|
123
125
|
Phronomy::Context::LLMInputManifest::Segment.new(**value.merge(position: position))
|
|
@@ -128,6 +130,7 @@ module Phronomy
|
|
|
128
130
|
segments: positioned,
|
|
129
131
|
model_config_ref: model_config_ref,
|
|
130
132
|
tool_definitions_ref: tool_definitions_ref,
|
|
133
|
+
response_schema_ref: response_schema_ref,
|
|
131
134
|
assembly_policy_version: ASSEMBLY_POLICY_VERSION,
|
|
132
135
|
ruby_llm_version: adapter_identity["sdk_version"],
|
|
133
136
|
adapter_name: adapter_identity["adapter_name"]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "faraday"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module Embeddings
|
|
5
7
|
# Embeddings adapter backed by RubyLLM.
|
|
@@ -44,7 +46,7 @@ module Phronomy
|
|
|
44
46
|
raise AuthenticationError.new(error.message, code: "embeddings.authentication_failed"), cause: error
|
|
45
47
|
rescue ::RubyLLM::RateLimitError => error
|
|
46
48
|
raise RateLimitError.new(error.message, code: "embeddings.rate_limited"), cause: error
|
|
47
|
-
rescue ::RubyLLM::Error => error
|
|
49
|
+
rescue ::RubyLLM::Error, ::Faraday::TimeoutError, ::Faraday::ConnectionFailed => error
|
|
48
50
|
raise TransportError.new(error.message), cause: error
|
|
49
51
|
end
|
|
50
52
|
end
|
|
@@ -93,6 +93,7 @@ module Phronomy
|
|
|
93
93
|
phase_machine_class:,
|
|
94
94
|
recursion_limit:,
|
|
95
95
|
event_loop:,
|
|
96
|
+
recursion_limit_scope: :session,
|
|
96
97
|
resume_event: nil,
|
|
97
98
|
resume_phase: nil,
|
|
98
99
|
stable_observer: nil,
|
|
@@ -123,6 +124,11 @@ module Phronomy
|
|
|
123
124
|
@external_events = external_events
|
|
124
125
|
@phase_machine_class = phase_machine_class
|
|
125
126
|
@recursion_limit = recursion_limit
|
|
127
|
+
unless %i[session automatic_chain].include?(recursion_limit_scope)
|
|
128
|
+
raise ArgumentError, "unknown recursion_limit_scope: #{recursion_limit_scope.inspect}"
|
|
129
|
+
end
|
|
130
|
+
@recursion_limit_scope = recursion_limit_scope
|
|
131
|
+
@automatic_steps = 0
|
|
126
132
|
@event_loop = event_loop
|
|
127
133
|
@resume_event = resume_event
|
|
128
134
|
@resume_phase = resume_phase
|
|
@@ -230,7 +236,13 @@ module Phronomy
|
|
|
230
236
|
end
|
|
231
237
|
|
|
232
238
|
def fire_and_advance!(event)
|
|
233
|
-
|
|
239
|
+
automatic = event.type == :state_completed
|
|
240
|
+
exhausted = if @recursion_limit_scope == :automatic_chain
|
|
241
|
+
automatic && @automatic_steps >= @recursion_limit
|
|
242
|
+
else
|
|
243
|
+
@step >= @recursion_limit
|
|
244
|
+
end
|
|
245
|
+
if exhausted
|
|
234
246
|
raise Phronomy::RecursionLimitError,
|
|
235
247
|
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
236
248
|
end
|
|
@@ -246,6 +258,9 @@ module Phronomy
|
|
|
246
258
|
apply_context_metadata!
|
|
247
259
|
@current_state = @tracker.phase.to_sym
|
|
248
260
|
@step += 1
|
|
261
|
+
# Only an accepted external transition starts a fresh automatic chain.
|
|
262
|
+
# Consumed/stale events and events with no transition cannot reset it.
|
|
263
|
+
@automatic_steps = automatic ? @automatic_steps + 1 : 0
|
|
249
264
|
advance_or_halt
|
|
250
265
|
ensure
|
|
251
266
|
@tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
|
|
@@ -8,23 +8,27 @@ module Phronomy
|
|
|
8
8
|
# still pending. Generation owns the request-to-Workflow correlation.
|
|
9
9
|
# @api private
|
|
10
10
|
class AgentOperation
|
|
11
|
-
def initialize(agent_class)
|
|
11
|
+
def initialize(agent_class, output_schema:)
|
|
12
|
+
@output_schema = output_schema
|
|
12
13
|
@mutex = Mutex.new
|
|
13
14
|
@pending = []
|
|
14
15
|
@agent = agent_class.new(on_event: method(:receive_event))
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
def start(input, listener:)
|
|
18
|
+
def start(input, listener:, config: {})
|
|
18
19
|
request = Object.new
|
|
19
20
|
# Workflow entry calls are serial. Keep submitted requests until their
|
|
20
21
|
# own result settles: Agent admission itself may run after an earlier
|
|
21
22
|
# request completes, so a pending submission is not necessarily busy.
|
|
22
23
|
@mutex.synchronize { @pending << [request, listener] }
|
|
23
|
-
task = @agent.invoke_async(input)
|
|
24
|
+
task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
|
|
24
25
|
task.on_complete do |result, error|
|
|
25
26
|
release(request)
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
remember_execution(result[:execution_id]) if result.is_a?(Hash)
|
|
28
|
+
if !error && result.is_a?(Hash) && result[:handoff_request]
|
|
29
|
+
error = Phronomy::Error.new("GeneratorVerifier does not coordinate Handoff",
|
|
30
|
+
code: "generation.unsupported_handoff")
|
|
31
|
+
end
|
|
28
32
|
|
|
29
33
|
event = if error
|
|
30
34
|
type = if error.is_a?(Phronomy::TimeoutError)
|
|
@@ -46,10 +50,29 @@ module Phronomy
|
|
|
46
50
|
raise
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
def approve_async(request, approved:)
|
|
54
|
+
@agent.approve_async(request.execution_id, approval_request_id: request.id, approved: approved)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cancel_async
|
|
58
|
+
execution_id = @mutex.synchronize { @execution_id }
|
|
59
|
+
@agent.cancel_async(execution_id) if execution_id
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def unload!
|
|
63
|
+
@agent.unload!
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
private
|
|
50
67
|
|
|
68
|
+
def remember_execution(execution_id)
|
|
69
|
+
@mutex.synchronize { @execution_id = execution_id } if execution_id
|
|
70
|
+
end
|
|
71
|
+
|
|
51
72
|
def receive_event(event)
|
|
52
73
|
return unless event.type == :approval_required
|
|
74
|
+
request = event.payload[:request]
|
|
75
|
+
remember_execution(request.execution_id) if request.respond_to?(:execution_id)
|
|
53
76
|
|
|
54
77
|
listener = @mutex.synchronize { @pending.first&.last }
|
|
55
78
|
listener&.call(event)
|