phronomy 0.22.0 → 0.24.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/.mutant.yml +3 -4
- data/CHANGELOG.md +215 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +147 -0
- data/docs/architecture/persistence.md +186 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +89 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +176 -0
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +65 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +162 -72
- data/lib/phronomy/agent/base.rb +416 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +339 -103
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- data/lib/phronomy/agent/runner.rb +0 -97
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -7,12 +7,13 @@ module Phronomy
|
|
|
7
7
|
module Agent
|
|
8
8
|
class AgentExecution
|
|
9
9
|
ACTIVE_STATUSES = %i[preparing active suspended].freeze
|
|
10
|
-
TERMINAL_STATUSES = %i[completed failed cancelled rejected blocked].freeze
|
|
10
|
+
TERMINAL_STATUSES = %i[completed handed_off failed cancelled rejected blocked].freeze
|
|
11
11
|
TRANSITIONS = {
|
|
12
12
|
preparing: %i[preparing active failed cancelled blocked],
|
|
13
|
-
active: %i[active suspended completed failed cancelled rejected blocked],
|
|
13
|
+
active: %i[active suspended completed handed_off failed cancelled rejected blocked],
|
|
14
14
|
suspended: %i[suspended active failed cancelled],
|
|
15
15
|
completed: %i[completed],
|
|
16
|
+
handed_off: %i[handed_off],
|
|
16
17
|
failed: %i[failed],
|
|
17
18
|
cancelled: %i[cancelled],
|
|
18
19
|
rejected: %i[rejected],
|
|
@@ -27,10 +28,10 @@ module Phronomy
|
|
|
27
28
|
].freeze
|
|
28
29
|
attr_reader(*ATTRIBUTES)
|
|
29
30
|
|
|
30
|
-
def self.start(agent_root:, input_record:, metadata: {})
|
|
31
|
+
def self.start(agent_root:, input_record:, metadata: {}, execution_id: SecureRandom.uuid)
|
|
31
32
|
now = Time.now.utc.iso8601(6)
|
|
32
33
|
new(
|
|
33
|
-
execution_id:
|
|
34
|
+
execution_id: execution_id.to_s,
|
|
34
35
|
agent_id: agent_root.agent_id,
|
|
35
36
|
execution_revision: 0,
|
|
36
37
|
status: :preparing,
|
|
@@ -84,10 +85,8 @@ module Phronomy
|
|
|
84
85
|
self.class.new(**values)
|
|
85
86
|
end
|
|
86
87
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# @return [Hash{String => Object}]
|
|
88
|
+
# Current semantic payload representation. Persistence format identity and
|
|
89
|
+
# compatibility validation are owned by Persistence::DurableCodec.
|
|
91
90
|
# @api public
|
|
92
91
|
def to_h
|
|
93
92
|
ATTRIBUTES.to_h do |name|
|
|
@@ -98,18 +97,23 @@ module Phronomy
|
|
|
98
97
|
end
|
|
99
98
|
end
|
|
100
99
|
|
|
101
|
-
# Restores
|
|
102
|
-
#
|
|
103
|
-
# records and LLM Call records are restored through their public codecs so
|
|
104
|
-
# storage backends do not need to know their constructor details.
|
|
105
|
-
#
|
|
106
|
-
# @param hash [Hash]
|
|
107
|
-
# @return [AgentExecution]
|
|
100
|
+
# Restores only the current semantic payload shape. Historical durable
|
|
101
|
+
# representations must go through explicit Persistence migration first.
|
|
108
102
|
# @api public
|
|
109
103
|
def self.from_h(hash)
|
|
104
|
+
source = hash.to_h { |key, value| [key.to_s, value] }
|
|
105
|
+
expected = ATTRIBUTES.map(&:to_s).sort
|
|
106
|
+
actual = source.keys.sort
|
|
107
|
+
unless actual == expected
|
|
108
|
+
missing = expected - actual
|
|
109
|
+
unknown = actual - expected
|
|
110
|
+
raise ArgumentError,
|
|
111
|
+
"AgentExecution payload schema mismatch: " \
|
|
112
|
+
"missing=#{missing.inspect}, unknown=#{unknown.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
110
115
|
attributes = ATTRIBUTES.to_h do |name|
|
|
111
|
-
|
|
112
|
-
[name, hash.fetch(key)]
|
|
116
|
+
[name, source.fetch(name.to_s)]
|
|
113
117
|
end
|
|
114
118
|
|
|
115
119
|
attributes[:working_records] = attributes.fetch(:working_records).map do |record|
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require "time"
|
|
4
5
|
|
|
5
6
|
module Phronomy
|
|
6
7
|
module Agent
|
|
7
|
-
# Mutable domain state for one Agent invocation.
|
|
8
|
-
#
|
|
9
|
-
# AgentInvocation interprets Agent-internal events. FSMSession owns the
|
|
10
|
-
# transition mechanics, while Agent::Base projects the terminal outcome to
|
|
11
|
-
# both the Application listener and the returned Task.
|
|
12
|
-
#
|
|
13
|
-
# @api private
|
|
14
8
|
class AgentInvocation
|
|
9
|
+
# execution_id is the logical Agent execution parent. Concrete Runtime
|
|
10
|
+
# routing identity belongs only to the owning FSMSession incarnation.
|
|
15
11
|
TOOL_EVENT_TYPES = %i[
|
|
16
12
|
tool_authorized
|
|
17
13
|
tool_approval_required
|
|
@@ -21,12 +17,25 @@ module Phronomy
|
|
|
21
17
|
tool_cancelled
|
|
22
18
|
].freeze
|
|
23
19
|
|
|
24
|
-
LLM_EVENT_TYPES = %i[
|
|
25
|
-
llm_completed
|
|
26
|
-
llm_failed
|
|
27
|
-
].freeze
|
|
28
|
-
|
|
20
|
+
LLM_EVENT_TYPES = %i[llm_completed llm_failed].freeze
|
|
29
21
|
CALLBACK_FAILED_EVENTS = %i[application_callback_failed].freeze
|
|
22
|
+
SETUP_FAILED_EVENTS = %i[llm_setup_failed tool_setup_failed].freeze
|
|
23
|
+
|
|
24
|
+
ApplicationCallbackFailure = Data.define(:event_type, :error) do
|
|
25
|
+
def to_stream_callback_error
|
|
26
|
+
wrapped = Phronomy::StreamCallbackError.new(
|
|
27
|
+
event_type: event_type,
|
|
28
|
+
original_error: error,
|
|
29
|
+
result: nil
|
|
30
|
+
)
|
|
31
|
+
begin
|
|
32
|
+
raise wrapped, cause: error
|
|
33
|
+
rescue Phronomy::StreamCallbackError => caught
|
|
34
|
+
caught.set_backtrace(error.backtrace)
|
|
35
|
+
caught
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
30
39
|
|
|
31
40
|
attr_accessor :input,
|
|
32
41
|
:chat,
|
|
@@ -41,19 +50,19 @@ module Phronomy
|
|
|
41
50
|
:rejected,
|
|
42
51
|
:error
|
|
43
52
|
|
|
44
|
-
attr_reader :
|
|
53
|
+
attr_reader :execution_id,
|
|
45
54
|
:agent,
|
|
46
55
|
:config,
|
|
47
|
-
:thread_id,
|
|
48
56
|
:approval_policy,
|
|
49
57
|
:approval_listener,
|
|
50
58
|
:pending_tool_calls,
|
|
51
59
|
:tool_invocations,
|
|
52
|
-
:session_id,
|
|
53
60
|
:phase,
|
|
54
61
|
:mode,
|
|
55
|
-
:
|
|
56
|
-
:
|
|
62
|
+
:tool_batch_llm_call_id,
|
|
63
|
+
:handoff_request,
|
|
64
|
+
:callback_failure,
|
|
65
|
+
:event_sink
|
|
57
66
|
|
|
58
67
|
def initialize(
|
|
59
68
|
agent:,
|
|
@@ -63,20 +72,22 @@ module Phronomy
|
|
|
63
72
|
approval_listener: nil,
|
|
64
73
|
event_listener: nil,
|
|
65
74
|
mode: nil,
|
|
66
|
-
|
|
75
|
+
execution_id: nil
|
|
67
76
|
)
|
|
68
77
|
@agent = agent
|
|
69
78
|
@input = input
|
|
70
|
-
|
|
71
|
-
@
|
|
72
|
-
|
|
73
|
-
|
|
79
|
+
# The caller's Hash must not become a shared mutable Runtime object.
|
|
80
|
+
@config = config.dup
|
|
81
|
+
resolved_execution_id = execution_id || @config[:execution_id]
|
|
82
|
+
@execution_id = resolved_execution_id&.to_s&.freeze
|
|
83
|
+
invocation_context = @config[:invocation_context]
|
|
74
84
|
invocation_policy = if invocation_context&.respond_to?(:approval_policy)
|
|
75
85
|
invocation_context.approval_policy
|
|
76
86
|
end
|
|
77
87
|
@approval_policy = invocation_policy || approval_policy
|
|
78
88
|
@approval_listener = approval_listener
|
|
79
89
|
@event_listener = event_listener
|
|
90
|
+
@event_sink = nil
|
|
80
91
|
@mode = (mode || :invoke).to_sym
|
|
81
92
|
|
|
82
93
|
@chat = nil
|
|
@@ -94,26 +105,68 @@ module Phronomy
|
|
|
94
105
|
@approval_resume_in_progress = false
|
|
95
106
|
@pending_approval_resolution_ids = []
|
|
96
107
|
@error = nil
|
|
97
|
-
@session_id = nil
|
|
98
108
|
@phase = nil
|
|
99
|
-
@
|
|
109
|
+
@current_llm_call = nil
|
|
100
110
|
@tool_batch_llm_call_id = nil
|
|
111
|
+
@handoff_request = nil
|
|
112
|
+
@llm_results = []
|
|
113
|
+
@runtime_events = []
|
|
114
|
+
@callback_failure = nil
|
|
101
115
|
end
|
|
102
116
|
|
|
103
117
|
def streaming?
|
|
104
118
|
@mode == :stream
|
|
105
119
|
end
|
|
106
120
|
|
|
107
|
-
def set_graph_metadata(
|
|
108
|
-
@session_id = thread_id if thread_id
|
|
121
|
+
def set_graph_metadata(phase: nil)
|
|
109
122
|
@phase = phase
|
|
110
123
|
end
|
|
111
124
|
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
# Binds the Runtime-only event target of the currently owning FSMSession.
|
|
126
|
+
# A resume creates a new FSMSession incarnation and therefore replaces the
|
|
127
|
+
# previous sink on EventLoop before any resumed work starts.
|
|
128
|
+
def bind_event_sink!(sink)
|
|
129
|
+
@event_sink = sink
|
|
114
130
|
self
|
|
115
131
|
end
|
|
116
132
|
|
|
133
|
+
def current_llm_call_id
|
|
134
|
+
@current_llm_call&.fetch(:llm_call_id)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Allocates Provider Call identity on EventLoop before transport begins.
|
|
138
|
+
# The optional explicit ID is retained for focused internal tests; normal
|
|
139
|
+
# runtime code leaves it nil and receives a fresh UUID.
|
|
140
|
+
def begin_llm_call!(projection, llm_call_id: nil)
|
|
141
|
+
if @current_llm_call
|
|
142
|
+
raise Phronomy::Error,
|
|
143
|
+
"cannot start a Provider Call while another Provider Call is active"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
durable_id = llm_call_id
|
|
147
|
+
if durable_id.nil? &&
|
|
148
|
+
Phronomy::Runtime.instance.event_loop.current?
|
|
149
|
+
state =
|
|
150
|
+
Phronomy::Runtime.instance.event_loop.agent_execution_state(
|
|
151
|
+
execution_id
|
|
152
|
+
)
|
|
153
|
+
durable_id = state&.execution&.metadata&.fetch(
|
|
154
|
+
RecoverySupport::PENDING_LLM_ID_KEY,
|
|
155
|
+
nil
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
unless durable_id
|
|
159
|
+
raise Phronomy::ExecutionRehydrationRequiredError,
|
|
160
|
+
"Provider Call semantic identity was not durably established before dispatch"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@current_llm_call = {
|
|
164
|
+
llm_call_id: durable_id.to_s.freeze,
|
|
165
|
+
manifest_ref: projection.manifest_ref.to_s.freeze,
|
|
166
|
+
started_at: Time.now.utc.iso8601(6).freeze
|
|
167
|
+
}.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
117
170
|
def pending_tool_calls=(calls)
|
|
118
171
|
@pending_tool_calls = Array(calls)
|
|
119
172
|
end
|
|
@@ -131,17 +184,23 @@ module Phronomy
|
|
|
131
184
|
|
|
132
185
|
def handle_fsm_event(event)
|
|
133
186
|
if event.type == :llm_stream_chunk
|
|
187
|
+
payload = event.payload || {}
|
|
188
|
+
return :consume unless current_llm_result_authoritative?(payload[:llm_call_id])
|
|
189
|
+
|
|
134
190
|
deliver_event(
|
|
135
|
-
StreamEvent.new(
|
|
136
|
-
type: :token,
|
|
137
|
-
payload: {content: event.payload.fetch(:content)}
|
|
138
|
-
)
|
|
191
|
+
StreamEvent.new(type: :token, payload: {content: payload.fetch(:content)})
|
|
139
192
|
)
|
|
140
193
|
return true
|
|
141
194
|
end
|
|
142
195
|
|
|
143
196
|
if LLM_EVENT_TYPES.include?(event.type)
|
|
144
|
-
apply_llm_event(event)
|
|
197
|
+
return apply_llm_event(event)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if SETUP_FAILED_EVENTS.include?(event.type)
|
|
201
|
+
@error ||= event.payload.is_a?(Exception) ? event.payload :
|
|
202
|
+
event.payload&.fetch(:error, nil)
|
|
203
|
+
@error ||= Phronomy::Error.new("external operation setup failed without an error")
|
|
145
204
|
return true
|
|
146
205
|
end
|
|
147
206
|
|
|
@@ -152,27 +211,47 @@ module Phronomy
|
|
|
152
211
|
|
|
153
212
|
return false unless TOOL_EVENT_TYPES.include?(event.type)
|
|
154
213
|
|
|
155
|
-
invocation = tool_invocation(
|
|
156
|
-
event.payload&.fetch(:tool_invocation_id, nil)
|
|
157
|
-
)
|
|
214
|
+
invocation = tool_invocation(event.payload&.fetch(:tool_invocation_id, nil))
|
|
158
215
|
return true unless invocation
|
|
159
216
|
|
|
160
217
|
@error ||= invocation.error if invocation.failed? || invocation.cancelled?
|
|
161
218
|
@rejected = true if invocation.rejected?
|
|
162
219
|
if @approval_resume_in_progress &&
|
|
163
220
|
@pending_approval_resolution_ids.delete(invocation.id)
|
|
164
|
-
if @pending_approval_resolution_ids.empty?
|
|
165
|
-
@approval_resume_in_progress = false
|
|
166
|
-
end
|
|
221
|
+
@approval_resume_in_progress = false if @pending_approval_resolution_ids.empty?
|
|
167
222
|
end
|
|
168
223
|
true
|
|
169
224
|
end
|
|
170
225
|
|
|
171
226
|
def accept_tool_calls!(tool_calls, llm_call_id: nil)
|
|
172
227
|
@user_message_sent = true
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
228
|
+
calls = Array(tool_calls)
|
|
229
|
+
call_llm_id = (llm_call_id || current_llm_call_id)&.to_s
|
|
230
|
+
handoff_matches = handoff_matches_for(calls)
|
|
231
|
+
|
|
232
|
+
unless handoff_matches.empty?
|
|
233
|
+
if handoff_matches.length != 1 || calls.length != 1
|
|
234
|
+
@error = Phronomy::HandoffError.new(
|
|
235
|
+
"one Provider outcome cannot mix Handoff with ordinary Tool Calls or multiple Handoffs"
|
|
236
|
+
)
|
|
237
|
+
@pending_tool_calls = []
|
|
238
|
+
@tool_batch_llm_call_id = call_llm_id
|
|
239
|
+
return self
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
tool_call, binding = handoff_matches.first
|
|
243
|
+
@handoff_request = build_handoff_request(
|
|
244
|
+
tool_call,
|
|
245
|
+
binding,
|
|
246
|
+
llm_call_id: call_llm_id
|
|
247
|
+
)
|
|
248
|
+
@pending_tool_calls = []
|
|
249
|
+
@tool_batch_llm_call_id = call_llm_id
|
|
250
|
+
return self
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
@pending_tool_calls = calls
|
|
254
|
+
@tool_batch_llm_call_id = call_llm_id
|
|
176
255
|
@pending_tool_calls.each do |tool_call|
|
|
177
256
|
deliver_event(
|
|
178
257
|
StreamEvent.new(
|
|
@@ -185,18 +264,28 @@ module Phronomy
|
|
|
185
264
|
)
|
|
186
265
|
end
|
|
187
266
|
self
|
|
267
|
+
rescue => caught
|
|
268
|
+
@error = caught.is_a?(Phronomy::HandoffError) ? caught :
|
|
269
|
+
Phronomy::HandoffError.new("invalid Handoff request: #{caught.message}")
|
|
270
|
+
@pending_tool_calls = []
|
|
271
|
+
self
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def handoff_requested?
|
|
275
|
+
!@handoff_request.nil?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def handoff_failed?
|
|
279
|
+
@error.is_a?(Phronomy::HandoffError) && !handoff_requested?
|
|
188
280
|
end
|
|
189
281
|
|
|
190
282
|
def apply_llm_response!(response)
|
|
191
|
-
unless response
|
|
192
|
-
raise Phronomy::Error, "LLM operation completed without a response"
|
|
193
|
-
end
|
|
283
|
+
raise Phronomy::Error, "LLM operation completed without a response" unless response
|
|
194
284
|
|
|
195
285
|
@user_message_sent = true
|
|
196
286
|
@output = response.content
|
|
197
287
|
@usage = Phronomy::TokenUsage.from_tokens(response.tokens)
|
|
198
288
|
@pending_tool_calls = []
|
|
199
|
-
@current_llm_call_id = nil
|
|
200
289
|
self
|
|
201
290
|
end
|
|
202
291
|
|
|
@@ -215,13 +304,8 @@ module Phronomy
|
|
|
215
304
|
context = @config[:invocation_context]
|
|
216
305
|
return {} unless context
|
|
217
306
|
|
|
218
|
-
%i[
|
|
219
|
-
|
|
220
|
-
task_id parent_task_id
|
|
221
|
-
].each_with_object({}) do |name, result|
|
|
222
|
-
if context.respond_to?(name)
|
|
223
|
-
result[name] = context.public_send(name)
|
|
224
|
-
end
|
|
307
|
+
%i[user_id token_budget task_id parent_task_id].each_with_object({}) do |name, result|
|
|
308
|
+
result[name] = context.public_send(name) if context.respond_to?(name)
|
|
225
309
|
end
|
|
226
310
|
end
|
|
227
311
|
|
|
@@ -230,8 +314,7 @@ module Phronomy
|
|
|
230
314
|
@pending_approval_resolution_ids = @tool_invocations
|
|
231
315
|
.select(&:awaiting_approval?)
|
|
232
316
|
.map(&:id)
|
|
233
|
-
@approval_resume_in_progress =
|
|
234
|
-
!@pending_approval_resolution_ids.empty?
|
|
317
|
+
@approval_resume_in_progress = !@pending_approval_resolution_ids.empty?
|
|
235
318
|
self
|
|
236
319
|
end
|
|
237
320
|
|
|
@@ -269,29 +352,40 @@ module Phronomy
|
|
|
269
352
|
self
|
|
270
353
|
end
|
|
271
354
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
355
|
+
# EventLoop-owned snapshot handed to one durable Offload operation.
|
|
356
|
+
# Values already canonicalized by Phronomy are immutable; the arrays are
|
|
357
|
+
# copied/frozen so worker code cannot mutate the live queues.
|
|
358
|
+
def runtime_snapshot
|
|
359
|
+
{
|
|
360
|
+
llm_results: @llm_results.dup.freeze,
|
|
361
|
+
runtime_events: @runtime_events.dup.freeze,
|
|
362
|
+
active_call: @current_llm_call
|
|
363
|
+
}.freeze
|
|
278
364
|
end
|
|
279
365
|
|
|
280
|
-
|
|
281
|
-
|
|
366
|
+
# Applies a successful durable barrier on EventLoop. Facts appended after
|
|
367
|
+
# the snapshot was taken remain queued for the next barrier.
|
|
368
|
+
def acknowledge_runtime_snapshot(snapshot)
|
|
369
|
+
@llm_results.shift(snapshot.fetch(:llm_results).length)
|
|
370
|
+
@runtime_events.shift(snapshot.fetch(:runtime_events).length)
|
|
371
|
+
if snapshot[:active_call] && @current_llm_call == snapshot[:active_call]
|
|
372
|
+
@current_llm_call = nil
|
|
373
|
+
end
|
|
374
|
+
self
|
|
282
375
|
end
|
|
283
376
|
|
|
284
|
-
def
|
|
285
|
-
|
|
377
|
+
def callback_failed?
|
|
378
|
+
!@callback_failure.nil?
|
|
286
379
|
end
|
|
287
380
|
|
|
288
|
-
def
|
|
289
|
-
|
|
290
|
-
|
|
381
|
+
def input_passed? = !@input_blocked
|
|
382
|
+
def input_blocked? = @input_blocked
|
|
383
|
+
def output_passed? = !@output_blocked
|
|
384
|
+
def output_blocked? = @output_blocked
|
|
385
|
+
def tool_call_pending? = !@pending_tool_calls.empty?
|
|
291
386
|
|
|
292
387
|
def preflight_complete?
|
|
293
|
-
!@tool_invocations.empty? &&
|
|
294
|
-
@tool_invocations.all?(&:preflight_settled?)
|
|
388
|
+
!@tool_invocations.empty? && @tool_invocations.all?(&:preflight_settled?)
|
|
295
389
|
end
|
|
296
390
|
|
|
297
391
|
def approval_required?
|
|
@@ -312,24 +406,19 @@ module Phronomy
|
|
|
312
406
|
end
|
|
313
407
|
|
|
314
408
|
def tool_batch_terminal?
|
|
315
|
-
!@tool_invocations.empty? &&
|
|
316
|
-
@tool_invocations.all?(&:terminal?)
|
|
409
|
+
!@tool_invocations.empty? && @tool_invocations.all?(&:terminal?)
|
|
317
410
|
end
|
|
318
411
|
|
|
319
412
|
def tool_batch_failed?
|
|
320
413
|
return false if @human_rejection || @approval_resume_in_progress
|
|
321
414
|
|
|
322
415
|
no_rejection = @tool_invocations.none?(&:rejected?)
|
|
323
|
-
preflight_failure =
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
tool_batch_terminal? &&
|
|
330
|
-
@tool_invocations.any? do |invocation|
|
|
331
|
-
invocation.failed? || invocation.cancelled?
|
|
332
|
-
end
|
|
416
|
+
preflight_failure = preflight_complete? && @tool_invocations.any? do |invocation|
|
|
417
|
+
invocation.failed? || invocation.cancelled?
|
|
418
|
+
end
|
|
419
|
+
terminal_failure = tool_batch_terminal? && @tool_invocations.any? do |invocation|
|
|
420
|
+
invocation.failed? || invocation.cancelled?
|
|
421
|
+
end
|
|
333
422
|
no_rejection && (preflight_failure || terminal_failure)
|
|
334
423
|
end
|
|
335
424
|
|
|
@@ -340,43 +429,149 @@ module Phronomy
|
|
|
340
429
|
end
|
|
341
430
|
|
|
342
431
|
def tool_batch_completed?
|
|
343
|
-
tool_batch_terminal? &&
|
|
344
|
-
@tool_invocations.all?(&:execution_completed?)
|
|
432
|
+
tool_batch_terminal? && @tool_invocations.all?(&:execution_completed?)
|
|
345
433
|
end
|
|
346
434
|
|
|
347
435
|
private
|
|
348
436
|
|
|
437
|
+
def handoff_matches_for(calls)
|
|
438
|
+
bindings = Array(@config[:phronomy_handoff_bindings])
|
|
439
|
+
return [] if bindings.empty?
|
|
440
|
+
|
|
441
|
+
by_name = bindings.to_h { |binding| [binding.tool_name.to_s, binding] }
|
|
442
|
+
calls.filter_map do |tool_call|
|
|
443
|
+
binding = by_name[tool_call.name.to_s]
|
|
444
|
+
binding && [tool_call, binding]
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def build_handoff_request(tool_call, binding, llm_call_id:)
|
|
449
|
+
handoff = binding.handoff
|
|
450
|
+
unless handoff.source_agent.equal?(@agent)
|
|
451
|
+
raise Phronomy::HandoffError,
|
|
452
|
+
"Handoff capability is not bound to the active Source Agent"
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
args = tool_call.respond_to?(:arguments) ? tool_call.arguments : {}
|
|
456
|
+
args = (args || {}).to_h.transform_keys(&:to_sym)
|
|
457
|
+
selection = handoff.policy.selectable_categories.each_with_object({}) do |category, result|
|
|
458
|
+
key = :"include_#{category}"
|
|
459
|
+
result[category] = args[key] if args.key?(key)
|
|
460
|
+
end
|
|
461
|
+
Phronomy::Agent::HandoffRequest.new(
|
|
462
|
+
handoff: handoff,
|
|
463
|
+
responsibility: args.fetch(:responsibility),
|
|
464
|
+
selection_intent: selection,
|
|
465
|
+
llm_call_id: llm_call_id,
|
|
466
|
+
tool_call_id: tool_call.respond_to?(:id) ? tool_call.id : nil
|
|
467
|
+
)
|
|
468
|
+
end
|
|
469
|
+
|
|
349
470
|
def apply_llm_event(event)
|
|
350
471
|
result = event.payload
|
|
351
472
|
unless result.is_a?(LLMOperationResult)
|
|
352
|
-
raise Phronomy::Error,
|
|
353
|
-
|
|
473
|
+
raise Phronomy::Error, "Expected LLMOperationResult, got #{result.class}"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
unless current_llm_result_authoritative?(result.llm_call_id)
|
|
477
|
+
warn_stale_llm_result(result.llm_call_id)
|
|
478
|
+
return :consume
|
|
354
479
|
end
|
|
355
480
|
|
|
481
|
+
call = @current_llm_call
|
|
482
|
+
@llm_results << {
|
|
483
|
+
llm_call_id: call.fetch(:llm_call_id),
|
|
484
|
+
response: canonical_response_for_llm_result(result),
|
|
485
|
+
error: result.error,
|
|
486
|
+
streaming: result.streaming,
|
|
487
|
+
manifest_ref: call.fetch(:manifest_ref),
|
|
488
|
+
started_at: call.fetch(:started_at)
|
|
489
|
+
}.freeze
|
|
490
|
+
@current_llm_call = nil
|
|
491
|
+
|
|
356
492
|
if event.type == :llm_failed
|
|
357
|
-
@
|
|
358
|
-
|
|
359
|
-
Phronomy::Error.new("LLM operation failed without an error")
|
|
360
|
-
return
|
|
493
|
+
@error = result.error || Phronomy::Error.new("LLM operation failed without an error")
|
|
494
|
+
return true
|
|
361
495
|
end
|
|
362
496
|
|
|
363
497
|
if result.error
|
|
364
498
|
if result.error.is_a?(ToolCallIntercepted)
|
|
365
499
|
accept_tool_calls!(
|
|
366
500
|
result.error.tool_calls,
|
|
367
|
-
llm_call_id: result.
|
|
501
|
+
llm_call_id: result.llm_call_id
|
|
368
502
|
)
|
|
369
503
|
else
|
|
370
|
-
@current_llm_call_id = nil
|
|
371
504
|
@error = result.error
|
|
372
505
|
end
|
|
373
506
|
else
|
|
374
507
|
apply_llm_response!(result.response)
|
|
375
508
|
end
|
|
509
|
+
true
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def current_llm_result_authoritative?(llm_call_id)
|
|
513
|
+
current = current_llm_call_id
|
|
514
|
+
!current.nil? && current == llm_call_id&.to_s
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def warn_stale_llm_result(llm_call_id)
|
|
518
|
+
Phronomy.configuration.logger&.warn(
|
|
519
|
+
"[Phronomy] Dropped stale Provider result: " \
|
|
520
|
+
"execution_id=#{@execution_id} expected_llm_call_id=#{current_llm_call_id.inspect} " \
|
|
521
|
+
"actual_llm_call_id=#{llm_call_id.inspect}"
|
|
522
|
+
)
|
|
523
|
+
rescue
|
|
524
|
+
nil
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def canonical_response_for_llm_result(result)
|
|
528
|
+
if result.error.is_a?(ToolCallIntercepted)
|
|
529
|
+
result.error.assistant_outcome || ProviderCallOutcome.capture(result.response)
|
|
530
|
+
else
|
|
531
|
+
ProviderCallOutcome.capture(result.response)
|
|
532
|
+
end
|
|
376
533
|
end
|
|
377
534
|
|
|
535
|
+
# Canonical runtime recording is independent of Application callback health.
|
|
536
|
+
# Once an event is observed it is appended even after a listener has failed.
|
|
378
537
|
def deliver_event(event)
|
|
379
|
-
|
|
538
|
+
# Canonical runtime facts must not share mutable payload containers with
|
|
539
|
+
# Application callbacks. Record an EventLoop-owned snapshot first; the
|
|
540
|
+
# listener receives the notification event separately.
|
|
541
|
+
@runtime_events << StreamEvent.new(
|
|
542
|
+
type: event.type,
|
|
543
|
+
payload: Immutable.copy(event.payload)
|
|
544
|
+
)
|
|
545
|
+
listener = @callback_failure ? nil : @event_listener
|
|
546
|
+
return unless listener
|
|
547
|
+
|
|
548
|
+
listener.call(event)
|
|
549
|
+
rescue => callback_error
|
|
550
|
+
failure = ApplicationCallbackFailure.new(
|
|
551
|
+
event_type: event.type,
|
|
552
|
+
error: callback_error
|
|
553
|
+
)
|
|
554
|
+
@callback_failure ||= failure
|
|
555
|
+
@event_listener = nil
|
|
556
|
+
notify_callback_failure(failure)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def notify_callback_failure(failure)
|
|
560
|
+
if @event_sink
|
|
561
|
+
accepted = @event_sink.post(:application_callback_failed, {failure: failure})
|
|
562
|
+
unless accepted
|
|
563
|
+
Phronomy.configuration.logger&.warn(
|
|
564
|
+
"[Phronomy] Callback failure recorded but could not notify " \
|
|
565
|
+
"FSMSession #{@event_sink.fsm_session_id}: execution_id=#{@execution_id}"
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
Phronomy.configuration.logger&.warn(
|
|
570
|
+
"[Phronomy] Application event listener failed: " \
|
|
571
|
+
"#{failure.error.class}: #{failure.error.message}"
|
|
572
|
+
)
|
|
573
|
+
rescue
|
|
574
|
+
nil
|
|
380
575
|
end
|
|
381
576
|
end
|
|
382
577
|
end
|