phronomy 0.13.0 → 0.15.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 +155 -0
- data/README.md +266 -38
- data/benchmark/bench_agent_invoke.rb +2 -3
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
- data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
- data/docs/mcp-client.md +75 -0
- data/examples/workflows/agent_event_mapping.rb +104 -0
- data/examples/workflows/generic_task_event_mapping.rb +58 -0
- data/gemfiles/mcp_1_0.gemfile +9 -0
- data/lib/phronomy/agent/agent_invocation.rb +385 -0
- data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
- data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
- data/lib/phronomy/agent/async_event_api.rb +471 -0
- data/lib/phronomy/agent/base.rb +509 -420
- data/lib/phronomy/agent/context/capability/base.rb +57 -119
- data/lib/phronomy/agent/llm_operation_result.rb +23 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
- data/lib/phronomy/agent/tool_approval_request.rb +121 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
- data/lib/phronomy/agent/tool_executor.rb +47 -69
- data/lib/phronomy/agent/tool_invocation.rb +634 -0
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
- data/lib/phronomy/agent.rb +21 -9
- data/lib/phronomy/configuration.rb +58 -53
- data/lib/phronomy/diagnostics.rb +1 -1
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
- data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
- data/lib/phronomy/engine/event_loop.rb +507 -303
- data/lib/phronomy/engine/fsm_session.rb +181 -140
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
- data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
- data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
- data/lib/phronomy/engine/runtime.rb +247 -57
- data/lib/phronomy/engine/task.rb +5 -10
- data/lib/phronomy/event.rb +8 -8
- data/lib/phronomy/generator_verifier.rb +253 -142
- data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
- data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
- data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
- data/lib/phronomy/invocation_context.rb +5 -19
- data/lib/phronomy/llm_adapter/base.rb +25 -34
- data/lib/phronomy/metrics.rb +6 -3
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
- data/lib/phronomy/stream_callback_error.rb +35 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
- data/lib/phronomy/tools/mcp.rb +410 -81
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
- data/lib/phronomy/workflow.rb +122 -261
- data/lib/phronomy/workflow_context.rb +55 -104
- data/lib/phronomy/workflow_runner.rb +239 -291
- data/lib/phronomy.rb +30 -23
- data/scripts/check_readme_runnable.rb +4 -1
- metadata +63 -11
- data/lib/phronomy/agent/concerns/retryable.rb +0 -103
- data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
- data/lib/phronomy/agent/invocation_context.rb +0 -171
- data/lib/phronomy/agent/invocation_session.rb +0 -346
- data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
- data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
- data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Phronomy
|
|
6
|
+
module Agent
|
|
7
|
+
# Mutable state for one concrete ToolCall.
|
|
8
|
+
#
|
|
9
|
+
# The object owns Tool-domain state. FSMSession owns transitions, while
|
|
10
|
+
# ToolInvocationSessionBuilder converts Task completion into explicit
|
|
11
|
+
# Tool-internal events.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class ToolInvocation
|
|
15
|
+
AuthorizationOutcome = Struct.new(
|
|
16
|
+
:decision,
|
|
17
|
+
:facts,
|
|
18
|
+
:reason,
|
|
19
|
+
:error,
|
|
20
|
+
:cancelled
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ExecutionOutcome = Struct.new(
|
|
24
|
+
:result,
|
|
25
|
+
:error,
|
|
26
|
+
:cancelled
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PREFLIGHT_SETTLED_STATES = %i[
|
|
30
|
+
authorized awaiting_approval rejected failed cancelled completed
|
|
31
|
+
].freeze
|
|
32
|
+
TERMINAL_STATES = %i[completed rejected failed cancelled].freeze
|
|
33
|
+
|
|
34
|
+
attr_reader :id,
|
|
35
|
+
:parent_agent_invocation_id,
|
|
36
|
+
:agent,
|
|
37
|
+
:tool,
|
|
38
|
+
:tool_name,
|
|
39
|
+
:tool_call_id,
|
|
40
|
+
:raw_arguments,
|
|
41
|
+
:arguments,
|
|
42
|
+
:facts,
|
|
43
|
+
:final_decision,
|
|
44
|
+
:authorization_reason,
|
|
45
|
+
:result,
|
|
46
|
+
:error,
|
|
47
|
+
:status,
|
|
48
|
+
:session_id,
|
|
49
|
+
:phase,
|
|
50
|
+
:config,
|
|
51
|
+
:approval_policy,
|
|
52
|
+
:approval_context,
|
|
53
|
+
:origin,
|
|
54
|
+
:metadata
|
|
55
|
+
|
|
56
|
+
def self.missing(
|
|
57
|
+
parent_agent_invocation_id:,
|
|
58
|
+
agent:,
|
|
59
|
+
tool_call:,
|
|
60
|
+
config: {}
|
|
61
|
+
)
|
|
62
|
+
new(
|
|
63
|
+
parent_agent_invocation_id: parent_agent_invocation_id,
|
|
64
|
+
agent: agent,
|
|
65
|
+
tool: nil,
|
|
66
|
+
tool_call: tool_call,
|
|
67
|
+
config: config
|
|
68
|
+
).tap do |invocation|
|
|
69
|
+
invocation.send(:complete_missing_tool!)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(
|
|
74
|
+
parent_agent_invocation_id:,
|
|
75
|
+
agent:,
|
|
76
|
+
tool:,
|
|
77
|
+
tool_call:,
|
|
78
|
+
config:,
|
|
79
|
+
approval_policy: nil,
|
|
80
|
+
approval_context: {},
|
|
81
|
+
id: SecureRandom.uuid
|
|
82
|
+
)
|
|
83
|
+
@id = id.to_s
|
|
84
|
+
@parent_agent_invocation_id =
|
|
85
|
+
parent_agent_invocation_id.to_s
|
|
86
|
+
@agent = agent
|
|
87
|
+
@tool = tool
|
|
88
|
+
@tool_name = tool_call.name.to_s
|
|
89
|
+
@tool_call_id =
|
|
90
|
+
tool_call.respond_to?(:id) ? tool_call.id : nil
|
|
91
|
+
raw_arguments =
|
|
92
|
+
if tool_call.respond_to?(:arguments)
|
|
93
|
+
tool_call.arguments || {}
|
|
94
|
+
else
|
|
95
|
+
{}
|
|
96
|
+
end
|
|
97
|
+
@raw_arguments = immutable_copy(raw_arguments)
|
|
98
|
+
@config = config
|
|
99
|
+
@approval_policy = approval_policy
|
|
100
|
+
@approval_context =
|
|
101
|
+
immutable_copy(approval_context || {})
|
|
102
|
+
@origin =
|
|
103
|
+
if tool&.respond_to?(:tool_origin)
|
|
104
|
+
tool.tool_origin.to_sym
|
|
105
|
+
else
|
|
106
|
+
:local
|
|
107
|
+
end
|
|
108
|
+
@metadata =
|
|
109
|
+
immutable_copy(
|
|
110
|
+
tool&.respond_to?(:approval_metadata) ?
|
|
111
|
+
tool.approval_metadata :
|
|
112
|
+
{}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
@arguments = nil
|
|
116
|
+
@facts = {}.freeze
|
|
117
|
+
@final_decision = nil
|
|
118
|
+
@authorization_reason = nil
|
|
119
|
+
@result = nil
|
|
120
|
+
@error = nil
|
|
121
|
+
@approval_consumed = false
|
|
122
|
+
@status = :created
|
|
123
|
+
@session_id = nil
|
|
124
|
+
@phase = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
128
|
+
@session_id = thread_id if thread_id
|
|
129
|
+
@phase = phase
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Applies Tool-internal asynchronous completion events on the EventLoop
|
|
133
|
+
# thread. The return value tells FSMSession that the event was consumed by
|
|
134
|
+
# the context before its declared transition is evaluated.
|
|
135
|
+
def handle_fsm_event(event)
|
|
136
|
+
case event.type
|
|
137
|
+
when :authorization_completed
|
|
138
|
+
outcome = event.payload
|
|
139
|
+
if outcome.is_a?(Exception)
|
|
140
|
+
outcome =
|
|
141
|
+
AuthorizationOutcome.new(error: outcome)
|
|
142
|
+
end
|
|
143
|
+
apply_authorization_outcome(outcome)
|
|
144
|
+
true
|
|
145
|
+
when :execution_completed
|
|
146
|
+
outcome = event.payload
|
|
147
|
+
if outcome.is_a?(Exception)
|
|
148
|
+
outcome = ExecutionOutcome.new(
|
|
149
|
+
error: outcome,
|
|
150
|
+
cancelled: outcome.is_a?(Phronomy::CancellationError)
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
apply_execution_outcome(outcome)
|
|
154
|
+
true
|
|
155
|
+
else
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Deprecated internal compatibility hook. FSMSession no longer calls
|
|
161
|
+
# this method; asynchronous results enter through explicit events.
|
|
162
|
+
def apply_fsm_action_result(outcome)
|
|
163
|
+
event_type =
|
|
164
|
+
case outcome
|
|
165
|
+
when AuthorizationOutcome
|
|
166
|
+
:authorization_completed
|
|
167
|
+
when ExecutionOutcome
|
|
168
|
+
:execution_completed
|
|
169
|
+
else
|
|
170
|
+
return self
|
|
171
|
+
end
|
|
172
|
+
handle_fsm_event(
|
|
173
|
+
Phronomy::Event.new(
|
|
174
|
+
type: event_type,
|
|
175
|
+
target_id: @id,
|
|
176
|
+
payload: outcome
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def validate!
|
|
183
|
+
return self if terminal?
|
|
184
|
+
|
|
185
|
+
validated, schema_error =
|
|
186
|
+
if @tool.respond_to?(:validate_and_coerce, true)
|
|
187
|
+
@tool.send(:validate_and_coerce, @raw_arguments)
|
|
188
|
+
else
|
|
189
|
+
[@raw_arguments, nil]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
if schema_error
|
|
193
|
+
if @tool.class.respond_to?(:on_schema_error) &&
|
|
194
|
+
@tool.class.on_schema_error == :raise
|
|
195
|
+
@error = Phronomy::ToolError.new(
|
|
196
|
+
"#{@tool.class.name} schema error: #{schema_error}"
|
|
197
|
+
)
|
|
198
|
+
@status = :failed
|
|
199
|
+
else
|
|
200
|
+
@result = "Schema validation failed: #{schema_error}"
|
|
201
|
+
@status = :completed
|
|
202
|
+
end
|
|
203
|
+
return self
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
@arguments = immutable_copy(validated || {})
|
|
207
|
+
@status = :valid
|
|
208
|
+
self
|
|
209
|
+
rescue => error
|
|
210
|
+
@error = error
|
|
211
|
+
@status = :failed
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def authorization_task(runtime: Phronomy::Runtime.instance)
|
|
216
|
+
pool = runtime.pool(
|
|
217
|
+
:authorization,
|
|
218
|
+
size: Phronomy.configuration.authorization_pool_size,
|
|
219
|
+
queue_size: Phronomy.configuration.authorization_queue_size
|
|
220
|
+
)
|
|
221
|
+
timeout = @config.fetch(
|
|
222
|
+
:authorization_timeout,
|
|
223
|
+
Phronomy.configuration.authorization_timeout
|
|
224
|
+
)
|
|
225
|
+
cancellation_token = @config[:cancellation_token]
|
|
226
|
+
pending = pool.submit(
|
|
227
|
+
timeout: timeout,
|
|
228
|
+
cancellation_token: cancellation_token,
|
|
229
|
+
on_full: :raise
|
|
230
|
+
) do
|
|
231
|
+
evaluate_authorization
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
task = Phronomy::Task.deferred(
|
|
235
|
+
name: "tool-authorization:#{@tool_name}"
|
|
236
|
+
)
|
|
237
|
+
pending.on_complete do |outcome, error|
|
|
238
|
+
resolved =
|
|
239
|
+
if error
|
|
240
|
+
authorization_failure_outcome(error)
|
|
241
|
+
else
|
|
242
|
+
outcome
|
|
243
|
+
end
|
|
244
|
+
task.backend.unblock(resolved, nil)
|
|
245
|
+
task.transition!(:completed, value: resolved)
|
|
246
|
+
end
|
|
247
|
+
task
|
|
248
|
+
rescue => error
|
|
249
|
+
task = Phronomy::Task.deferred(
|
|
250
|
+
name: "tool-authorization:#{@tool_name}"
|
|
251
|
+
)
|
|
252
|
+
outcome = authorization_failure_outcome(error)
|
|
253
|
+
task.backend.unblock(outcome, nil)
|
|
254
|
+
task.transition!(:completed, value: outcome)
|
|
255
|
+
task
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def execution_task(runtime: Phronomy::Runtime.instance)
|
|
259
|
+
pending =
|
|
260
|
+
Phronomy::Agent::ToolExecutor.call_invocation_async(
|
|
261
|
+
tool_invocation: self,
|
|
262
|
+
cancellation_token: @config[:cancellation_token],
|
|
263
|
+
config: @config,
|
|
264
|
+
runtime: runtime
|
|
265
|
+
)
|
|
266
|
+
task = Phronomy::Task.deferred(
|
|
267
|
+
name: "tool-execution:#{@tool_name}"
|
|
268
|
+
)
|
|
269
|
+
pending.on_complete do |result, error|
|
|
270
|
+
outcome =
|
|
271
|
+
if error
|
|
272
|
+
ExecutionOutcome.new(
|
|
273
|
+
error: error,
|
|
274
|
+
cancelled: error.is_a?(
|
|
275
|
+
Phronomy::CancellationError
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
else
|
|
279
|
+
ExecutionOutcome.new(result: result)
|
|
280
|
+
end
|
|
281
|
+
task.backend.unblock(outcome, nil)
|
|
282
|
+
task.transition!(:completed, value: outcome)
|
|
283
|
+
end
|
|
284
|
+
task
|
|
285
|
+
rescue => error
|
|
286
|
+
task = Phronomy::Task.deferred(
|
|
287
|
+
name: "tool-execution:#{@tool_name}"
|
|
288
|
+
)
|
|
289
|
+
outcome = ExecutionOutcome.new(
|
|
290
|
+
error: error,
|
|
291
|
+
cancelled: error.is_a?(Phronomy::CancellationError)
|
|
292
|
+
)
|
|
293
|
+
task.backend.unblock(outcome, nil)
|
|
294
|
+
task.transition!(:completed, value: outcome)
|
|
295
|
+
task
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def mark_awaiting_approval!
|
|
299
|
+
@status = :awaiting_approval
|
|
300
|
+
self
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def mark_authorized!
|
|
304
|
+
@approval_consumed = true if @status == :awaiting_approval
|
|
305
|
+
@status = :authorized
|
|
306
|
+
self
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def mark_queued!
|
|
310
|
+
@status = :queued
|
|
311
|
+
self
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def mark_running!
|
|
315
|
+
@status = :running
|
|
316
|
+
self
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def mark_rejected!
|
|
320
|
+
@final_decision = :reject
|
|
321
|
+
@status = :rejected
|
|
322
|
+
self
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def mark_cancelled!
|
|
326
|
+
@status = :cancelled
|
|
327
|
+
self
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def mark_framework_failed!(error)
|
|
331
|
+
@error = error
|
|
332
|
+
@status = :failed
|
|
333
|
+
self
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def validation_passed?
|
|
337
|
+
@status == :valid
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def validation_completed?
|
|
341
|
+
@status == :completed
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def failed?
|
|
345
|
+
@status == :failed
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def cancelled?
|
|
349
|
+
@status == :cancelled
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def rejected?
|
|
353
|
+
@status == :rejected
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def awaiting_approval?
|
|
357
|
+
@status == :awaiting_approval
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def authorized?
|
|
361
|
+
@status == :authorized
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def execution_completed?
|
|
365
|
+
@status == :completed
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def preflight_settled?
|
|
369
|
+
PREFLIGHT_SETTLED_STATES.include?(@status)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def terminal?
|
|
373
|
+
TERMINAL_STATES.include?(@status)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def dispatchable?
|
|
377
|
+
return false unless @status == :queued
|
|
378
|
+
|
|
379
|
+
@final_decision == :allow || @approval_consumed
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def tool_schema
|
|
383
|
+
@tool&.respond_to?(:params_schema) ?
|
|
384
|
+
@tool.params_schema :
|
|
385
|
+
{}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def display_arguments
|
|
389
|
+
redact_for_display(@arguments || @raw_arguments)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def display_facts
|
|
393
|
+
sensitive_values = sensitive_argument_values
|
|
394
|
+
redact_value(@facts, sensitive_values)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
private
|
|
398
|
+
|
|
399
|
+
def evaluate_authorization
|
|
400
|
+
request = build_request(
|
|
401
|
+
facts: {},
|
|
402
|
+
default_decision: nil
|
|
403
|
+
)
|
|
404
|
+
facts = evaluate_facts
|
|
405
|
+
request = request.with(facts: facts)
|
|
406
|
+
default_decision = evaluate_default_decision(request)
|
|
407
|
+
request = request.with(
|
|
408
|
+
default_decision: default_decision
|
|
409
|
+
)
|
|
410
|
+
decision =
|
|
411
|
+
if @approval_policy
|
|
412
|
+
@approval_policy.call(request)
|
|
413
|
+
else
|
|
414
|
+
default_decision
|
|
415
|
+
end
|
|
416
|
+
decision = decision.to_sym if decision.respond_to?(:to_sym)
|
|
417
|
+
|
|
418
|
+
unless ApprovalEvaluationRequest::VALID_DECISIONS
|
|
419
|
+
.include?(decision)
|
|
420
|
+
raise Phronomy::ConfigurationError,
|
|
421
|
+
"tool_approval_policy must return :allow, " \
|
|
422
|
+
":require_approval, or :reject " \
|
|
423
|
+
"(got #{decision.inspect})"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
reason =
|
|
427
|
+
if decision == :require_approval
|
|
428
|
+
if @origin == :mcp
|
|
429
|
+
"MCP Tool execution requires approval"
|
|
430
|
+
else
|
|
431
|
+
"Tool execution requires approval"
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
AuthorizationOutcome.new(
|
|
436
|
+
decision: decision,
|
|
437
|
+
facts: facts,
|
|
438
|
+
reason: reason
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def evaluate_facts
|
|
443
|
+
callable =
|
|
444
|
+
if @tool.class.respond_to?(:approval_facts)
|
|
445
|
+
@tool.class.approval_facts
|
|
446
|
+
end
|
|
447
|
+
return {} unless callable
|
|
448
|
+
|
|
449
|
+
value = callable.call(
|
|
450
|
+
@arguments,
|
|
451
|
+
@approval_context
|
|
452
|
+
)
|
|
453
|
+
unless value.nil? || value.is_a?(Hash)
|
|
454
|
+
raise Phronomy::ConfigurationError,
|
|
455
|
+
"approval_facts must return a Hash or nil " \
|
|
456
|
+
"(got #{value.class})"
|
|
457
|
+
end
|
|
458
|
+
immutable_copy(value || {})
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def evaluate_default_decision(request)
|
|
462
|
+
requirement =
|
|
463
|
+
if @tool.respond_to?(:requires_approval)
|
|
464
|
+
@tool.requires_approval
|
|
465
|
+
else
|
|
466
|
+
false
|
|
467
|
+
end
|
|
468
|
+
if requirement.respond_to?(:call)
|
|
469
|
+
requirement = requirement.call(request)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
case requirement
|
|
473
|
+
when true
|
|
474
|
+
:require_approval
|
|
475
|
+
when false, nil
|
|
476
|
+
:allow
|
|
477
|
+
else
|
|
478
|
+
raise Phronomy::ConfigurationError,
|
|
479
|
+
"requires_approval callable must return true or false " \
|
|
480
|
+
"(got #{requirement.inspect})"
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def build_request(facts:, default_decision:)
|
|
485
|
+
ApprovalEvaluationRequest.new(
|
|
486
|
+
agent: @agent,
|
|
487
|
+
agent_invocation_id: @parent_agent_invocation_id,
|
|
488
|
+
tool: @tool,
|
|
489
|
+
tool_name: @tool_name,
|
|
490
|
+
tool_schema: tool_schema,
|
|
491
|
+
tool_invocation_id: @id,
|
|
492
|
+
tool_call_id: @tool_call_id,
|
|
493
|
+
arguments: @arguments,
|
|
494
|
+
facts: facts,
|
|
495
|
+
invocation_context: @approval_context,
|
|
496
|
+
origin: @origin,
|
|
497
|
+
metadata: @metadata,
|
|
498
|
+
default_decision: default_decision
|
|
499
|
+
)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def authorization_failure_outcome(error)
|
|
503
|
+
if error.is_a?(Phronomy::TimeoutError) ||
|
|
504
|
+
error.is_a?(Phronomy::TransportError) ||
|
|
505
|
+
error.is_a?(Phronomy::BackpressureError)
|
|
506
|
+
AuthorizationOutcome.new(
|
|
507
|
+
decision: :require_approval,
|
|
508
|
+
facts: {},
|
|
509
|
+
reason:
|
|
510
|
+
"Authorization could not be completed safely: " \
|
|
511
|
+
"#{error.message}"
|
|
512
|
+
)
|
|
513
|
+
elsif error.is_a?(Phronomy::CancellationError)
|
|
514
|
+
AuthorizationOutcome.new(
|
|
515
|
+
error: error,
|
|
516
|
+
cancelled: true
|
|
517
|
+
)
|
|
518
|
+
else
|
|
519
|
+
AuthorizationOutcome.new(error: error)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def apply_authorization_outcome(outcome)
|
|
524
|
+
unless outcome.is_a?(AuthorizationOutcome)
|
|
525
|
+
raise Phronomy::Error,
|
|
526
|
+
"Expected AuthorizationOutcome, got #{outcome.class}"
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
@facts = immutable_copy(outcome.facts || {})
|
|
530
|
+
@authorization_reason = outcome.reason
|
|
531
|
+
@error = outcome.error
|
|
532
|
+
|
|
533
|
+
if outcome.cancelled
|
|
534
|
+
@status = :cancelled
|
|
535
|
+
elsif outcome.error
|
|
536
|
+
@status = :failed
|
|
537
|
+
else
|
|
538
|
+
@final_decision = outcome.decision
|
|
539
|
+
@status =
|
|
540
|
+
case outcome.decision
|
|
541
|
+
when :allow
|
|
542
|
+
:authorized
|
|
543
|
+
when :require_approval
|
|
544
|
+
:awaiting_approval
|
|
545
|
+
when :reject
|
|
546
|
+
:rejected
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def apply_execution_outcome(outcome)
|
|
552
|
+
unless outcome.is_a?(ExecutionOutcome)
|
|
553
|
+
raise Phronomy::Error,
|
|
554
|
+
"Expected ExecutionOutcome, got #{outcome.class}"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
@result = outcome.result
|
|
558
|
+
@error = outcome.error
|
|
559
|
+
@status =
|
|
560
|
+
if outcome.cancelled
|
|
561
|
+
:cancelled
|
|
562
|
+
elsif outcome.error
|
|
563
|
+
:failed
|
|
564
|
+
else
|
|
565
|
+
:completed
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def complete_missing_tool!
|
|
570
|
+
@result = "Tool not found: #{@tool_name}"
|
|
571
|
+
@status = :completed
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
def immutable_copy(value)
|
|
575
|
+
case value
|
|
576
|
+
when Hash
|
|
577
|
+
value.each_with_object({}) do |(key, item), result|
|
|
578
|
+
result[immutable_copy(key)] =
|
|
579
|
+
immutable_copy(item)
|
|
580
|
+
end.freeze
|
|
581
|
+
when Array
|
|
582
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
583
|
+
when String
|
|
584
|
+
value.dup.freeze
|
|
585
|
+
else
|
|
586
|
+
value
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def redact_for_display(value)
|
|
591
|
+
if @tool&.respond_to?(:redacted_args, true)
|
|
592
|
+
immutable_copy(
|
|
593
|
+
@tool.send(:redacted_args, value || {})
|
|
594
|
+
)
|
|
595
|
+
else
|
|
596
|
+
immutable_copy(value || {})
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
def sensitive_argument_values
|
|
601
|
+
return [] unless @tool&.class&.respond_to?(:redact_params)
|
|
602
|
+
|
|
603
|
+
normalized =
|
|
604
|
+
(@arguments || @raw_arguments || {})
|
|
605
|
+
.transform_keys(&:to_sym)
|
|
606
|
+
@tool.class.redact_params.filter_map do |name|
|
|
607
|
+
normalized[name]
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
def redact_value(value, sensitive_values)
|
|
612
|
+
if sensitive_values.any? { |sensitive| sensitive == value }
|
|
613
|
+
return "[REDACTED]"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
case value
|
|
617
|
+
when Hash
|
|
618
|
+
value.each_with_object({}) do |(key, item), result|
|
|
619
|
+
result[key] =
|
|
620
|
+
redact_value(item, sensitive_values)
|
|
621
|
+
end.freeze
|
|
622
|
+
when Array
|
|
623
|
+
value.map do |item|
|
|
624
|
+
redact_value(item, sensitive_values)
|
|
625
|
+
end.freeze
|
|
626
|
+
when String
|
|
627
|
+
"[REDACTED]"
|
|
628
|
+
else
|
|
629
|
+
value
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
end
|