phronomy 0.16.0 → 0.18.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.
Files changed (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -4,27 +4,9 @@ require "securerandom"
4
4
 
5
5
  module Phronomy
6
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
7
  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
- )
8
+ AuthorizationOutcome = Struct.new(:decision, :facts, :reason, :error, :cancelled)
9
+ ExecutionOutcome = Struct.new(:result, :error, :cancelled)
28
10
 
29
11
  PREFLIGHT_SETTLED_STATES = %i[
30
12
  authorized awaiting_approval rejected failed cancelled completed
@@ -53,21 +35,14 @@ module Phronomy
53
35
  :origin,
54
36
  :metadata
55
37
 
56
- def self.missing(
57
- parent_agent_invocation_id:,
58
- agent:,
59
- tool_call:,
60
- config: {}
61
- )
38
+ def self.missing(parent_agent_invocation_id:, agent:, tool_call:, config: {})
62
39
  new(
63
40
  parent_agent_invocation_id: parent_agent_invocation_id,
64
41
  agent: agent,
65
42
  tool: nil,
66
43
  tool_call: tool_call,
67
44
  config: config
68
- ).tap do |invocation|
69
- invocation.send(:complete_missing_tool!)
70
- end
45
+ ).tap { |invocation| invocation.send(:complete_missing_tool!) }
71
46
  end
72
47
 
73
48
  def initialize(
@@ -81,37 +56,20 @@ module Phronomy
81
56
  id: SecureRandom.uuid
82
57
  )
83
58
  @id = id.to_s
84
- @parent_agent_invocation_id =
85
- parent_agent_invocation_id.to_s
59
+ @parent_agent_invocation_id = parent_agent_invocation_id.to_s
86
60
  @agent = agent
87
61
  @tool = tool
88
62
  @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
63
+ @tool_call_id = tool_call.respond_to?(:id) ? tool_call.id : nil
64
+ raw_arguments = tool_call.respond_to?(:arguments) ? (tool_call.arguments || {}) : {}
97
65
  @raw_arguments = immutable_copy(raw_arguments)
98
66
  @config = config
99
67
  @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
-
68
+ @approval_context = immutable_copy(approval_context || {})
69
+ @origin = tool&.respond_to?(:tool_origin) ? tool.tool_origin.to_sym : :local
70
+ @metadata = immutable_copy(
71
+ tool&.respond_to?(:approval_metadata) ? tool.approval_metadata : {}
72
+ )
115
73
  @arguments = nil
116
74
  @facts = {}.freeze
117
75
  @final_decision = nil
@@ -129,17 +87,11 @@ module Phronomy
129
87
  @phase = phase
130
88
  end
131
89
 
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
90
  def handle_fsm_event(event)
136
91
  case event.type
137
92
  when :authorization_completed
138
93
  outcome = event.payload
139
- if outcome.is_a?(Exception)
140
- outcome =
141
- AuthorizationOutcome.new(error: outcome)
142
- end
94
+ outcome = AuthorizationOutcome.new(error: outcome) if outcome.is_a?(Exception)
143
95
  apply_authorization_outcome(outcome)
144
96
  true
145
97
  when :execution_completed
@@ -157,41 +109,17 @@ module Phronomy
157
109
  end
158
110
  end
159
111
 
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
112
  def validate!
183
113
  return self if terminal?
184
114
 
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
115
+ validated, schema_error = if @tool.respond_to?(:validate_and_coerce, true)
116
+ @tool.send(:validate_and_coerce, @raw_arguments)
117
+ else
118
+ [@raw_arguments, nil]
119
+ end
191
120
 
192
121
  if schema_error
193
- if @tool.class.respond_to?(:on_schema_error) &&
194
- @tool.class.on_schema_error == :raise
122
+ if @tool.class.respond_to?(:on_schema_error) && @tool.class.on_schema_error == :raise
195
123
  @error = Phronomy::ToolError.new(
196
124
  "#{@tool.class.name} schema error: #{schema_error}"
197
125
  )
@@ -212,7 +140,11 @@ module Phronomy
212
140
  self
213
141
  end
214
142
 
215
- def authorization_task(runtime: Phronomy::Runtime.instance)
143
+ # Starts authorization and reports exactly one AuthorizationOutcome through
144
+ # the callback. No Task is created.
145
+ def start_authorization(runtime: Phronomy::Runtime.instance, &callback)
146
+ raise ArgumentError, "start_authorization requires a callback" unless callback
147
+
216
148
  pool = runtime.pool(
217
149
  :authorization,
218
150
  size: Phronomy.configuration.authorization_pool_size,
@@ -222,167 +154,106 @@ module Phronomy
222
154
  :authorization_timeout,
223
155
  Phronomy.configuration.authorization_timeout
224
156
  )
225
- cancellation_token = @config[:cancellation_token]
226
- pending = pool.submit(
157
+ operation = pool.submit(
227
158
  timeout: timeout,
228
- cancellation_token: cancellation_token,
159
+ cancellation_token: @config[:cancellation_token],
229
160
  on_full: :raise
230
- ) do
231
- evaluate_authorization
161
+ ) { evaluate_authorization }
162
+ operation.on_complete do |outcome, error|
163
+ callback.call(error ? authorization_failure_outcome(error) : outcome)
232
164
  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
165
+ self
285
166
  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
167
+ callback.call(authorization_failure_outcome(error))
300
168
  self
301
169
  end
302
170
 
303
- def mark_authorized!
304
- @approval_consumed = true if @status == :awaiting_approval
305
- @status = :authorized
306
- self
307
- end
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
+ def start_execution(runtime: Phronomy::Runtime.instance, &callback)
184
+ raise ArgumentError, "start_execution requires a callback" unless callback
185
+ unless dispatchable?
186
+ callback.call(ExecutionOutcome.new(error: Phronomy::ToolError.new(
187
+ "ToolInvocation #{@id} is not authorized for dispatch"
188
+ )))
189
+ return self
190
+ end
308
191
 
309
- def mark_queued!
310
- @status = :queued
311
- self
312
- end
192
+ case @tool.class.execution_mode
193
+ when :cooperative, :offloaded
194
+ operation = start_async_tool_operation(runtime)
195
+ unless operation.respond_to?(:on_complete)
196
+ raise Phronomy::ToolError,
197
+ "Tool #{@tool.class.name}#call_async must return a completion handle"
198
+ end
313
199
 
314
- def mark_running!
315
- @status = :running
200
+ operation.on_complete do |result, error|
201
+ callback.call(execution_outcome(result, error))
202
+ end
203
+ else
204
+ callback.call(ExecutionOutcome.new(error: Phronomy::ConfigurationError.new(
205
+ "unknown Tool execution_mode: #{@tool.class.execution_mode.inspect}"
206
+ )))
207
+ end
316
208
  self
317
- end
318
-
319
- def mark_rejected!
320
- @final_decision = :reject
321
- @status = :rejected
209
+ rescue => error
210
+ callback.call(execution_outcome(nil, error))
322
211
  self
323
212
  end
324
213
 
325
- def mark_cancelled!
326
- @status = :cancelled
327
- self
328
- end
214
+ def mark_awaiting_approval! = (@status = :awaiting_approval
215
+ self)
329
216
 
330
- def mark_framework_failed!(error)
331
- @error = error
332
- @status = :failed
217
+ def mark_authorized!
218
+ @approval_consumed = true if @status == :awaiting_approval
219
+ @status = :authorized
333
220
  self
334
221
  end
335
222
 
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
223
+ def mark_queued! = (@status = :queued
224
+ self)
351
225
 
352
- def rejected?
353
- @status == :rejected
354
- end
226
+ def mark_running! = (@status = :running
227
+ self)
355
228
 
356
- def awaiting_approval?
357
- @status == :awaiting_approval
358
- end
229
+ def mark_rejected! = (@final_decision = :reject
230
+ @status = :rejected
231
+ self)
359
232
 
360
- def authorized?
361
- @status == :authorized
362
- end
233
+ def mark_cancelled! = (@status = :cancelled
234
+ self)
363
235
 
364
- def execution_completed?
365
- @status == :completed
366
- end
367
-
368
- def preflight_settled?
369
- PREFLIGHT_SETTLED_STATES.include?(@status)
370
- end
236
+ def mark_framework_failed!(error) = (@error = error
237
+ @status = :failed
238
+ self)
371
239
 
372
- def terminal?
373
- TERMINAL_STATES.include?(@status)
374
- end
240
+ def validation_passed? = @status == :valid
241
+ def validation_completed? = @status == :completed
242
+ def failed? = @status == :failed
243
+ def cancelled? = @status == :cancelled
244
+ def rejected? = @status == :rejected
245
+ def awaiting_approval? = @status == :awaiting_approval
246
+ def authorized? = @status == :authorized
247
+ def execution_completed? = @status == :completed
248
+ def preflight_settled? = PREFLIGHT_SETTLED_STATES.include?(@status)
249
+ def terminal? = TERMINAL_STATES.include?(@status)
375
250
 
376
251
  def dispatchable?
377
- return false unless @status == :queued
378
-
379
- @final_decision == :allow || @approval_consumed
252
+ @status == :queued && (@final_decision == :allow || @approval_consumed)
380
253
  end
381
254
 
382
255
  def tool_schema
383
- @tool&.respond_to?(:params_schema) ?
384
- @tool.params_schema :
385
- {}
256
+ @tool&.respond_to?(:params_schema) ? @tool.params_schema : {}
386
257
  end
387
258
 
388
259
  def display_arguments
@@ -390,94 +261,95 @@ module Phronomy
390
261
  end
391
262
 
392
263
  def display_facts
393
- sensitive_values = sensitive_argument_values
394
- redact_value(@facts, sensitive_values)
264
+ redact_value(@facts, sensitive_argument_values)
395
265
  end
396
266
 
397
267
  private
398
268
 
269
+ # Runtime is framework execution infrastructure, not part of the public
270
+ # Tool#call_async protocol.
271
+ #
272
+ # Tools using Capability::Base's default async implementation are routed
273
+ # directly through ToolExecutor so this ToolInvocation can supply its
274
+ # owning Runtime internally. Tools that override #call_async (for example
275
+ # Agent-backed Tools) receive only the public Tool async keywords.
276
+ def start_async_tool_operation(runtime)
277
+ if uses_default_call_async?
278
+ Phronomy::Agent::ToolExecutor.call_async(
279
+ tool: @tool,
280
+ args: @arguments,
281
+ cancellation_token: @config[:cancellation_token],
282
+ config: @config,
283
+ runtime: runtime,
284
+ on_full: :raise
285
+ )
286
+ else
287
+ @tool.call_async(
288
+ @arguments,
289
+ cancellation_token: @config[:cancellation_token],
290
+ config: @config
291
+ )
292
+ end
293
+ end
294
+
295
+ def uses_default_call_async?
296
+ @tool.method(:call_async).owner ==
297
+ Phronomy::Agent::Context::Capability::Base
298
+ end
299
+
300
+ def execution_outcome(result, error)
301
+ if error
302
+ ExecutionOutcome.new(
303
+ error: error,
304
+ cancelled: error.is_a?(Phronomy::CancellationError)
305
+ )
306
+ else
307
+ ExecutionOutcome.new(result: result)
308
+ end
309
+ end
310
+
399
311
  def evaluate_authorization
400
- request = build_request(
401
- facts: {},
402
- default_decision: nil
403
- )
312
+ request = build_request(facts: {}, default_decision: nil)
404
313
  facts = evaluate_facts
405
314
  request = request.with(facts: facts)
406
315
  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
316
+ request = request.with(default_decision: default_decision)
317
+ decision = @approval_policy ? @approval_policy.call(request) : default_decision
416
318
  decision = decision.to_sym if decision.respond_to?(:to_sym)
417
319
 
418
- unless ApprovalEvaluationRequest::VALID_DECISIONS
419
- .include?(decision)
320
+ unless ApprovalEvaluationRequest::VALID_DECISIONS.include?(decision)
420
321
  raise Phronomy::ConfigurationError,
421
- "tool_approval_policy must return :allow, " \
422
- ":require_approval, or :reject " \
322
+ "tool_approval_policy must return :allow, :require_approval, or :reject " \
423
323
  "(got #{decision.inspect})"
424
324
  end
425
325
 
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
- )
326
+ reason = if decision == :require_approval
327
+ (@origin == :mcp) ? "MCP Tool execution requires approval" : "Tool execution requires approval"
328
+ end
329
+ AuthorizationOutcome.new(decision: decision, facts: facts, reason: reason)
440
330
  end
441
331
 
442
332
  def evaluate_facts
443
- callable =
444
- if @tool.class.respond_to?(:approval_facts)
445
- @tool.class.approval_facts
446
- end
333
+ callable = @tool.class.approval_facts if @tool.class.respond_to?(:approval_facts)
447
334
  return {} unless callable
448
335
 
449
- value = callable.call(
450
- @arguments,
451
- @approval_context
452
- )
336
+ value = callable.call(@arguments, @approval_context)
453
337
  unless value.nil? || value.is_a?(Hash)
454
338
  raise Phronomy::ConfigurationError,
455
- "approval_facts must return a Hash or nil " \
456
- "(got #{value.class})"
339
+ "approval_facts must return a Hash or nil (got #{value.class})"
457
340
  end
458
341
  immutable_copy(value || {})
459
342
  end
460
343
 
461
344
  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
-
345
+ requirement = @tool.respond_to?(:requires_approval) ? @tool.requires_approval : false
346
+ requirement = requirement.call(request) if requirement.respond_to?(:call)
472
347
  case requirement
473
- when true
474
- :require_approval
475
- when false, nil
476
- :allow
348
+ when true then :require_approval
349
+ when false, nil then :allow
477
350
  else
478
351
  raise Phronomy::ConfigurationError,
479
- "requires_approval callable must return true or false " \
480
- "(got #{requirement.inspect})"
352
+ "requires_approval callable must return true or false (got #{requirement.inspect})"
481
353
  end
482
354
  end
483
355
 
@@ -506,15 +378,10 @@ module Phronomy
506
378
  AuthorizationOutcome.new(
507
379
  decision: :require_approval,
508
380
  facts: {},
509
- reason:
510
- "Authorization could not be completed safely: " \
511
- "#{error.message}"
381
+ reason: "Authorization could not be completed safely: #{error.message}"
512
382
  )
513
383
  elsif error.is_a?(Phronomy::CancellationError)
514
- AuthorizationOutcome.new(
515
- error: error,
516
- cancelled: true
517
- )
384
+ AuthorizationOutcome.new(error: error, cancelled: true)
518
385
  else
519
386
  AuthorizationOutcome.new(error: error)
520
387
  end
@@ -522,48 +389,38 @@ module Phronomy
522
389
 
523
390
  def apply_authorization_outcome(outcome)
524
391
  unless outcome.is_a?(AuthorizationOutcome)
525
- raise Phronomy::Error,
526
- "Expected AuthorizationOutcome, got #{outcome.class}"
392
+ raise Phronomy::Error, "Expected AuthorizationOutcome, got #{outcome.class}"
527
393
  end
528
-
529
394
  @facts = immutable_copy(outcome.facts || {})
530
395
  @authorization_reason = outcome.reason
531
396
  @error = outcome.error
532
-
533
397
  if outcome.cancelled
534
398
  @status = :cancelled
535
399
  elsif outcome.error
536
400
  @status = :failed
537
401
  else
538
402
  @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
403
+ @status = case outcome.decision
404
+ when :allow then :authorized
405
+ when :require_approval then :awaiting_approval
406
+ when :reject then :rejected
407
+ end
548
408
  end
549
409
  end
550
410
 
551
411
  def apply_execution_outcome(outcome)
552
412
  unless outcome.is_a?(ExecutionOutcome)
553
- raise Phronomy::Error,
554
- "Expected ExecutionOutcome, got #{outcome.class}"
413
+ raise Phronomy::Error, "Expected ExecutionOutcome, got #{outcome.class}"
555
414
  end
556
-
557
415
  @result = outcome.result
558
416
  @error = outcome.error
559
- @status =
560
- if outcome.cancelled
561
- :cancelled
562
- elsif outcome.error
563
- :failed
564
- else
565
- :completed
566
- end
417
+ @status = if outcome.cancelled
418
+ :cancelled
419
+ elsif outcome.error
420
+ :failed
421
+ else
422
+ :completed
423
+ end
567
424
  end
568
425
 
569
426
  def complete_missing_tool!
@@ -575,8 +432,7 @@ module Phronomy
575
432
  case value
576
433
  when Hash
577
434
  value.each_with_object({}) do |(key, item), result|
578
- result[immutable_copy(key)] =
579
- immutable_copy(item)
435
+ result[immutable_copy(key)] = immutable_copy(item)
580
436
  end.freeze
581
437
  when Array
582
438
  value.map { |item| immutable_copy(item) }.freeze
@@ -589,9 +445,7 @@ module Phronomy
589
445
 
590
446
  def redact_for_display(value)
591
447
  if @tool&.respond_to?(:redacted_args, true)
592
- immutable_copy(
593
- @tool.send(:redacted_args, value || {})
594
- )
448
+ immutable_copy(@tool.send(:redacted_args, value || {}))
595
449
  else
596
450
  immutable_copy(value || {})
597
451
  end
@@ -599,30 +453,20 @@ module Phronomy
599
453
 
600
454
  def sensitive_argument_values
601
455
  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
456
+ normalized = (@arguments || @raw_arguments || {}).transform_keys(&:to_sym)
457
+ @tool.class.redact_params.filter_map { |name| normalized[name] }
609
458
  end
610
459
 
611
460
  def redact_value(value, sensitive_values)
612
- if sensitive_values.any? { |sensitive| sensitive == value }
613
- return "[REDACTED]"
614
- end
461
+ return "[REDACTED]" if sensitive_values.any? { |sensitive| sensitive == value }
615
462
 
616
463
  case value
617
464
  when Hash
618
465
  value.each_with_object({}) do |(key, item), result|
619
- result[key] =
620
- redact_value(item, sensitive_values)
466
+ result[key] = redact_value(item, sensitive_values)
621
467
  end.freeze
622
468
  when Array
623
- value.map do |item|
624
- redact_value(item, sensitive_values)
625
- end.freeze
469
+ value.map { |item| redact_value(item, sensitive_values) }.freeze
626
470
  when String
627
471
  "[REDACTED]"
628
472
  else