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
|
@@ -5,8 +5,48 @@ require "securerandom"
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module Agent
|
|
7
7
|
class ToolInvocation
|
|
8
|
-
AuthorizationOutcome =
|
|
9
|
-
|
|
8
|
+
AuthorizationOutcome = Data.define(
|
|
9
|
+
:tool_invocation_id, :decision, :facts, :reason, :error, :cancelled
|
|
10
|
+
) do
|
|
11
|
+
def initialize(
|
|
12
|
+
tool_invocation_id: nil, decision: nil, facts: nil, reason: nil,
|
|
13
|
+
error: nil, cancelled: false
|
|
14
|
+
)
|
|
15
|
+
super(
|
|
16
|
+
tool_invocation_id: tool_invocation_id&.to_s&.freeze,
|
|
17
|
+
decision: decision,
|
|
18
|
+
facts: facts,
|
|
19
|
+
reason: reason,
|
|
20
|
+
error: error,
|
|
21
|
+
cancelled: !!cancelled
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ExecutionOutcome = Data.define(:tool_invocation_id, :result, :error, :cancelled) do
|
|
27
|
+
def initialize(tool_invocation_id: nil, result: nil, error: nil, cancelled: false)
|
|
28
|
+
super(
|
|
29
|
+
tool_invocation_id: tool_invocation_id&.to_s&.freeze,
|
|
30
|
+
result: result,
|
|
31
|
+
error: error,
|
|
32
|
+
cancelled: !!cancelled
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Operation input captured on EventLoop. Framework-owned container/String
|
|
38
|
+
# value fields are immutable snapshots; callables are explicitly classified
|
|
39
|
+
# Application-owned behavior handles. No Phronomy-managed live domain object
|
|
40
|
+
# crosses this worker boundary as command/request data.
|
|
41
|
+
AuthorizationCommand = Data.define(
|
|
42
|
+
:agent_id, :agent_definition_id, :agent_definition_version,
|
|
43
|
+
:execution_id, :tool_name, :tool_schema,
|
|
44
|
+
:tool_invocation_id, :tool_call_id, :arguments,
|
|
45
|
+
:approval_policy, :approval_facts_callable, :approval_requirement,
|
|
46
|
+
:approval_context, :origin, :metadata
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
private_constant :AuthorizationCommand
|
|
10
50
|
|
|
11
51
|
PREFLIGHT_SETTLED_STATES = %i[
|
|
12
52
|
authorized awaiting_approval rejected failed cancelled completed
|
|
@@ -14,7 +54,7 @@ module Phronomy
|
|
|
14
54
|
TERMINAL_STATES = %i[completed rejected failed cancelled].freeze
|
|
15
55
|
|
|
16
56
|
attr_reader :id,
|
|
17
|
-
:
|
|
57
|
+
:execution_id,
|
|
18
58
|
:agent,
|
|
19
59
|
:tool,
|
|
20
60
|
:tool_name,
|
|
@@ -27,7 +67,6 @@ module Phronomy
|
|
|
27
67
|
:result,
|
|
28
68
|
:error,
|
|
29
69
|
:status,
|
|
30
|
-
:session_id,
|
|
31
70
|
:phase,
|
|
32
71
|
:config,
|
|
33
72
|
:approval_policy,
|
|
@@ -35,18 +74,25 @@ module Phronomy
|
|
|
35
74
|
:origin,
|
|
36
75
|
:metadata
|
|
37
76
|
|
|
38
|
-
def self.missing(
|
|
77
|
+
def self.missing(
|
|
78
|
+
execution_id:,
|
|
79
|
+
agent:,
|
|
80
|
+
tool_call:,
|
|
81
|
+
config: {},
|
|
82
|
+
id: SecureRandom.uuid
|
|
83
|
+
)
|
|
39
84
|
new(
|
|
40
|
-
|
|
85
|
+
execution_id: execution_id,
|
|
41
86
|
agent: agent,
|
|
42
87
|
tool: nil,
|
|
43
88
|
tool_call: tool_call,
|
|
44
|
-
config: config
|
|
89
|
+
config: config,
|
|
90
|
+
id: id
|
|
45
91
|
).tap { |invocation| invocation.send(:complete_missing_tool!) }
|
|
46
92
|
end
|
|
47
93
|
|
|
48
94
|
def initialize(
|
|
49
|
-
|
|
95
|
+
execution_id:,
|
|
50
96
|
agent:,
|
|
51
97
|
tool:,
|
|
52
98
|
tool_call:,
|
|
@@ -55,15 +101,19 @@ module Phronomy
|
|
|
55
101
|
approval_context: {},
|
|
56
102
|
id: SecureRandom.uuid
|
|
57
103
|
)
|
|
104
|
+
if execution_id.nil? || execution_id.to_s.empty?
|
|
105
|
+
raise ArgumentError, "ToolInvocation requires execution_id"
|
|
106
|
+
end
|
|
107
|
+
|
|
58
108
|
@id = id.to_s
|
|
59
|
-
@
|
|
109
|
+
@execution_id = execution_id.to_s.freeze
|
|
60
110
|
@agent = agent
|
|
61
111
|
@tool = tool
|
|
62
112
|
@tool_name = tool_call.name.to_s
|
|
63
113
|
@tool_call_id = tool_call.respond_to?(:id) ? tool_call.id : nil
|
|
64
114
|
raw_arguments = tool_call.respond_to?(:arguments) ? (tool_call.arguments || {}) : {}
|
|
65
115
|
@raw_arguments = immutable_copy(raw_arguments)
|
|
66
|
-
@config = config
|
|
116
|
+
@config = config.dup.freeze
|
|
67
117
|
@approval_policy = approval_policy
|
|
68
118
|
@approval_context = immutable_copy(approval_context || {})
|
|
69
119
|
@origin = tool&.respond_to?(:tool_origin) ? tool.tool_origin.to_sym : :local
|
|
@@ -78,12 +128,10 @@ module Phronomy
|
|
|
78
128
|
@error = nil
|
|
79
129
|
@approval_consumed = false
|
|
80
130
|
@status = :created
|
|
81
|
-
@session_id = nil
|
|
82
131
|
@phase = nil
|
|
83
132
|
end
|
|
84
133
|
|
|
85
|
-
def set_graph_metadata(
|
|
86
|
-
@session_id = thread_id if thread_id
|
|
134
|
+
def set_graph_metadata(phase: nil)
|
|
87
135
|
@phase = phase
|
|
88
136
|
end
|
|
89
137
|
|
|
@@ -91,17 +139,24 @@ module Phronomy
|
|
|
91
139
|
case event.type
|
|
92
140
|
when :authorization_completed
|
|
93
141
|
outcome = event.payload
|
|
94
|
-
|
|
142
|
+
if outcome.is_a?(Exception)
|
|
143
|
+
outcome = AuthorizationOutcome.new(tool_invocation_id: @id, error: outcome)
|
|
144
|
+
end
|
|
145
|
+
return :consume unless authoritative_tool_outcome?(outcome)
|
|
146
|
+
|
|
95
147
|
apply_authorization_outcome(outcome)
|
|
96
148
|
true
|
|
97
149
|
when :execution_completed
|
|
98
150
|
outcome = event.payload
|
|
99
151
|
if outcome.is_a?(Exception)
|
|
100
152
|
outcome = ExecutionOutcome.new(
|
|
153
|
+
tool_invocation_id: @id,
|
|
101
154
|
error: outcome,
|
|
102
155
|
cancelled: outcome.is_a?(Phronomy::CancellationError)
|
|
103
156
|
)
|
|
104
157
|
end
|
|
158
|
+
return :consume unless authoritative_tool_outcome?(outcome)
|
|
159
|
+
|
|
105
160
|
apply_execution_outcome(outcome)
|
|
106
161
|
true
|
|
107
162
|
else
|
|
@@ -140,11 +195,12 @@ module Phronomy
|
|
|
140
195
|
self
|
|
141
196
|
end
|
|
142
197
|
|
|
143
|
-
# Starts authorization and reports exactly one AuthorizationOutcome through
|
|
144
|
-
# the callback. No Task is created.
|
|
145
198
|
def start_authorization(runtime: Phronomy::Runtime.instance, &callback)
|
|
146
199
|
raise ArgumentError, "start_authorization requires a callback" unless callback
|
|
147
200
|
|
|
201
|
+
command = authorization_command
|
|
202
|
+
evaluator = self.class
|
|
203
|
+
tool_invocation_id = @id.to_s.freeze
|
|
148
204
|
pool = runtime.pool(
|
|
149
205
|
:authorization,
|
|
150
206
|
size: Phronomy.configuration.authorization_pool_size,
|
|
@@ -158,56 +214,79 @@ module Phronomy
|
|
|
158
214
|
timeout: timeout,
|
|
159
215
|
cancellation_token: @config[:cancellation_token],
|
|
160
216
|
on_full: :raise
|
|
161
|
-
) {
|
|
217
|
+
) { evaluator.send(:evaluate_authorization_command, command) }
|
|
218
|
+
runtime.event_loop.supervise_agent_operation(@execution_id, operation)
|
|
162
219
|
operation.on_complete do |outcome, error|
|
|
163
|
-
callback.call(
|
|
220
|
+
callback.call(
|
|
221
|
+
error ? evaluator.send(:authorization_failure_result, tool_invocation_id, error) : outcome
|
|
222
|
+
)
|
|
164
223
|
end
|
|
165
224
|
self
|
|
166
225
|
rescue => error
|
|
167
|
-
callback.call(
|
|
226
|
+
callback.call(
|
|
227
|
+
self.class.send(:authorization_failure_result, @id.to_s, error)
|
|
228
|
+
)
|
|
168
229
|
self
|
|
169
230
|
end
|
|
170
231
|
|
|
171
|
-
# Starts Tool execution and reports completion through the callback.
|
|
172
|
-
#
|
|
173
|
-
# Both core execution paths use Tool#call_async:
|
|
174
|
-
#
|
|
175
|
-
# - :cooperative returns a Task without consuming an OffloadPool worker.
|
|
176
|
-
# Ordinary cooperative Tools settle that Task inline; Agent-backed Tools
|
|
177
|
-
# may start child EventLoop/FSM work and settle later.
|
|
178
|
-
# - :offloaded returns an OffloadPool PendingOperation for synchronous work
|
|
179
|
-
# that must not occupy the EventLoop.
|
|
180
|
-
#
|
|
181
|
-
# In either case ToolInvocation remains in :running and resumes only from
|
|
182
|
-
# the explicit :execution_completed FSM event posted by the session builder.
|
|
183
232
|
def start_execution(runtime: Phronomy::Runtime.instance, &callback)
|
|
184
233
|
raise ArgumentError, "start_execution requires a callback" unless callback
|
|
185
234
|
unless dispatchable?
|
|
186
|
-
callback.call(ExecutionOutcome.new(
|
|
187
|
-
|
|
188
|
-
|
|
235
|
+
callback.call(ExecutionOutcome.new(
|
|
236
|
+
tool_invocation_id: @id,
|
|
237
|
+
error: Phronomy::ToolError.new(
|
|
238
|
+
"ToolInvocation #{@id} is not authorized for dispatch"
|
|
239
|
+
)
|
|
240
|
+
))
|
|
189
241
|
return self
|
|
190
242
|
end
|
|
191
243
|
|
|
244
|
+
trace_handle = nil
|
|
192
245
|
case @tool.class.execution_mode
|
|
193
246
|
when :cooperative, :offloaded
|
|
247
|
+
trace_handle = Phronomy::Tracing::Automatic.start(
|
|
248
|
+
"tool.execute",
|
|
249
|
+
input: @arguments || @raw_arguments,
|
|
250
|
+
agent_id: @agent.agent_id,
|
|
251
|
+
execution_id: @execution_id,
|
|
252
|
+
tool_invocation_id: @id,
|
|
253
|
+
tool_call_id: @tool_call_id,
|
|
254
|
+
tool_name: @tool_name,
|
|
255
|
+
**@agent.send(:_build_caller_meta, @config)
|
|
256
|
+
)
|
|
194
257
|
operation = start_async_tool_operation(runtime)
|
|
195
258
|
unless operation.respond_to?(:on_complete)
|
|
196
259
|
raise Phronomy::ToolError,
|
|
197
260
|
"Tool #{@tool.class.name}#call_async must return a completion handle"
|
|
198
261
|
end
|
|
262
|
+
runtime.event_loop.supervise_agent_operation(@execution_id, operation)
|
|
199
263
|
|
|
264
|
+
evaluator = self.class
|
|
265
|
+
tool_invocation_id = @id.to_s.freeze
|
|
200
266
|
operation.on_complete do |result, error|
|
|
201
|
-
|
|
267
|
+
Phronomy::Tracing::Automatic.finish(
|
|
268
|
+
trace_handle,
|
|
269
|
+
output: result,
|
|
270
|
+
error: error
|
|
271
|
+
)
|
|
272
|
+
callback.call(
|
|
273
|
+
evaluator.send(:build_execution_outcome, tool_invocation_id, result, error)
|
|
274
|
+
)
|
|
202
275
|
end
|
|
203
276
|
else
|
|
204
|
-
callback.call(ExecutionOutcome.new(
|
|
205
|
-
|
|
206
|
-
|
|
277
|
+
callback.call(ExecutionOutcome.new(
|
|
278
|
+
tool_invocation_id: @id,
|
|
279
|
+
error: Phronomy::ConfigurationError.new(
|
|
280
|
+
"unknown Tool execution_mode: #{@tool.class.execution_mode.inspect}"
|
|
281
|
+
)
|
|
282
|
+
))
|
|
207
283
|
end
|
|
208
284
|
self
|
|
209
285
|
rescue => error
|
|
210
|
-
|
|
286
|
+
Phronomy::Tracing::Automatic.finish(trace_handle, error: error)
|
|
287
|
+
callback.call(
|
|
288
|
+
self.class.send(:build_execution_outcome, @id.to_s, nil, error)
|
|
289
|
+
)
|
|
211
290
|
self
|
|
212
291
|
end
|
|
213
292
|
|
|
@@ -266,55 +345,59 @@ module Phronomy
|
|
|
266
345
|
|
|
267
346
|
private
|
|
268
347
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
348
|
+
def authorization_command
|
|
349
|
+
definition = @agent.class.agent_definition
|
|
350
|
+
|
|
351
|
+
AuthorizationCommand.new(
|
|
352
|
+
agent_id: @agent.agent_id.to_s.freeze,
|
|
353
|
+
agent_definition_id: definition.fetch(:id).to_s.freeze,
|
|
354
|
+
agent_definition_version: Integer(definition.fetch(:version)),
|
|
355
|
+
execution_id: @execution_id,
|
|
356
|
+
tool_name: @tool_name.to_s.freeze,
|
|
357
|
+
tool_schema: self.class.send(:immutable_command_copy, tool_schema),
|
|
358
|
+
tool_invocation_id: @id.to_s.freeze,
|
|
359
|
+
tool_call_id: @tool_call_id&.to_s&.freeze,
|
|
360
|
+
arguments: self.class.send(
|
|
361
|
+
:immutable_command_copy,
|
|
362
|
+
@arguments || {}
|
|
363
|
+
),
|
|
364
|
+
approval_policy: self.class.send(
|
|
365
|
+
:safe_behavior_handle, @approval_policy, "approval_policy"
|
|
366
|
+
),
|
|
367
|
+
approval_facts_callable: self.class.send(
|
|
368
|
+
:safe_behavior_handle, authorization_facts_callable, "approval_facts"
|
|
369
|
+
),
|
|
370
|
+
approval_requirement: self.class.send(
|
|
371
|
+
:safe_behavior_handle, authorization_requirement, "requires_approval"
|
|
372
|
+
),
|
|
373
|
+
approval_context: self.class.send(
|
|
374
|
+
:immutable_command_copy,
|
|
375
|
+
@approval_context
|
|
376
|
+
),
|
|
377
|
+
origin: @origin,
|
|
378
|
+
metadata: self.class.send(:immutable_command_copy, @metadata)
|
|
379
|
+
)
|
|
293
380
|
end
|
|
294
381
|
|
|
295
|
-
def
|
|
296
|
-
@tool
|
|
297
|
-
|
|
382
|
+
def authorization_facts_callable
|
|
383
|
+
return unless @tool&.class&.respond_to?(:approval_facts)
|
|
384
|
+
|
|
385
|
+
@tool.class.approval_facts
|
|
298
386
|
end
|
|
299
387
|
|
|
300
|
-
def
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
cancelled: error.is_a?(Phronomy::CancellationError)
|
|
305
|
-
)
|
|
306
|
-
else
|
|
307
|
-
ExecutionOutcome.new(result: result)
|
|
308
|
-
end
|
|
388
|
+
def authorization_requirement
|
|
389
|
+
return false unless @tool&.respond_to?(:requires_approval)
|
|
390
|
+
|
|
391
|
+
@tool.requires_approval
|
|
309
392
|
end
|
|
310
393
|
|
|
311
|
-
def
|
|
312
|
-
request =
|
|
313
|
-
facts =
|
|
394
|
+
def self.evaluate_authorization_command(command)
|
|
395
|
+
request = build_authorization_request(command, facts: {}, default_decision: nil)
|
|
396
|
+
facts = evaluate_authorization_facts(command)
|
|
314
397
|
request = request.with(facts: facts)
|
|
315
|
-
default_decision =
|
|
398
|
+
default_decision = evaluate_default_authorization_decision(command, request)
|
|
316
399
|
request = request.with(default_decision: default_decision)
|
|
317
|
-
decision =
|
|
400
|
+
decision = command.approval_policy ? command.approval_policy.call(request) : default_decision
|
|
318
401
|
decision = decision.to_sym if decision.respond_to?(:to_sym)
|
|
319
402
|
|
|
320
403
|
unless ApprovalEvaluationRequest::VALID_DECISIONS.include?(decision)
|
|
@@ -324,25 +407,33 @@ module Phronomy
|
|
|
324
407
|
end
|
|
325
408
|
|
|
326
409
|
reason = if decision == :require_approval
|
|
327
|
-
(
|
|
410
|
+
(command.origin == :mcp) ?
|
|
411
|
+
"MCP Tool execution requires approval" : "Tool execution requires approval"
|
|
328
412
|
end
|
|
329
|
-
AuthorizationOutcome.new(
|
|
413
|
+
AuthorizationOutcome.new(
|
|
414
|
+
tool_invocation_id: command.tool_invocation_id,
|
|
415
|
+
decision: decision,
|
|
416
|
+
facts: facts,
|
|
417
|
+
reason: reason
|
|
418
|
+
)
|
|
330
419
|
end
|
|
420
|
+
private_class_method :evaluate_authorization_command
|
|
331
421
|
|
|
332
|
-
def
|
|
333
|
-
callable =
|
|
422
|
+
def self.evaluate_authorization_facts(command)
|
|
423
|
+
callable = command.approval_facts_callable
|
|
334
424
|
return {} unless callable
|
|
335
425
|
|
|
336
|
-
value = callable.call(
|
|
426
|
+
value = callable.call(command.arguments, command.approval_context)
|
|
337
427
|
unless value.nil? || value.is_a?(Hash)
|
|
338
428
|
raise Phronomy::ConfigurationError,
|
|
339
429
|
"approval_facts must return a Hash or nil (got #{value.class})"
|
|
340
430
|
end
|
|
341
|
-
|
|
431
|
+
immutable_command_copy(value || {})
|
|
342
432
|
end
|
|
433
|
+
private_class_method :evaluate_authorization_facts
|
|
343
434
|
|
|
344
|
-
def
|
|
345
|
-
requirement =
|
|
435
|
+
def self.evaluate_default_authorization_decision(command, request)
|
|
436
|
+
requirement = command.approval_requirement
|
|
346
437
|
requirement = requirement.call(request) if requirement.respond_to?(:call)
|
|
347
438
|
case requirement
|
|
348
439
|
when true then :require_approval
|
|
@@ -352,40 +443,185 @@ module Phronomy
|
|
|
352
443
|
"requires_approval callable must return true or false (got #{requirement.inspect})"
|
|
353
444
|
end
|
|
354
445
|
end
|
|
446
|
+
private_class_method :evaluate_default_authorization_decision
|
|
355
447
|
|
|
356
|
-
def
|
|
448
|
+
def self.build_authorization_request(command, facts:, default_decision:)
|
|
357
449
|
ApprovalEvaluationRequest.new(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
450
|
+
agent_id: command.agent_id,
|
|
451
|
+
agent_definition_id: command.agent_definition_id,
|
|
452
|
+
agent_definition_version: command.agent_definition_version,
|
|
453
|
+
execution_id: command.execution_id,
|
|
454
|
+
tool_name: command.tool_name,
|
|
455
|
+
tool_schema: command.tool_schema,
|
|
456
|
+
tool_invocation_id: command.tool_invocation_id,
|
|
457
|
+
tool_call_id: command.tool_call_id,
|
|
458
|
+
arguments: command.arguments,
|
|
366
459
|
facts: facts,
|
|
367
|
-
invocation_context:
|
|
368
|
-
origin:
|
|
369
|
-
metadata:
|
|
460
|
+
invocation_context: command.approval_context,
|
|
461
|
+
origin: command.origin,
|
|
462
|
+
metadata: command.metadata,
|
|
370
463
|
default_decision: default_decision
|
|
371
464
|
)
|
|
372
465
|
end
|
|
466
|
+
private_class_method :build_authorization_request
|
|
373
467
|
|
|
374
|
-
def
|
|
468
|
+
def self.immutable_command_copy(value)
|
|
469
|
+
if phronomy_managed_live_domain_object?(value)
|
|
470
|
+
raise Phronomy::ConfigurationError,
|
|
471
|
+
"authorization worker snapshot cannot contain Phronomy-managed live " \
|
|
472
|
+
"domain object #{value.class}"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
case value
|
|
476
|
+
when Hash
|
|
477
|
+
value.each_with_object({}) do |(key, item), result|
|
|
478
|
+
result[immutable_command_copy(key)] = immutable_command_copy(item)
|
|
479
|
+
end.freeze
|
|
480
|
+
when Array
|
|
481
|
+
value.map { |item| immutable_command_copy(item) }.freeze
|
|
482
|
+
when String
|
|
483
|
+
value.dup.freeze
|
|
484
|
+
else
|
|
485
|
+
# Application-defined opaque objects are permitted by ACS-11 and remain
|
|
486
|
+
# Application-owned. A stricter general value-type protocol is deferred.
|
|
487
|
+
value
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
private_class_method :immutable_command_copy
|
|
491
|
+
|
|
492
|
+
def self.phronomy_managed_live_domain_object?(value)
|
|
493
|
+
value.is_a?(Phronomy::Agent::Base) ||
|
|
494
|
+
value.is_a?(Phronomy::Agent::AgentRoot) ||
|
|
495
|
+
value.is_a?(Phronomy::Agent::AgentExecution) ||
|
|
496
|
+
value.is_a?(Phronomy::Agent::AgentInvocation) ||
|
|
497
|
+
value.is_a?(Phronomy::Agent::ToolInvocation) ||
|
|
498
|
+
value.is_a?(Phronomy::Agent::JournalProjection) ||
|
|
499
|
+
value.is_a?(Phronomy::Agent::ExecutionCoordinator) ||
|
|
500
|
+
value.is_a?(Phronomy::Agent::Context::Capability::Base) ||
|
|
501
|
+
value.is_a?(Phronomy::Workflow) ||
|
|
502
|
+
value.is_a?(Phronomy::WorkflowRunner) ||
|
|
503
|
+
value.is_a?(Phronomy::WorkflowContext) ||
|
|
504
|
+
value.is_a?(Phronomy::Runtime) ||
|
|
505
|
+
value.is_a?(Phronomy::Task) ||
|
|
506
|
+
value.is_a?(Phronomy::EventLoop) ||
|
|
507
|
+
value.is_a?(Phronomy::FSMSession) ||
|
|
508
|
+
value.is_a?(Phronomy::FSMSession::EventSink) ||
|
|
509
|
+
value.is_a?(Phronomy::Concurrency::CancellationToken) ||
|
|
510
|
+
value.is_a?(Phronomy::Concurrency::OffloadPool)
|
|
511
|
+
end
|
|
512
|
+
private_class_method :phronomy_managed_live_domain_object?
|
|
513
|
+
|
|
514
|
+
def self.safe_behavior_handle(value, name)
|
|
515
|
+
return value if value.nil? || value == true || value == false
|
|
516
|
+
if phronomy_managed_live_domain_object?(value)
|
|
517
|
+
raise Phronomy::ConfigurationError,
|
|
518
|
+
"#{name} must not be a Phronomy-managed live domain object (got #{value.class})"
|
|
519
|
+
end
|
|
520
|
+
value
|
|
521
|
+
end
|
|
522
|
+
private_class_method :safe_behavior_handle
|
|
523
|
+
|
|
524
|
+
# Compatibility helpers for the existing private behavioral specs. These
|
|
525
|
+
# evaluate a frozen operation command just like the worker path; they do not
|
|
526
|
+
# reintroduce worker-side access to live mutable ToolInvocation state.
|
|
527
|
+
def evaluate_authorization
|
|
528
|
+
self.class.send(:evaluate_authorization_command, authorization_command)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def evaluate_facts
|
|
532
|
+
self.class.send(:evaluate_authorization_facts, authorization_command)
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def evaluate_default_decision(request)
|
|
536
|
+
self.class.send(
|
|
537
|
+
:evaluate_default_authorization_decision,
|
|
538
|
+
authorization_command,
|
|
539
|
+
request
|
|
540
|
+
)
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def build_request(facts:, default_decision:)
|
|
544
|
+
self.class.send(
|
|
545
|
+
:build_authorization_request,
|
|
546
|
+
authorization_command,
|
|
547
|
+
facts: facts,
|
|
548
|
+
default_decision: default_decision
|
|
549
|
+
)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def start_async_tool_operation(runtime)
|
|
553
|
+
if uses_default_call_async?
|
|
554
|
+
Phronomy::Agent::ToolExecutor.call_async(
|
|
555
|
+
tool: @tool,
|
|
556
|
+
args: @arguments,
|
|
557
|
+
cancellation_token: @config[:cancellation_token],
|
|
558
|
+
config: @config,
|
|
559
|
+
runtime: runtime,
|
|
560
|
+
on_full: :raise
|
|
561
|
+
)
|
|
562
|
+
else
|
|
563
|
+
tool_config = @tool.class.respond_to?(:__framework_owned_operation?) ?
|
|
564
|
+
@config.merge(phronomy_tool_invocation_id: @id, execution_id: @execution_id).freeze : @config
|
|
565
|
+
@tool.call_async(
|
|
566
|
+
@arguments,
|
|
567
|
+
cancellation_token: @config[:cancellation_token],
|
|
568
|
+
config: tool_config
|
|
569
|
+
)
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def uses_default_call_async?
|
|
574
|
+
@tool.method(:call_async).owner ==
|
|
575
|
+
Phronomy::Agent::Context::Capability::Base
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def self.build_execution_outcome(tool_invocation_id, result, error)
|
|
579
|
+
if error
|
|
580
|
+
ExecutionOutcome.new(
|
|
581
|
+
tool_invocation_id: tool_invocation_id,
|
|
582
|
+
error: error,
|
|
583
|
+
cancelled: error.is_a?(Phronomy::CancellationError)
|
|
584
|
+
)
|
|
585
|
+
else
|
|
586
|
+
ExecutionOutcome.new(tool_invocation_id: tool_invocation_id, result: result)
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
private_class_method :build_execution_outcome
|
|
590
|
+
|
|
591
|
+
def self.authorization_failure_result(tool_invocation_id, error)
|
|
375
592
|
if error.is_a?(Phronomy::TimeoutError) ||
|
|
376
593
|
error.is_a?(Phronomy::TransportError) ||
|
|
377
594
|
error.is_a?(Phronomy::BackpressureError)
|
|
378
595
|
AuthorizationOutcome.new(
|
|
596
|
+
tool_invocation_id: tool_invocation_id,
|
|
379
597
|
decision: :require_approval,
|
|
380
598
|
facts: {},
|
|
381
599
|
reason: "Authorization could not be completed safely: #{error.message}"
|
|
382
600
|
)
|
|
383
601
|
elsif error.is_a?(Phronomy::CancellationError)
|
|
384
|
-
AuthorizationOutcome.new(
|
|
602
|
+
AuthorizationOutcome.new(
|
|
603
|
+
tool_invocation_id: tool_invocation_id, error: error, cancelled: true
|
|
604
|
+
)
|
|
385
605
|
else
|
|
386
|
-
AuthorizationOutcome.new(error: error)
|
|
606
|
+
AuthorizationOutcome.new(tool_invocation_id: tool_invocation_id, error: error)
|
|
387
607
|
end
|
|
388
608
|
end
|
|
609
|
+
private_class_method :authorization_failure_result
|
|
610
|
+
|
|
611
|
+
# Private compatibility helpers used by existing behavioral specs. Runtime
|
|
612
|
+
# callbacks use only the pure class helpers above and captured semantic IDs.
|
|
613
|
+
def execution_outcome(result, error)
|
|
614
|
+
self.class.send(:build_execution_outcome, @id, result, error)
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def authorization_failure_outcome(error)
|
|
618
|
+
self.class.send(:authorization_failure_result, @id, error)
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def authoritative_tool_outcome?(outcome)
|
|
622
|
+
outcome.respond_to?(:tool_invocation_id) &&
|
|
623
|
+
outcome.tool_invocation_id.to_s == @id
|
|
624
|
+
end
|
|
389
625
|
|
|
390
626
|
def apply_authorization_outcome(outcome)
|
|
391
627
|
unless outcome.is_a?(AuthorizationOutcome)
|