phronomy 0.30.1 → 0.31.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 +65 -0
- data/README.md +7 -2
- data/docs/architecture/agent-tool-child-lifetime.md +141 -0
- data/docs/architecture/dx05-agent-scope.md +4 -2
- data/docs/architecture/structured-output-and-scope.md +73 -13
- data/docs/decisions/044-agent-default-and-one-shot-composition.md +5 -0
- data/docs/getting-started.md +26 -0
- data/docs/migrations/0.31.md +72 -0
- data/lib/phronomy/agent/async_event_api.rb +17 -8
- data/lib/phronomy/agent/base.rb +27 -2
- data/lib/phronomy/agent/composition/run_once.rb +11 -10
- data/lib/phronomy/agent/context_preparation.rb +1 -0
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +5 -3
- data/lib/phronomy/agent/execution/execution_coordinator.rb +6 -0
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -6
- data/lib/phronomy/agent/execution/initial_preparation.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_actions.rb +17 -15
- data/lib/phronomy/agent/execution_change.rb +4 -0
- data/lib/phronomy/agent/output_contract.rb +47 -5
- data/lib/phronomy/agent/persistence/codec.rb +16 -2
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +1 -0
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -2
- data/lib/phronomy/agent/runtime_input.rb +7 -0
- data/lib/phronomy/engine/fsm_session.rb +7 -0
- data/lib/phronomy/execution/async_operation.rb +16 -0
- data/lib/phronomy/execution/concurrency/result_finalization.rb +106 -0
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +3 -3
- data/lib/phronomy/generation/generator_verifier.rb +6 -3
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +1 -1
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +3 -1
- data/lib/phronomy/tools/agent.rb +58 -22
- data/lib/phronomy/version.rb +1 -1
- data/sig/phronomy/agent.rbs +7 -5
- data/sig/phronomy/execution/async_operation.rbs +1 -0
- data/sig/phronomy/generation/generator_verifier.rbs +1 -1
- data/sig/phronomy/tools/agent.rbs +14 -0
- metadata +6 -2
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -400,6 +400,31 @@ module Phronomy
|
|
|
400
400
|
end
|
|
401
401
|
end
|
|
402
402
|
|
|
403
|
+
# Materializes the current durable transcript as immutable Message values.
|
|
404
|
+
# Application authorization and presentation filtering remain caller-owned.
|
|
405
|
+
# @return [Array<LLMAdapter::Message>]
|
|
406
|
+
# @api public
|
|
407
|
+
def transcript_messages
|
|
408
|
+
_check_event_loop_reentrancy(:transcript_messages, :transcript_messages_async)
|
|
409
|
+
transcript_messages_async.wait_result
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Holds Agent ownership until the submitted read physically completes.
|
|
413
|
+
# @return [TaskResult<Array<LLMAdapter::Message>>]
|
|
414
|
+
# @api public
|
|
415
|
+
def transcript_messages_async
|
|
416
|
+
__with_operation(async: true, read_only: true) do
|
|
417
|
+
__execution_environment.submit(on_full: :raise) do
|
|
418
|
+
root, records = persistence.transaction do |tx|
|
|
419
|
+
current = tx.agents.load(agent_id)
|
|
420
|
+
[current, tx.journals.read(agent_id, limit: current.journal_position)]
|
|
421
|
+
end
|
|
422
|
+
RuntimeInput.new(agent: self, persistence: persistence)
|
|
423
|
+
.materialize_transcript(root: root, records: records)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
403
428
|
def clear_transcript!
|
|
404
429
|
__with_operation do
|
|
405
430
|
mutate_context!(:transcript_cleared) do |root|
|
|
@@ -730,8 +755,8 @@ module Phronomy
|
|
|
730
755
|
def _prepare_invocation_config(config, invocation_context)
|
|
731
756
|
__assert_live_agent!
|
|
732
757
|
_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
|
|
758
|
+
if config.keys.any? { |key| %w[output_schema schema_delivery response_schema phronomy_output_contract].include?(key.to_s) }
|
|
759
|
+
raise ArgumentError, "Use the output_schema and schema_delivery keywords on a new Agent execution"
|
|
735
760
|
end
|
|
736
761
|
invocation_context ?
|
|
737
762
|
config.merge(invocation_context: invocation_context) : config
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# This convenience API
|
|
3
|
+
# This convenience API owns a fresh ephemeral Persistence and a scoped Agent.
|
|
4
4
|
# The application entry loads the method definition; Agent execution does not
|
|
5
5
|
# delegate to or require this higher-level composition implementation.
|
|
6
6
|
module Phronomy
|
|
@@ -18,15 +18,16 @@ module Phronomy
|
|
|
18
18
|
raise ArgumentError, "Provide either on_event: or a block, not both"
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
open_scope do |scope|
|
|
22
|
+
persistence = Phronomy::PersistenceComposition.agent
|
|
23
|
+
agent = scope.create(definition,
|
|
24
|
+
context: context,
|
|
25
|
+
knowledge: knowledge,
|
|
26
|
+
persistence: persistence,
|
|
27
|
+
on_event: on_event,
|
|
28
|
+
&event_block)
|
|
29
|
+
agent.invoke(input, **invoke_options)
|
|
30
|
+
end
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
end
|
|
@@ -251,6 +251,7 @@ module Phronomy
|
|
|
251
251
|
tools:,
|
|
252
252
|
current_input:
|
|
253
253
|
)
|
|
254
|
+
OutputContract.validate_model_config!(model_config) if execution.metadata.key?("output_contract")
|
|
254
255
|
token_budget = Phronomy::Agent::Settings.current.llm_adapter.input_budget(model_config)
|
|
255
256
|
policy_input = @input_builder.build(
|
|
256
257
|
agent_id: agent_root.agent_id,
|
|
@@ -123,7 +123,7 @@ module Phronomy
|
|
|
123
123
|
manifest, manifest_ref = @persistence.transaction do |tx|
|
|
124
124
|
assert_local_durable_base!(tx, operation.root)
|
|
125
125
|
Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
|
|
126
|
-
response_schema_ref: recorded.metadata
|
|
126
|
+
response_schema_ref: OutputContract.response_schema_ref(recorded.metadata))
|
|
127
127
|
end
|
|
128
128
|
updated = execution_with_provider_manifest(recorded, manifest_ref)
|
|
129
129
|
change = ExecutionChange.new(agent: @agent, current: operation.execution,
|
|
@@ -122,9 +122,11 @@ module Phronomy
|
|
|
122
122
|
raise Phronomy::Persistence::StateConflictError, "Reserved execution #{@id} correlation mismatch"
|
|
123
123
|
end
|
|
124
124
|
unless @resume_only
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
expected_contract = @config[:phronomy_output_contract]
|
|
126
|
+
saved_contract = OutputContract.restore(execution, @agent.persistence.contents)
|
|
127
|
+
if expected_contract&.digest != saved_contract&.digest ||
|
|
128
|
+
expected_contract&.schema_delivery != saved_contract&.schema_delivery
|
|
129
|
+
raise Phronomy::Persistence::StateConflictError, "Reserved execution output contract mismatch"
|
|
128
130
|
end
|
|
129
131
|
end
|
|
130
132
|
ref = execution.metadata["current_input_ref"]
|
|
@@ -1703,6 +1703,12 @@ module Phronomy
|
|
|
1703
1703
|
execution_id,
|
|
1704
1704
|
fallback: fallback_task
|
|
1705
1705
|
)
|
|
1706
|
+
# All these waiters observe the same terminal execution. A synchronous
|
|
1707
|
+
# completion callback may request unload immediately, so acknowledge
|
|
1708
|
+
# every finished operation before publishing any logical result. Each
|
|
1709
|
+
# OperationResult still retains its lease until tracked physical work
|
|
1710
|
+
# ends; unrelated operations remain protected by their own leases.
|
|
1711
|
+
waiters.each { |task| task.finish_operation! if task.respond_to?(:finish_operation!) }
|
|
1706
1712
|
waiters.each do |task|
|
|
1707
1713
|
settle_after_terminal(
|
|
1708
1714
|
task, callback_error, event_type, result, execution_error
|
|
@@ -286,12 +286,7 @@ module Phronomy
|
|
|
286
286
|
agent: @agent,
|
|
287
287
|
persistence: persistence
|
|
288
288
|
)
|
|
289
|
-
materializer.
|
|
290
|
-
JournalProjection.new(
|
|
291
|
-
agent_root: root,
|
|
292
|
-
records: journal_records
|
|
293
|
-
).transcript_records
|
|
294
|
-
)
|
|
289
|
+
materializer.materialize_transcript(root: root, records: journal_records)
|
|
295
290
|
end
|
|
296
291
|
|
|
297
292
|
def result_base(execution, root)
|
|
@@ -192,7 +192,7 @@ module Phronomy
|
|
|
192
192
|
@persistence.transaction do |tx|
|
|
193
193
|
assert_local_durable_base!(tx, root)
|
|
194
194
|
manifest, manifest_ref = Phronomy::Context::Assembly.new.store(prepared, contents: tx.contents,
|
|
195
|
-
response_schema_ref: staged.metadata
|
|
195
|
+
response_schema_ref: OutputContract.response_schema_ref(staged.metadata))
|
|
196
196
|
active = execution_with_initial_manifest(staged, manifest_ref)
|
|
197
197
|
tx.executions.save(
|
|
198
198
|
staged.execution_id,
|
|
@@ -6,46 +6,48 @@ module Phronomy
|
|
|
6
6
|
# Session construction and resource selection are supplied by the environment.
|
|
7
7
|
# @api private
|
|
8
8
|
class InvocationActions
|
|
9
|
-
def self.build_entry_actions(
|
|
9
|
+
def self.build_entry_actions(environment, mode:, event_sink:)
|
|
10
|
+
# Generated machine callbacks can outlive a session through VM method
|
|
11
|
+
# caches. Resolve the Agent from the current invocation, never capture it.
|
|
10
12
|
calling_action = if mode.to_sym == :stream
|
|
11
|
-
method(:calling_llm_stream_action).curry.call(
|
|
13
|
+
method(:calling_llm_stream_action).curry.call(environment, event_sink)
|
|
12
14
|
else
|
|
13
|
-
method(:calling_llm_action).curry.call(
|
|
15
|
+
method(:calling_llm_action).curry.call(environment, event_sink)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
{
|
|
17
|
-
filtering_input: [method(:apply_prepared_input_action)
|
|
18
|
-
building_context: [method(:prepare_runtime_input_action)
|
|
19
|
+
filtering_input: [method(:apply_prepared_input_action)],
|
|
20
|
+
building_context: [method(:prepare_runtime_input_action)],
|
|
19
21
|
calling_llm: [calling_action],
|
|
20
22
|
starting_tools: [method(:starting_tools_action).curry.call(environment, event_sink)],
|
|
21
23
|
dispatching_tools: [method(:dispatching_tools_action).curry.call(environment, event_sink)],
|
|
22
24
|
recording_tool_results: [method(:recording_tool_results_action)],
|
|
23
25
|
suspended: [method(:suspended_action)],
|
|
24
|
-
output_filtering: [method(:output_filtering_action)
|
|
26
|
+
output_filtering: [method(:output_filtering_action)],
|
|
25
27
|
failed: [method(:failed_action)]
|
|
26
28
|
}
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
def self.apply_prepared_input_action(
|
|
31
|
+
def self.apply_prepared_input_action(invocation)
|
|
30
32
|
invocation.input = invocation.config.fetch(:phronomy_filtered_input)
|
|
31
33
|
invocation
|
|
32
34
|
end
|
|
33
35
|
private_class_method :apply_prepared_input_action
|
|
34
36
|
|
|
35
|
-
def self.prepare_runtime_input_action(
|
|
37
|
+
def self.prepare_runtime_input_action(invocation)
|
|
36
38
|
projection = invocation.config.fetch(:phronomy_runtime_projection)
|
|
37
|
-
agent.send(:prepare_runtime_input, projection, invocation: invocation)
|
|
39
|
+
invocation.agent.send(:prepare_runtime_input, projection, invocation: invocation)
|
|
38
40
|
end
|
|
39
41
|
private_class_method :prepare_runtime_input_action
|
|
40
42
|
|
|
41
|
-
def self.calling_llm_action(
|
|
42
|
-
prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: false)
|
|
43
|
+
def self.calling_llm_action(environment, event_sink, invocation)
|
|
44
|
+
prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: false)
|
|
43
45
|
invocation
|
|
44
46
|
end
|
|
45
47
|
private_class_method :calling_llm_action
|
|
46
48
|
|
|
47
|
-
def self.calling_llm_stream_action(
|
|
48
|
-
prepare_and_start_llm_call(agent, environment, event_sink, invocation, streaming: true)
|
|
49
|
+
def self.calling_llm_stream_action(environment, event_sink, invocation)
|
|
50
|
+
prepare_and_start_llm_call(invocation.agent, environment, event_sink, invocation, streaming: true)
|
|
49
51
|
invocation
|
|
50
52
|
end
|
|
51
53
|
private_class_method :calling_llm_stream_action
|
|
@@ -354,8 +356,8 @@ module Phronomy
|
|
|
354
356
|
end
|
|
355
357
|
private_class_method :failed_action
|
|
356
358
|
|
|
357
|
-
def self.output_filtering_action(
|
|
358
|
-
invocation.output = agent.send(:run_output_filters!, invocation.output)
|
|
359
|
+
def self.output_filtering_action(invocation)
|
|
360
|
+
invocation.output = invocation.agent.send(:run_output_filters!, invocation.output)
|
|
359
361
|
invocation
|
|
360
362
|
rescue Phronomy::FilterBlockError => error
|
|
361
363
|
invocation.output_blocked = true
|
|
@@ -75,6 +75,10 @@ module Phronomy
|
|
|
75
75
|
return @result if outcome.disposition == :committed
|
|
76
76
|
raise error if outcome.disposition == :not_committed
|
|
77
77
|
raise Phronomy::ExecutionRehydrationRequiredError, "Execution change outcome is unknown: #{(outcome.read_error || error).message}"
|
|
78
|
+
ensure
|
|
79
|
+
# A completed synchronous change may remain on a worker's native stack.
|
|
80
|
+
# Release live execution wiring only after commit/readback has finished.
|
|
81
|
+
@agent = @write = @participant = nil
|
|
78
82
|
end
|
|
79
83
|
|
|
80
84
|
private :perform_once
|
|
@@ -9,13 +9,17 @@ module Phronomy
|
|
|
9
9
|
class OutputContract
|
|
10
10
|
FACTORIES = {}
|
|
11
11
|
private_constant :FACTORIES
|
|
12
|
-
attr_reader :schema, :digest
|
|
12
|
+
attr_reader :schema, :digest, :schema_delivery
|
|
13
13
|
|
|
14
14
|
def self.install_factory(factory)
|
|
15
15
|
FACTORIES.replace(parser: factory).freeze
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def initialize(schema)
|
|
18
|
+
def initialize(schema, schema_delivery: :native)
|
|
19
|
+
unless %i[native none].include?(schema_delivery)
|
|
20
|
+
raise ArgumentError, "schema_delivery must be :native or :none"
|
|
21
|
+
end
|
|
22
|
+
@schema_delivery = schema_delivery
|
|
19
23
|
@parser = FACTORIES.fetch(:parser).call(schema)
|
|
20
24
|
@schema = @parser.json_schema
|
|
21
25
|
@digest = Digest::SHA256.hexdigest(JSON.generate(@schema, sort_keys: true)).freeze
|
|
@@ -23,20 +27,58 @@ module Phronomy
|
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
def metadata(contents)
|
|
26
|
-
{"output_contract" => {"version" =>
|
|
30
|
+
{"output_contract" => {"version" => 2, "schema_ref" => contents.put_text(JSON.generate(schema, sort_keys: true)),
|
|
31
|
+
"schema_digest" => digest, "schema_delivery" => schema_delivery.to_s}}
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
def self.restore(execution, contents)
|
|
30
35
|
stored = execution.metadata["output_contract"]
|
|
31
36
|
return nil unless stored
|
|
32
|
-
|
|
33
|
-
contract = new(contents.fetch_json(stored.fetch("schema_ref")))
|
|
37
|
+
contract = new(contents.fetch_json(stored.fetch("schema_ref")), schema_delivery: delivery(stored))
|
|
34
38
|
unless contract.digest == stored.fetch("schema_digest")
|
|
35
39
|
raise Phronomy::ContentStore::IntegrityError, "Agent output schema digest mismatch"
|
|
36
40
|
end
|
|
37
41
|
contract
|
|
38
42
|
end
|
|
39
43
|
|
|
44
|
+
# The schema stays durable even when the provider receives no schema.
|
|
45
|
+
def self.response_schema_ref(metadata)
|
|
46
|
+
stored = metadata["output_contract"]
|
|
47
|
+
stored.fetch("schema_ref") if stored && delivery(stored) == :native
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.delivery(stored)
|
|
51
|
+
case stored.fetch("version")
|
|
52
|
+
when 1 then :native
|
|
53
|
+
when 2
|
|
54
|
+
value = stored.fetch("schema_delivery")
|
|
55
|
+
unless %w[native none].include?(value)
|
|
56
|
+
raise ArgumentError, "unsupported Agent schema delivery"
|
|
57
|
+
end
|
|
58
|
+
value.to_sym
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "unsupported Agent output contract version"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Agent owns the final-output contract, including bypass prevention when
|
|
65
|
+
# no native schema is sent. Provider configuration cannot replace it.
|
|
66
|
+
def self.validate_model_config!(value)
|
|
67
|
+
case value
|
|
68
|
+
when Hash
|
|
69
|
+
value.each do |key, child|
|
|
70
|
+
if %w[schema schema_delivery output_schema response_schema response_format responseJsonSchema responseSchema
|
|
71
|
+
text.format output_config.format].include?(key.to_s) ||
|
|
72
|
+
(%w[text output_config].include?(key.to_s) && child.is_a?(Hash) && child.keys.any? { |name| name.to_s == "format" })
|
|
73
|
+
raise ArgumentError, "model configuration cannot override the Agent output contract"
|
|
74
|
+
end
|
|
75
|
+
validate_model_config!(child)
|
|
76
|
+
end
|
|
77
|
+
when Array
|
|
78
|
+
value.each { |child| validate_model_config!(child) }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
40
82
|
def validate(output, response:, execution_id:)
|
|
41
83
|
raw = response&.content
|
|
42
84
|
reason = if response&.refusal || response&.finish_reason == :refusal
|
|
@@ -12,6 +12,7 @@ module Phronomy
|
|
|
12
12
|
AGENT_ROOT_FORMAT_VERSION = "0.1"
|
|
13
13
|
AGENT_EXECUTION_RECORD_TYPE = "phronomy.agent_execution"
|
|
14
14
|
AGENT_EXECUTION_FORMAT_VERSION = "0.2"
|
|
15
|
+
OUTPUT_CONTRACT_FORMAT_VERSION = "0.3"
|
|
15
16
|
JOURNAL_RECORD_TYPE = "phronomy.journal_record"
|
|
16
17
|
JOURNAL_FORMAT_VERSION = "0.1"
|
|
17
18
|
AGENT_ROOT_KEYS = %w[
|
|
@@ -88,7 +89,7 @@ module Phronomy
|
|
|
88
89
|
validate_agent_execution_payload!(payload)
|
|
89
90
|
build_record(
|
|
90
91
|
AGENT_EXECUTION_RECORD_TYPE,
|
|
91
|
-
|
|
92
|
+
execution_format_version(payload),
|
|
92
93
|
payload
|
|
93
94
|
)
|
|
94
95
|
rescue Phronomy::Storage::SerializationError
|
|
@@ -101,11 +102,15 @@ module Phronomy
|
|
|
101
102
|
payload = current_payload!(
|
|
102
103
|
record,
|
|
103
104
|
record_type: AGENT_EXECUTION_RECORD_TYPE,
|
|
104
|
-
format_version:
|
|
105
|
+
format_version: (record.respond_to?(:format_version) && record.format_version == OUTPUT_CONTRACT_FORMAT_VERSION) ?
|
|
106
|
+
OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION,
|
|
105
107
|
keys: AGENT_EXECUTION_KEYS,
|
|
106
108
|
label: "AgentExecution payload"
|
|
107
109
|
)
|
|
108
110
|
validate_agent_execution_payload!(payload)
|
|
111
|
+
unless record.format_version == execution_format_version(payload)
|
|
112
|
+
raise Phronomy::Storage::SerializationError, "Agent output contract does not match execution format version"
|
|
113
|
+
end
|
|
109
114
|
Phronomy::Agent::AgentExecution.from_h(payload)
|
|
110
115
|
rescue Phronomy::Storage::SerializationError
|
|
111
116
|
raise
|
|
@@ -113,6 +118,15 @@ module Phronomy
|
|
|
113
118
|
serialization_error("cannot decode AgentExecution", error)
|
|
114
119
|
end
|
|
115
120
|
|
|
121
|
+
# Old readers reject the new envelope before interpreting any execution.
|
|
122
|
+
# Freeform and version-one records retain their original envelope.
|
|
123
|
+
def execution_format_version(payload)
|
|
124
|
+
stored = payload.fetch("metadata")["output_contract"]
|
|
125
|
+
return AGENT_EXECUTION_FORMAT_VERSION unless stored
|
|
126
|
+
Phronomy::Agent::OutputContract.delivery(stored)
|
|
127
|
+
(stored.fetch("version") == 2) ? OUTPUT_CONTRACT_FORMAT_VERSION : AGENT_EXECUTION_FORMAT_VERSION
|
|
128
|
+
end
|
|
129
|
+
|
|
116
130
|
def encode_journal_record(journal_record)
|
|
117
131
|
payload = journal_payload(journal_record, require_sequence: true)
|
|
118
132
|
build_record(JOURNAL_RECORD_TYPE, JOURNAL_FORMAT_VERSION, payload)
|
|
@@ -28,6 +28,7 @@ module Phronomy
|
|
|
28
28
|
# operation-local projections transferred to the new invocation, never a
|
|
29
29
|
# shared cache or a second source of continuation decisions.
|
|
30
30
|
def prepare_recovery_material(execution, projection: nil)
|
|
31
|
+
OutputContract.restore(execution, agent.persistence.contents)
|
|
31
32
|
action = recovery_action(execution)
|
|
32
33
|
suspended = execution.status == :suspended || execution.phase.to_sym == :resuming
|
|
33
34
|
return if !suspended && %i[resolution_required failed_terminal].include?(action)
|
|
@@ -9,8 +9,8 @@ module Phronomy
|
|
|
9
9
|
def self.build(invocation:, environment:, event_loop:, resume_event: nil, resume_phase: nil)
|
|
10
10
|
event_sink = Phronomy::FSMSession::EventSink.new(event_loop: event_loop)
|
|
11
11
|
invocation.bind_event_sink!(event_sink)
|
|
12
|
-
actions = InvocationActions.build_entry_actions(
|
|
13
|
-
|
|
12
|
+
actions = InvocationActions.build_entry_actions(environment,
|
|
13
|
+
mode: invocation.mode, event_sink: event_sink)
|
|
14
14
|
phase_machine = PhaseMachineBuilder.new(entry_actions: actions).build
|
|
15
15
|
|
|
16
16
|
Phronomy::FSMSession.new(
|
|
@@ -54,6 +54,13 @@ module Phronomy
|
|
|
54
54
|
)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# One interpretation for execution results and public transcript reads.
|
|
58
|
+
def materialize_transcript(root:, records:)
|
|
59
|
+
materialize_journal_records(
|
|
60
|
+
JournalProjection.new(agent_root: root, records: records).transcript_records
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
57
64
|
def materialize_journal_record(record)
|
|
58
65
|
materialize_segment(segment_from_record(record))
|
|
59
66
|
end
|
|
@@ -165,6 +165,10 @@ module Phronomy
|
|
|
165
165
|
end
|
|
166
166
|
rescue => error
|
|
167
167
|
finish_with_error(error)
|
|
168
|
+
ensure
|
|
169
|
+
# The session owns its context between events. A tracker retained by the
|
|
170
|
+
# state-machine library or VM must not retain that live context as well.
|
|
171
|
+
@tracker.context = nil if @tracker
|
|
168
172
|
end
|
|
169
173
|
|
|
170
174
|
def handle(event)
|
|
@@ -190,6 +194,9 @@ module Phronomy
|
|
|
190
194
|
fire_and_advance!(event)
|
|
191
195
|
rescue => error
|
|
192
196
|
finish_with_error(error)
|
|
197
|
+
ensure
|
|
198
|
+
# fire_and_advance! rebinds the context for the next accepted transition.
|
|
199
|
+
@tracker.context = nil if @tracker
|
|
193
200
|
end
|
|
194
201
|
|
|
195
202
|
private
|
|
@@ -25,6 +25,22 @@ module Phronomy
|
|
|
25
25
|
map(source, name: name, on_error: on_error, &transform)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Finalize owned work after its logical and physical boundaries. Cleanup is
|
|
29
|
+
# asynchronous too. The owner supplies cleanup and combined-error meaning;
|
|
30
|
+
# neither observer cancellation nor an Execution scope can skip cleanup.
|
|
31
|
+
# @api private
|
|
32
|
+
def self.finalize(name:, cleanup:, on_cleanup_error:)
|
|
33
|
+
source = begin
|
|
34
|
+
value = yield
|
|
35
|
+
raise TypeError, "finalized operation must return a TaskResult" unless value.is_a?(TaskResult)
|
|
36
|
+
value
|
|
37
|
+
rescue => error
|
|
38
|
+
TaskResult.failed(error, name: name)
|
|
39
|
+
end
|
|
40
|
+
Concurrency::ResultFinalization.new(source, name: name,
|
|
41
|
+
cleanup: cleanup, on_cleanup_error: on_cleanup_error).start
|
|
42
|
+
end
|
|
43
|
+
|
|
28
44
|
# Short synchronous fallback/validation only; never offloads or awaits work.
|
|
29
45
|
def self.capture(name:, on_error: nil)
|
|
30
46
|
value = yield
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Concurrency
|
|
5
|
+
# Completion composition for an owner-supplied asynchronous finalizer.
|
|
6
|
+
# This has no cancellation authority and no domain release policy.
|
|
7
|
+
# @api private
|
|
8
|
+
class ResultFinalization
|
|
9
|
+
def initialize(source, name:, cleanup:, on_cleanup_error:)
|
|
10
|
+
@source = source
|
|
11
|
+
@cleanup = cleanup
|
|
12
|
+
@on_cleanup_error = on_cleanup_error
|
|
13
|
+
@result = PhysicalCompletionTask.deferred(name: name)
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@phase = :source
|
|
16
|
+
@outcomes = {}
|
|
17
|
+
@physical = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
observe(@source, :source)
|
|
22
|
+
@result
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def observe(task, phase)
|
|
28
|
+
physical = task.respond_to?(:on_physical_complete)
|
|
29
|
+
@mutex.synchronize { @physical[phase] = !physical }
|
|
30
|
+
if physical
|
|
31
|
+
task.on_physical_complete do
|
|
32
|
+
@mutex.synchronize { @physical[phase] = true unless @phase == :finished }
|
|
33
|
+
advance
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
task.on_complete do |value, error|
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
if @phase == phase && !@outcomes.key?(phase)
|
|
39
|
+
status = if error
|
|
40
|
+
(task.status == :cancelled) ? :cancelled : :failed
|
|
41
|
+
else
|
|
42
|
+
:completed
|
|
43
|
+
end
|
|
44
|
+
@outcomes[phase] = [status, value, error]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
advance
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def advance
|
|
52
|
+
action = @mutex.synchronize do
|
|
53
|
+
phase = @phase
|
|
54
|
+
next unless @outcomes.key?(phase) && @physical[phase]
|
|
55
|
+
@phase = (phase == :source) ? :cleanup : :finished
|
|
56
|
+
phase
|
|
57
|
+
end
|
|
58
|
+
case action
|
|
59
|
+
when :source then start_cleanup
|
|
60
|
+
when :cleanup then finish
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def start_cleanup
|
|
65
|
+
task = begin
|
|
66
|
+
value = @cleanup.call
|
|
67
|
+
raise TypeError, "asynchronous cleanup must return a TaskResult" unless value.is_a?(TaskResult)
|
|
68
|
+
value
|
|
69
|
+
rescue => error
|
|
70
|
+
TaskResult.failed(error)
|
|
71
|
+
end
|
|
72
|
+
observe(task, :cleanup)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finish
|
|
76
|
+
status, value, error = @outcomes.fetch(:source)
|
|
77
|
+
cleanup_error = @outcomes.fetch(:cleanup).last
|
|
78
|
+
if cleanup_error
|
|
79
|
+
begin
|
|
80
|
+
error = @on_cleanup_error.call(value, error, cleanup_error)
|
|
81
|
+
raise TypeError, "cleanup error policy must return or raise an Exception" unless error.is_a?(Exception)
|
|
82
|
+
rescue => policy_error
|
|
83
|
+
error = policy_error
|
|
84
|
+
end
|
|
85
|
+
status = :failed
|
|
86
|
+
end
|
|
87
|
+
# Completed handles must not keep the owner or its source/cleanup
|
|
88
|
+
# closures alive. A domain cleanup error may deliberately retain them.
|
|
89
|
+
@source = @cleanup = @on_cleanup_error = nil
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@outcomes.clear
|
|
92
|
+
@physical.clear
|
|
93
|
+
end
|
|
94
|
+
begin
|
|
95
|
+
@result.mark_physical_complete!
|
|
96
|
+
ensure
|
|
97
|
+
case status
|
|
98
|
+
when :completed then @result.complete(value)
|
|
99
|
+
when :cancelled then @result.cancel!(error)
|
|
100
|
+
else @result.fail(error)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -8,8 +8,8 @@ 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, output_schema:)
|
|
12
|
-
@output_schema = output_schema
|
|
11
|
+
def initialize(agent_class, output_schema:, schema_delivery:)
|
|
12
|
+
@output_schema, @schema_delivery = output_schema, schema_delivery
|
|
13
13
|
@mutex = Mutex.new
|
|
14
14
|
@pending = []
|
|
15
15
|
@agent = agent_class.new(on_event: method(:receive_event))
|
|
@@ -21,7 +21,7 @@ module Phronomy
|
|
|
21
21
|
# own result settles: Agent admission itself may run after an earlier
|
|
22
22
|
# request completes, so a pending submission is not necessarily busy.
|
|
23
23
|
@mutex.synchronize { @pending << [request, listener] }
|
|
24
|
-
task = @agent.invoke_async(input, config: config, output_schema: @output_schema)
|
|
24
|
+
task = @agent.invoke_async(input, config: config, output_schema: @output_schema, schema_delivery: @schema_delivery)
|
|
25
25
|
task.on_complete do |result, error|
|
|
26
26
|
release(request)
|
|
27
27
|
remember_execution(result[:execution_id]) if result.is_a?(Hash)
|
|
@@ -34,10 +34,13 @@ module Phronomy
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def initialize(draft_agent:, review_agent:, draft_prompt_builder:, review_prompt_builder:,
|
|
37
|
-
citation_schema: Protocol::DEFAULT_CITATION,
|
|
37
|
+
citation_schema: Protocol::DEFAULT_CITATION, schema_delivery: :native,
|
|
38
38
|
confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
|
|
39
39
|
max_iterations: DEFAULT_MAX_ITERATIONS, raise_if_unaccepted: false, on_approval: nil)
|
|
40
40
|
ensure_blocking_allowed!(:new)
|
|
41
|
+
unless %i[native none].include?(schema_delivery)
|
|
42
|
+
raise ArgumentError, "schema_delivery must be :native or :none"
|
|
43
|
+
end
|
|
41
44
|
unless confidence_threshold.is_a?(Numeric) && confidence_threshold.real? && confidence_threshold.to_f.finite? &&
|
|
42
45
|
(0.0..1.0).cover?(confidence_threshold)
|
|
43
46
|
raise ArgumentError, "confidence_threshold must be a finite number in 0..1"
|
|
@@ -62,8 +65,8 @@ module Phronomy
|
|
|
62
65
|
@draft_prompt_builder, @review_prompt_builder = draft_prompt_builder, review_prompt_builder
|
|
63
66
|
draft_schema = Protocol.draft(citation_schema)
|
|
64
67
|
begin
|
|
65
|
-
@operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema)
|
|
66
|
-
@operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW)
|
|
68
|
+
@operations[:draft] = AgentOperation.new(draft_agent, output_schema: draft_schema, schema_delivery: schema_delivery)
|
|
69
|
+
@operations[:review] = AgentOperation.new(review_agent, output_schema: Protocol::REVIEW, schema_delivery: schema_delivery)
|
|
67
70
|
rescue Exception => primary # rubocop:disable Lint/RescueException -- release partially acquired resources
|
|
68
71
|
begin
|
|
69
72
|
release!
|
|
@@ -63,7 +63,7 @@ module Phronomy
|
|
|
63
63
|
callback_error = nil
|
|
64
64
|
chat = build_chat(request)
|
|
65
65
|
capture = StructuredResponseCapture.new
|
|
66
|
-
capture.bind(chat) if
|
|
66
|
+
capture.bind(chat) if block_given?
|
|
67
67
|
token&.raise_if_cancelled!
|
|
68
68
|
chat.after_message do |message|
|
|
69
69
|
raise ToolsRequested.new(message) if message.tool_call?
|
|
@@ -24,7 +24,9 @@ module Phronomy
|
|
|
24
24
|
|
|
25
25
|
def record(data)
|
|
26
26
|
choice = data.dig("choices", 0) || {}
|
|
27
|
-
|
|
27
|
+
provider_delta = data["delta"]
|
|
28
|
+
stop_reason = provider_delta["stop_reason"] if provider_delta.is_a?(Hash)
|
|
29
|
+
@raw_finish_reason = choice["finish_reason"] || stop_reason ||
|
|
28
30
|
data.dig("candidates", 0, "finishReason") || @raw_finish_reason
|
|
29
31
|
delta = choice.dig("delta", "refusal")
|
|
30
32
|
delta ||= data["delta"] if data["type"] == "response.refusal.delta"
|