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
data/docs/features.md
CHANGED
|
@@ -32,6 +32,13 @@ for production deployments.
|
|
|
32
32
|
| **Error Taxonomy** — Provider errors translated to Phronomy transport/authentication/rate-limit/context errors | Beta |
|
|
33
33
|
| **Optional failure reason codes** — `Phronomy::Error#code` and `code:` retain domain-supplied reasons in saved Agent/Team diagnostics; [meaning and compatibility](failure-reason-codes.md) | Beta |
|
|
34
34
|
|
|
35
|
+
The RubyLLM backends also translate identified Faraday timeout and connection
|
|
36
|
+
failures to the owning `LLMAdapter::TransportError` or
|
|
37
|
+
`Embeddings::TransportError`, retaining the original cause chain. A stream may
|
|
38
|
+
have delivered partial output before failure; this classification does not imply
|
|
39
|
+
that retry is safe. Callback failures and execution cancellation/deadlines keep
|
|
40
|
+
their identity. See [transport error boundaries](architecture/transport-error-boundaries.md).
|
|
41
|
+
|
|
35
42
|
Agent definition lineage is separate from Agent instance identity. A named
|
|
36
43
|
concrete Agent may declare `agent_definition version: N`; when `id:` is omitted,
|
|
37
44
|
its fully-qualified Ruby class name is the stable `agent_definition_id`.
|
|
@@ -97,7 +104,7 @@ See the [migration guide](migrations/parallel-tool-chat-removal.md).
|
|
|
97
104
|
|---|---|
|
|
98
105
|
| **Workflow asynchronous pattern** — `task_state` starts a `TaskResult`, applies its successful value once to the current state entry, and then evaluates ordinary transitions; `Workflow#signal` remains the application event API | Beta |
|
|
99
106
|
| **Durable Handoff** — `MultiAgent::Handoff` and `MultiAgent::HandoffRunner` persist responsibility, immutable Context and exact Target reservation in one Persistence domain; current graph wiring is required for continuation | Beta |
|
|
100
|
-
| **GeneratorVerifier** — Generator-Verifier loop with
|
|
107
|
+
| **GeneratorVerifier** — Generator-Verifier loop with application prompts, Agent-validated output schemas and explicit output failures | Beta |
|
|
101
108
|
| **`Phronomy::MultiAgent::Orchestrator`** — Parallel subagent dispatch, fan-out, and `subagent` DSL | Beta |
|
|
102
109
|
| **`Phronomy::MultiAgent::TeamCoordinator`** — LLM coordinator with stateful worker Agents | Beta |
|
|
103
110
|
| **`Phronomy::MultiAgent::SharedState`** — Sequential peer-agent coordination through an invocation-local findings store; see the [namespace migration](migrations/shared-state-multi-agent.md) | Experimental |
|
|
@@ -193,3 +200,5 @@ claim. See [the migration](migrations/durable-semantic-coordination-v2.md).
|
|
|
193
200
|
turn read-only, including a current absent-but-reserved Target, without graph or
|
|
194
201
|
Agent hydration. Purging an idle main Agent removes its Handoff anchor; pending
|
|
195
202
|
coordination rejects purge rather than discarding its reservation.
|
|
203
|
+
|
|
204
|
+
Structured output and symmetric lifetime details: [contract and migration](architecture/structured-output-and-scope.md).
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# r8 unit15 migration
|
|
2
2
|
|
|
3
|
+
> Historical contract: parser injection/fallback is superseded by
|
|
4
|
+
> [structured output and scope](../architecture/structured-output-and-scope.md).
|
|
5
|
+
|
|
3
6
|
## Application impact
|
|
4
7
|
|
|
5
8
|
No public API, data migration or configuration changes are required.
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
eval_gemfile "mcp_1_0.gemfile"
|
|
4
|
-
gem "faraday", "= 2.
|
|
4
|
+
gem "faraday", "= 2.14.4"
|
|
5
|
+
gem "faraday-net_http", "= 3.4.4"
|
|
5
6
|
gem "webmock", require: false
|
|
7
|
+
|
|
8
|
+
# Strict JSON decoding and schema transport use these verified minimum versions.
|
|
9
|
+
gem "json", "= 2.21.2"
|
|
10
|
+
gem "ruby_llm", "= 2.0.0"
|
|
@@ -47,7 +47,7 @@ module Phronomy
|
|
|
47
47
|
"reservation" => correlation, "current_input_ref" => input_ref,
|
|
48
48
|
"durable_context_ref" => context_ref,
|
|
49
49
|
"execution_extension" => @config[:phronomy_execution_participant]&.binding&.to_h
|
|
50
|
-
}.merge(@preparation_metadata).compact)
|
|
50
|
+
}.merge(@preparation_metadata).merge(@config[:phronomy_output_contract]&.metadata(records.contents) || {}).compact)
|
|
51
51
|
input_record = JournalRecord.from_h(input_record.to_h.merge("execution_id" => execution_id))
|
|
52
52
|
execution = execution.with(execution_revision: 0, working_records: [input_record])
|
|
53
53
|
records.executions.create_active(execution)
|
|
@@ -12,12 +12,12 @@ module Phronomy
|
|
|
12
12
|
# - approval notifications share the Agent listener as :approval_required;
|
|
13
13
|
# - Recovery resolution uses resolve/resolve_async.
|
|
14
14
|
module AsyncEventApi
|
|
15
|
-
def start_reserved_async(reservation:, input:, config: {})
|
|
15
|
+
def start_reserved_async(reservation:, input:, config: {}, output_schema: nil)
|
|
16
16
|
__with_operation(async: true) do
|
|
17
17
|
unless reservation.is_a?(ReservedExecution) && reservation.agent_id == agent_id
|
|
18
18
|
raise ArgumentError, "reservation must belong to this Agent"
|
|
19
19
|
end
|
|
20
|
-
config = __invocation_config(
|
|
20
|
+
config = __invocation_config(_prepare_output_config(config, nil, output_schema))
|
|
21
21
|
ExactExecution.start(agent: self, execution_id: reservation.execution_id, input: input,
|
|
22
22
|
config: config.merge(phronomy_reservation: reservation.correlation))
|
|
23
23
|
end
|
|
@@ -53,12 +53,13 @@ module Phronomy
|
|
|
53
53
|
input,
|
|
54
54
|
config: {},
|
|
55
55
|
invocation_context: nil,
|
|
56
|
+
output_schema: nil,
|
|
56
57
|
**removed_options,
|
|
57
58
|
&removed_block
|
|
58
59
|
)
|
|
59
60
|
__with_operation do
|
|
60
61
|
_reject_removed_invocation_listener_arguments!(removed_options, removed_block)
|
|
61
|
-
config =
|
|
62
|
+
config = _prepare_output_config(config, invocation_context, output_schema)
|
|
62
63
|
_check_event_loop_reentrancy(:invoke, :invoke_async)
|
|
63
64
|
_start_agent_operation(
|
|
64
65
|
input,
|
|
@@ -73,12 +74,13 @@ module Phronomy
|
|
|
73
74
|
input,
|
|
74
75
|
config: {},
|
|
75
76
|
invocation_context: nil,
|
|
77
|
+
output_schema: nil,
|
|
76
78
|
**removed_options,
|
|
77
79
|
&removed_block
|
|
78
80
|
)
|
|
79
81
|
__with_operation(async: true) do
|
|
80
82
|
_reject_removed_invocation_listener_arguments!(removed_options, removed_block)
|
|
81
|
-
config =
|
|
83
|
+
config = _prepare_output_config(config, invocation_context, output_schema)
|
|
82
84
|
_start_agent_operation(
|
|
83
85
|
input,
|
|
84
86
|
config: config,
|
|
@@ -92,13 +94,14 @@ module Phronomy
|
|
|
92
94
|
input,
|
|
93
95
|
config: {},
|
|
94
96
|
invocation_context: nil,
|
|
97
|
+
output_schema: nil,
|
|
95
98
|
**removed_options,
|
|
96
99
|
&removed_block
|
|
97
100
|
)
|
|
98
101
|
__with_operation do
|
|
99
102
|
_reject_removed_invocation_listener_arguments!(removed_options, removed_block)
|
|
100
103
|
listener = _required_stream_listener!(:stream)
|
|
101
|
-
config =
|
|
104
|
+
config = _prepare_output_config(config, invocation_context, output_schema)
|
|
102
105
|
_check_event_loop_reentrancy(:stream, :stream_async)
|
|
103
106
|
_start_agent_operation(
|
|
104
107
|
input,
|
|
@@ -113,13 +116,14 @@ module Phronomy
|
|
|
113
116
|
input,
|
|
114
117
|
config: {},
|
|
115
118
|
invocation_context: nil,
|
|
119
|
+
output_schema: nil,
|
|
116
120
|
**removed_options,
|
|
117
121
|
&removed_block
|
|
118
122
|
)
|
|
119
123
|
__with_operation(async: true) do
|
|
120
124
|
_reject_removed_invocation_listener_arguments!(removed_options, removed_block)
|
|
121
125
|
listener = _required_stream_listener!(:stream_async)
|
|
122
|
-
config =
|
|
126
|
+
config = _prepare_output_config(config, invocation_context, output_schema)
|
|
123
127
|
_start_agent_operation(
|
|
124
128
|
input,
|
|
125
129
|
config: config,
|
|
@@ -143,7 +147,7 @@ module Phronomy
|
|
|
143
147
|
|
|
144
148
|
def approve_async(execution_id, approval_request_id:, approved: true, config: {})
|
|
145
149
|
__with_operation(async: true) do
|
|
146
|
-
|
|
150
|
+
config = _prepare_invocation_config(config, nil)
|
|
147
151
|
owner = __execution_environment.existing_registry&.agent_execution_owner(execution_id)
|
|
148
152
|
coordinator = if owner&.agent&.equal?(self)
|
|
149
153
|
owner.coordinator
|
|
@@ -202,6 +206,11 @@ module Phronomy
|
|
|
202
206
|
|
|
203
207
|
private
|
|
204
208
|
|
|
209
|
+
def _prepare_output_config(config, invocation_context, schema)
|
|
210
|
+
config = _prepare_invocation_config(config, invocation_context)
|
|
211
|
+
schema.nil? ? config : config.merge(phronomy_output_contract: OutputContract.new(schema))
|
|
212
|
+
end
|
|
213
|
+
|
|
205
214
|
def _start_agent_operation(input, config:, mode:, listener:)
|
|
206
215
|
config = __invocation_config(_snapshot_durable_context(config))
|
|
207
216
|
@_phronomy_execution_wiring = config.dup.freeze
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -101,6 +101,9 @@ module Phronomy
|
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
# Maximum logical LLM calls per execution, including the first call.
|
|
105
|
+
# Tool calls and SDK transport retries do not consume extra iterations.
|
|
106
|
+
# The limit is captured at admission; zero admits no LLM call.
|
|
104
107
|
def max_iterations(val = nil)
|
|
105
108
|
if val
|
|
106
109
|
@max_iterations = val
|
|
@@ -727,6 +730,9 @@ module Phronomy
|
|
|
727
730
|
def _prepare_invocation_config(config, invocation_context)
|
|
728
731
|
__assert_live_agent!
|
|
729
732
|
_reject_removed_generic_identity_keys!(config)
|
|
733
|
+
if config.keys.any? { |key| %w[output_schema response_schema phronomy_output_contract].include?(key.to_s) }
|
|
734
|
+
raise ArgumentError, "Use the output_schema keyword on a new Agent execution"
|
|
735
|
+
end
|
|
730
736
|
invocation_context ?
|
|
731
737
|
config.merge(invocation_context: invocation_context) : config
|
|
732
738
|
|
|
@@ -871,7 +877,8 @@ module Phronomy
|
|
|
871
877
|
definitions = Phronomy::Tool::DefinitionSet.build(tools: invocation.tools.values).definitions
|
|
872
878
|
Phronomy::LLMAdapter::Request.new(
|
|
873
879
|
model_config: projection.model_config, system: projection.system,
|
|
874
|
-
messages: invocation.messages, tools: definitions, message: projection.ask_message
|
|
880
|
+
messages: invocation.messages, tools: definitions, message: projection.ask_message,
|
|
881
|
+
response_schema: projection.response_schema
|
|
875
882
|
)
|
|
876
883
|
end
|
|
877
884
|
|
|
@@ -116,6 +116,10 @@ module Phronomy
|
|
|
116
116
|
"content" => normalize(content),
|
|
117
117
|
"tool_calls" => calls
|
|
118
118
|
}
|
|
119
|
+
metadata = read_optional(message, :metadata) || {}
|
|
120
|
+
raise ArgumentError, "imported assistant metadata must be a Hash" unless metadata.is_a?(Hash)
|
|
121
|
+
Phronomy::Values::Immutable.validate_canonical_json!(metadata, label: "imported assistant metadata")
|
|
122
|
+
payload["metadata"] = Phronomy::Values::Immutable.copy(metadata) unless metadata.empty?
|
|
119
123
|
model_id = read_optional(message, :model_id)
|
|
120
124
|
payload["model_id"] = model_id.to_s if model_id
|
|
121
125
|
|
|
@@ -46,7 +46,8 @@ module Phronomy
|
|
|
46
46
|
:rejected,
|
|
47
47
|
:error
|
|
48
48
|
|
|
49
|
-
attr_reader :
|
|
49
|
+
attr_reader :final_response,
|
|
50
|
+
:execution_id,
|
|
50
51
|
:agent,
|
|
51
52
|
:config,
|
|
52
53
|
:approval_policy,
|
|
@@ -73,7 +74,9 @@ module Phronomy
|
|
|
73
74
|
@agent = agent
|
|
74
75
|
@input = input
|
|
75
76
|
# The caller's Hash must not become a shared mutable Runtime object.
|
|
76
|
-
|
|
77
|
+
# OutputContract is admission-only; the durable Manifest owns subsequent
|
|
78
|
+
# schema projection. Never forward the parent promise to an Agent Tool.
|
|
79
|
+
@config = config.except(:phronomy_output_contract)
|
|
77
80
|
resolved_execution_id = execution_id || @config[:execution_id]
|
|
78
81
|
@execution_id = resolved_execution_id&.to_s&.freeze
|
|
79
82
|
invocation_context = @config[:invocation_context]
|
|
@@ -269,6 +272,7 @@ module Phronomy
|
|
|
269
272
|
raise Phronomy::Error, "LLM operation completed without a response" unless response
|
|
270
273
|
|
|
271
274
|
@user_message_sent = true
|
|
275
|
+
@final_response = response
|
|
272
276
|
@output = response.content
|
|
273
277
|
@usage = response.usage
|
|
274
278
|
@pending_tool_calls = []
|
|
@@ -34,6 +34,7 @@ module Phronomy
|
|
|
34
34
|
def prepare_provider(operation)
|
|
35
35
|
staged = stage_provider_call(operation)
|
|
36
36
|
recorded = encode_provider_records(operation, staged)
|
|
37
|
+
IterationBudget.check!(recorded, default: @agent.class.max_iterations)
|
|
37
38
|
assembler, prepared = prepare_provider_context(operation, recorded)
|
|
38
39
|
committed, manifest, manifest_ref = commit_provider_preparation(
|
|
39
40
|
operation, recorded, assembler, prepared
|
|
@@ -56,6 +57,9 @@ module Phronomy
|
|
|
56
57
|
|
|
57
58
|
def stage_provider_call(operation)
|
|
58
59
|
metadata = operation.execution.metadata.dup
|
|
60
|
+
metadata[IterationBudget::LIMIT_KEY] = IterationBudget.limit_for(
|
|
61
|
+
operation.execution, default: @agent.class.max_iterations
|
|
62
|
+
)
|
|
59
63
|
metadata.delete(ExecutionMetadata::TOOL_BATCH_METADATA_KEY)
|
|
60
64
|
metadata.delete(ExecutionMetadata::RECOVERY_METADATA_KEY)
|
|
61
65
|
metadata[ExecutionMetadata::PENDING_LLM_ID_KEY] = operation.pending_llm_call_id
|
|
@@ -118,7 +122,8 @@ module Phronomy
|
|
|
118
122
|
def commit_provider_preparation(operation, recorded, assembler, prepared)
|
|
119
123
|
manifest, manifest_ref = @persistence.transaction do |tx|
|
|
120
124
|
assert_local_durable_base!(tx, operation.root)
|
|
121
|
-
Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents
|
|
125
|
+
Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
|
|
126
|
+
response_schema_ref: recorded.metadata.dig("output_contract", "schema_ref"))
|
|
122
127
|
end
|
|
123
128
|
updated = execution_with_provider_manifest(recorded, manifest_ref)
|
|
124
129
|
change = ExecutionChange.new(agent: @agent, current: operation.execution,
|
|
@@ -121,6 +121,12 @@ module Phronomy
|
|
|
121
121
|
if expected && stored != expected
|
|
122
122
|
raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
|
|
123
123
|
end
|
|
124
|
+
unless @resume_only
|
|
125
|
+
expected_digest = @config[:phronomy_output_contract]&.digest
|
|
126
|
+
if expected_digest != execution.metadata.dig("output_contract", "schema_digest")
|
|
127
|
+
raise Phronomy::Persistence::StateConflictError, "Reserved execution output schema mismatch"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
124
130
|
ref = execution.metadata["current_input_ref"]
|
|
125
131
|
@input = @agent.persistence.contents.fetch_text(ref) if @resume_only && ref
|
|
126
132
|
if ref && @agent.persistence.contents.fetch_text(ref) != @input
|
|
@@ -168,6 +174,9 @@ module Phronomy
|
|
|
168
174
|
|
|
169
175
|
def materialize(execution)
|
|
170
176
|
result = @agent.persistence.result(execution.execution_id)
|
|
177
|
+
if result[:error].is_a?(Hash) && result[:error]["class"] == "Phronomy::Agent::OutputError"
|
|
178
|
+
raise RecoverySupport.error_from_failure(result[:error])
|
|
179
|
+
end
|
|
171
180
|
result.merge(output: result[:result]).freeze
|
|
172
181
|
end
|
|
173
182
|
end
|
|
@@ -7,7 +7,6 @@ module Phronomy
|
|
|
7
7
|
# @api private
|
|
8
8
|
class ExecutionCancellation
|
|
9
9
|
Command = Data.define(:coordinator, :execution_id, :agent_id)
|
|
10
|
-
private_constant :Command
|
|
11
10
|
|
|
12
11
|
def self.signal(execution_id, agent_id, environment:)
|
|
13
12
|
command = Command.new(coordinator: new(environment), execution_id: execution_id, agent_id: agent_id)
|
|
@@ -22,6 +21,7 @@ module Phronomy
|
|
|
22
21
|
state = @environment.registry.agent_execution_state(command.execution_id)
|
|
23
22
|
return unless state && state.agent.agent_id == command.agent_id
|
|
24
23
|
state.invocation&.config&.fetch(:cancellation_token, nil)&.cancel!
|
|
24
|
+
state.coordinator.deliver_on_event_loop(command)
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -74,6 +74,8 @@ module Phronomy
|
|
|
74
74
|
TerminalCommitReady = Data.define(
|
|
75
75
|
:coordinator, :operation, :delivery, :outcome, :error
|
|
76
76
|
)
|
|
77
|
+
SuspensionCancelledCommand = Data.define(:coordinator, :execution_id)
|
|
78
|
+
|
|
77
79
|
DeferredTerminalCommand = Data.define(
|
|
78
80
|
:coordinator, :execution_id, :result_task, :invocation,
|
|
79
81
|
:source_error, :fsm_session_id
|
|
@@ -254,6 +256,8 @@ module Phronomy
|
|
|
254
256
|
apply_terminal_commit_on_event_loop(command)
|
|
255
257
|
when DeferredTerminalCommand
|
|
256
258
|
resume_deferred_terminal_on_event_loop(command)
|
|
259
|
+
when ExecutionCancellation::Command, SuspensionCancelledCommand
|
|
260
|
+
cancel_suspended_on_event_loop(command.execution_id)
|
|
257
261
|
else
|
|
258
262
|
raise Phronomy::Error, "unknown Agent control command: #{command.class}"
|
|
259
263
|
end
|
|
@@ -428,6 +432,7 @@ module Phronomy
|
|
|
428
432
|
Phronomy::Tracing::Automatic.observe_task(
|
|
429
433
|
request.result_task,
|
|
430
434
|
"agent.execution",
|
|
435
|
+
usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
|
|
431
436
|
input: request.input,
|
|
432
437
|
agent_id: @agent.agent_id,
|
|
433
438
|
execution_id: prepared.execution.execution_id,
|
|
@@ -911,11 +916,17 @@ module Phronomy
|
|
|
911
916
|
operation = capture_approval_resume(state, request)
|
|
912
917
|
resume_transition_started = false
|
|
913
918
|
submitted = false
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
+
begin
|
|
920
|
+
environment.registry.mark_agent_execution_admission(
|
|
921
|
+
@agent.agent_id,
|
|
922
|
+
execution_id: state.execution_id,
|
|
923
|
+
state: :resuming,
|
|
924
|
+
expected_state: :suspended
|
|
925
|
+
)
|
|
926
|
+
rescue Phronomy::AgentBusyError => error
|
|
927
|
+
return fail_task(request.result_task, error)
|
|
928
|
+
end
|
|
929
|
+
suspended_cancellation = clear_suspended_cancellation
|
|
919
930
|
resume_transition_started = true
|
|
920
931
|
task = environment.submit(on_full: :raise) do
|
|
921
932
|
@approval_resume_commit.commit(operation)
|
|
@@ -940,6 +951,9 @@ module Phronomy
|
|
|
940
951
|
execution_id: state.execution_id,
|
|
941
952
|
state: submitted ? :recovery_required : :suspended
|
|
942
953
|
)
|
|
954
|
+
if !submitted && suspended_cancellation
|
|
955
|
+
bind_suspended_cancellation(*suspended_cancellation.first(3))
|
|
956
|
+
end
|
|
943
957
|
end
|
|
944
958
|
fail_task(request.result_task, error)
|
|
945
959
|
raise unless resume_transition_started
|
|
@@ -1043,6 +1057,7 @@ module Phronomy
|
|
|
1043
1057
|
Phronomy::Tracing::Automatic.observe_task(
|
|
1044
1058
|
request.result_task,
|
|
1045
1059
|
"agent.execution",
|
|
1060
|
+
usage: ->(value) { value.is_a?(Hash) ? value[:usage]&.to_h&.freeze : nil },
|
|
1046
1061
|
agent_id: @agent.agent_id,
|
|
1047
1062
|
execution_id: operation.execution_id,
|
|
1048
1063
|
mode: trace_mode,
|
|
@@ -1373,6 +1388,7 @@ module Phronomy
|
|
|
1373
1388
|
TerminalView.new(
|
|
1374
1389
|
phase: invocation&.phase,
|
|
1375
1390
|
output: invocation&.output,
|
|
1391
|
+
response: invocation&.final_response,
|
|
1376
1392
|
usage: invocation&.usage,
|
|
1377
1393
|
approval_request: invocation&.approval_request,
|
|
1378
1394
|
rejected: invocation ? !!invocation.rejected : false,
|
|
@@ -1491,6 +1507,7 @@ module Phronomy
|
|
|
1491
1507
|
execution_id: operation.execution_id,
|
|
1492
1508
|
state: :suspended
|
|
1493
1509
|
)
|
|
1510
|
+
bind_suspended_cancellation(operation.execution_id, state.invocation, delivery.result_task)
|
|
1494
1511
|
dispatch_approval_listener(delivery.approval_listener, outcome.approval_request)
|
|
1495
1512
|
callback_error = deliver_terminal(
|
|
1496
1513
|
delivery.application_listener,
|
|
@@ -1507,6 +1524,46 @@ module Phronomy
|
|
|
1507
1524
|
end
|
|
1508
1525
|
end
|
|
1509
1526
|
|
|
1527
|
+
# Suspension closes the FSM session, but not the logical execution.
|
|
1528
|
+
# Keep cancellation connected until approval resumes or the owner terminates.
|
|
1529
|
+
def bind_suspended_cancellation(execution_id, invocation, result_task)
|
|
1530
|
+
clear_suspended_cancellation
|
|
1531
|
+
subscriptions = Phronomy::Concurrency::Subscriptions.new
|
|
1532
|
+
@suspended_cancellation = [execution_id, invocation, result_task, subscriptions]
|
|
1533
|
+
subscriptions.result(result_task) { subscriptions.close }
|
|
1534
|
+
environment = @agent.__execution_environment
|
|
1535
|
+
subscriptions.cancellation(invocation.config[:cancellation_token]) do
|
|
1536
|
+
post_control(environment, SuspensionCancelledCommand.new(coordinator: self, execution_id: execution_id))
|
|
1537
|
+
end
|
|
1538
|
+
end
|
|
1539
|
+
|
|
1540
|
+
def clear_suspended_cancellation
|
|
1541
|
+
record = @suspended_cancellation
|
|
1542
|
+
@suspended_cancellation = nil
|
|
1543
|
+
record&.last&.close
|
|
1544
|
+
record
|
|
1545
|
+
end
|
|
1546
|
+
|
|
1547
|
+
def cancel_suspended_on_event_loop(execution_id)
|
|
1548
|
+
record = @suspended_cancellation
|
|
1549
|
+
return unless record && record.first == execution_id
|
|
1550
|
+
state = @agent.__execution_environment.registry.agent_execution_state(execution_id)
|
|
1551
|
+
return unless state && state.execution.status == :suspended &&
|
|
1552
|
+
state.invocation.equal?(record[1]) && state.fsm_session_id.nil?
|
|
1553
|
+
|
|
1554
|
+
@agent.__execution_environment.registry.mark_agent_execution_admission(
|
|
1555
|
+
@agent.agent_id, execution_id: execution_id, state: :cancelling,
|
|
1556
|
+
expected_state: :suspended
|
|
1557
|
+
)
|
|
1558
|
+
clear_suspended_cancellation
|
|
1559
|
+
finish_on_event_loop(execution_id, record[2], state.invocation,
|
|
1560
|
+
Phronomy::CancellationError.new("Agent execution cancelled during approval"),
|
|
1561
|
+
fsm_session_id: nil)
|
|
1562
|
+
rescue Phronomy::AgentBusyError
|
|
1563
|
+
# A resume/terminal transition already owns this suspended boundary.
|
|
1564
|
+
nil
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1510
1567
|
def deliver_completed_execution_on_event_loop(ready, state, environment)
|
|
1511
1568
|
operation = ready.operation
|
|
1512
1569
|
delivery = ready.delivery
|
|
@@ -1554,6 +1611,7 @@ module Phronomy
|
|
|
1554
1611
|
|
|
1555
1612
|
def release_terminal_ownership(environment, execution_id, state)
|
|
1556
1613
|
registry = environment.registry
|
|
1614
|
+
clear_suspended_cancellation if @suspended_cancellation&.first == execution_id
|
|
1557
1615
|
registry.release_agent_execution(execution_id) if state
|
|
1558
1616
|
registry.release_agent_execution_admission(@agent.agent_id, execution_id: execution_id)
|
|
1559
1617
|
end
|
|
@@ -13,8 +13,12 @@ module Phronomy
|
|
|
13
13
|
TerminalView = Data.define(
|
|
14
14
|
:phase, :output, :usage, :approval_request, :rejected,
|
|
15
15
|
:input_blocked, :output_blocked, :block_error,
|
|
16
|
-
:invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested
|
|
17
|
-
)
|
|
16
|
+
:invocation_error, :handoff, :callback_failure, :source_error, :cancel_requested, :response
|
|
17
|
+
) do
|
|
18
|
+
def initialize(response: nil, **values)
|
|
19
|
+
super(**values, response: response)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
18
22
|
Command = Data.define(
|
|
19
23
|
:execution_id, :fsm_session_id, :expected_execution_revision,
|
|
20
24
|
:root, :journal_records, :execution, :runtime_snapshot,
|
|
@@ -36,6 +40,18 @@ module Phronomy
|
|
|
36
40
|
view = operation.terminal_view
|
|
37
41
|
error = view.callback_failure&.to_stream_callback_error || view.source_error ||
|
|
38
42
|
view.block_error || view.invocation_error
|
|
43
|
+
if !error && !view.rejected && !view.handoff && view.phase != :suspended
|
|
44
|
+
begin
|
|
45
|
+
contract = OutputContract.restore(operation.execution, @persistence.contents)
|
|
46
|
+
if contract
|
|
47
|
+
output = contract.validate(view.output, response: view.response, execution_id: operation.execution_id)
|
|
48
|
+
view = view.with(output: output)
|
|
49
|
+
operation = operation.with(terminal_view: view)
|
|
50
|
+
end
|
|
51
|
+
rescue => validation_error
|
|
52
|
+
error = validation_error
|
|
53
|
+
end
|
|
54
|
+
end
|
|
39
55
|
change = ExecutionChange.new(agent: @agent, current: operation.execution,
|
|
40
56
|
root: operation.root, kind: :terminal, terminal: view, config: operation.wiring) do |tx, state, receipt, pending|
|
|
41
57
|
if pending
|
|
@@ -147,7 +163,9 @@ module Phronomy
|
|
|
147
163
|
current = operation.execution
|
|
148
164
|
view = operation.terminal_view
|
|
149
165
|
encoded_records, call_records = encode_runtime_records(tx, operation, context_candidate: true)
|
|
150
|
-
|
|
166
|
+
structured = current.metadata.key?("output_contract") && !view.rejected
|
|
167
|
+
output_ref = tx.contents.put_text(structured ? JSON.generate(view.output) : view.output.to_s)
|
|
168
|
+
output_metadata = structured ? {"result_format" => "json", "raw_output_ref" => tx.contents.put_text(view.response.content)} : {}
|
|
151
169
|
records = current.working_records + encoded_records + completion_records(operation, output_ref)
|
|
152
170
|
completed = current.with(
|
|
153
171
|
status: view.rejected ? :rejected : :completed,
|
|
@@ -156,6 +174,7 @@ module Phronomy
|
|
|
156
174
|
llm_calls: current.llm_calls + call_records,
|
|
157
175
|
approval_request: nil,
|
|
158
176
|
result_ref: output_ref,
|
|
177
|
+
metadata: current.metadata.merge(output_metadata),
|
|
159
178
|
terminal_reason: view.rejected ? "rejected" : "completed"
|
|
160
179
|
)
|
|
161
180
|
[records, completed]
|
|
@@ -187,7 +206,7 @@ module Phronomy
|
|
|
187
206
|
def encode_failure(tx, operation, error)
|
|
188
207
|
current = operation.execution
|
|
189
208
|
encoded_records, call_records = encode_runtime_records(tx, operation, context_candidate: false)
|
|
190
|
-
error_ref = tx.contents.put_json(Phronomy::Error.diagnostic(error))
|
|
209
|
+
error_ref = tx.contents.put_json(error.is_a?(OutputError) ? error.diagnostic : Phronomy::Error.diagnostic(error))
|
|
191
210
|
terminal_status = ExecutionFailure.status_for(error)
|
|
192
211
|
records = failure_records(operation, encoded_records, terminal_status, error_ref)
|
|
193
212
|
failed = current.with(
|
|
@@ -252,6 +271,7 @@ module Phronomy
|
|
|
252
271
|
def completed_outcome(view, execution, root, appended, messages)
|
|
253
272
|
result = result_base(execution, root).merge(
|
|
254
273
|
output: view.output,
|
|
274
|
+
raw_output: (execution.metadata["result_format"] == "json") ? view.response.content : nil,
|
|
255
275
|
rejected: view.rejected || nil,
|
|
256
276
|
usage: view.usage,
|
|
257
277
|
messages: messages
|
|
@@ -145,7 +145,7 @@ module Phronomy
|
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
# @api private
|
|
148
|
-
def mark_agent_execution_admission(agent_id, execution_id:, state:)
|
|
148
|
+
def mark_agent_execution_admission(agent_id, execution_id:, state:, expected_state: nil)
|
|
149
149
|
assert_event_loop_thread!
|
|
150
150
|
key = agent_id.to_s
|
|
151
151
|
execution_key = execution_id.to_s
|
|
@@ -161,6 +161,10 @@ module Phronomy
|
|
|
161
161
|
unless current.execution_id.to_s == execution_key
|
|
162
162
|
raise Phronomy::Error, "stale Agent admission state update for #{key.inspect}"
|
|
163
163
|
end
|
|
164
|
+
if expected_state && current.state != expected_state
|
|
165
|
+
raise Phronomy::AgentBusyError,
|
|
166
|
+
"Agent #{key.inspect} is #{current.state}, expected #{expected_state}"
|
|
167
|
+
end
|
|
164
168
|
@agent_admissions[key] = AgentAdmission.new(
|
|
165
169
|
agent_id: current.agent_id,
|
|
166
170
|
owner_token: current.owner_token,
|
|
@@ -105,6 +105,13 @@ module Phronomy
|
|
|
105
105
|
def prepare_admitted(input:, config:, execution:, active_root:, journal_records:)
|
|
106
106
|
current_execution = execution
|
|
107
107
|
begin
|
|
108
|
+
# Legacy executions acquire a limit at their first resumed preparation.
|
|
109
|
+
current_execution = execution.with(
|
|
110
|
+
execution_revision: execution.execution_revision,
|
|
111
|
+
metadata: execution.metadata.merge(IterationBudget::LIMIT_KEY =>
|
|
112
|
+
IterationBudget.limit_for(execution, default: @agent.class.max_iterations))
|
|
113
|
+
)
|
|
114
|
+
IterationBudget.check!(current_execution, default: @agent.class.max_iterations)
|
|
108
115
|
filtered_input = filter_input(input, config)
|
|
109
116
|
staged = stage_filtered_input(current_execution, active_root, filtered_input)
|
|
110
117
|
assembler, prepared = prepare_context(staged, active_root, filtered_input, config, journal_records)
|
|
@@ -184,7 +191,8 @@ module Phronomy
|
|
|
184
191
|
active = manifest = manifest_ref = nil
|
|
185
192
|
@persistence.transaction do |tx|
|
|
186
193
|
assert_local_durable_base!(tx, root)
|
|
187
|
-
manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents
|
|
194
|
+
manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
|
|
195
|
+
response_schema_ref: staged.metadata.dig("output_contract", "schema_ref"))
|
|
188
196
|
active = execution_with_initial_manifest(staged, manifest_ref)
|
|
189
197
|
tx.executions.save(
|
|
190
198
|
staged.execution_id,
|
|
@@ -222,6 +230,7 @@ module Phronomy
|
|
|
222
230
|
def initial_preparation_metadata(mode, replayable)
|
|
223
231
|
{
|
|
224
232
|
"preparation_replayable" => !!replayable,
|
|
233
|
+
IterationBudget::LIMIT_KEY => IterationBudget.validate_limit(@agent.class.max_iterations),
|
|
225
234
|
ExecutionMetadata::CONTRACT_VERSION_KEY => ExecutionMetadata::CONTRACT_VERSION,
|
|
226
235
|
ExecutionMetadata::INVOCATION_MODE_KEY => mode.to_s,
|
|
227
236
|
ExecutionMetadata::PENDING_LLM_ID_KEY => SecureRandom.uuid.to_s.freeze,
|
|
@@ -197,7 +197,7 @@ module Phronomy
|
|
|
197
197
|
|
|
198
198
|
def self.finish_provider_trace(trace_handle, response, error)
|
|
199
199
|
Phronomy::Tracing::Automatic.finish(trace_handle,
|
|
200
|
-
output: response, usage: response&.usage, error: error)
|
|
200
|
+
output: response, usage: response&.usage&.to_h&.freeze, error: error)
|
|
201
201
|
end
|
|
202
202
|
private_class_method :finish_provider_trace
|
|
203
203
|
|
|
@@ -25,6 +25,10 @@ module Phronomy
|
|
|
25
25
|
recording_tool_results suspended output_filtering handed_off completed blocked failed
|
|
26
26
|
].freeze
|
|
27
27
|
|
|
28
|
+
# An automatic chain cannot visit more phases than an acyclic traversal.
|
|
29
|
+
# Logical LLM turns are bounded independently by IterationBudget.
|
|
30
|
+
AUTOMATIC_RECURSION_LIMIT = DECLARED_STATES.length
|
|
31
|
+
|
|
28
32
|
WAIT_STATES = %i[suspended].freeze
|
|
29
33
|
|
|
30
34
|
TOOL_EVENTS = %i[
|
|
@@ -115,10 +119,6 @@ module Phronomy
|
|
|
115
119
|
guard = definition[:guard]
|
|
116
120
|
!guard || !!(context && guard.call(context))
|
|
117
121
|
end
|
|
118
|
-
|
|
119
|
-
def self.recursion_limit(max_iterations)
|
|
120
|
-
12 + ((max_iterations || 10) * 8)
|
|
121
|
-
end
|
|
122
122
|
end
|
|
123
123
|
end
|
|
124
124
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Agent policy over existing execution facts. No Engine steps, provider
|
|
6
|
+
# retries, Tool events or context-selected messages contribute to this count.
|
|
7
|
+
# @api private
|
|
8
|
+
module IterationBudget
|
|
9
|
+
LIMIT_KEY = "agent_max_iterations"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def validate_limit(value)
|
|
14
|
+
unless value.is_a?(Integer) && value >= 0
|
|
15
|
+
raise ArgumentError, "Agent max_iterations must be a non-negative Integer"
|
|
16
|
+
end
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def limit_for(execution, default:)
|
|
21
|
+
validate_limit(execution.metadata.fetch(LIMIT_KEY) { default })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check!(execution, default:)
|
|
25
|
+
limit = limit_for(execution, default: default)
|
|
26
|
+
completed = execution.llm_calls.length
|
|
27
|
+
if completed >= limit
|
|
28
|
+
raise Phronomy::RecursionLimitError,
|
|
29
|
+
"Agent max_iterations (#{limit}) exceeded after #{completed} LLM calls"
|
|
30
|
+
end
|
|
31
|
+
limit
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -169,6 +169,9 @@ module Phronomy
|
|
|
169
169
|
"role" => "assistant",
|
|
170
170
|
"content" => json_value(outcome.content),
|
|
171
171
|
"tool_calls" => json_value(Array(outcome.tool_calls)),
|
|
172
|
+
"finish_reason" => outcome.finish_reason.to_s,
|
|
173
|
+
"refusal" => outcome.refusal,
|
|
174
|
+
"metadata" => outcome.metadata,
|
|
172
175
|
"model_id" => outcome.metadata["model_id"]
|
|
173
176
|
}.compact
|
|
174
177
|
tx.contents.put_json(payload)
|