phronomy 0.17.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1134
  3. data/README.md +68 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  8. data/docs/features.md +87 -0
  9. data/docs/getting-started.md +351 -0
  10. data/docs/migrations/0.15.md +35 -0
  11. data/docs/migrations/0.16.md +43 -0
  12. data/docs/runtime-and-concurrency.md +258 -0
  13. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  14. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  15. data/lib/phronomy/agent/async_event_api.rb +3 -3
  16. data/lib/phronomy/agent/base.rb +35 -19
  17. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  18. data/lib/phronomy/agent/execution_coordinator.rb +6 -6
  19. data/lib/phronomy/agent/shared_state.rb +2 -0
  20. data/lib/phronomy/agent/tool_executor.rb +29 -71
  21. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  23. data/lib/phronomy/configuration.rb +5 -29
  24. data/lib/phronomy/diagnostics.rb +12 -41
  25. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  26. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  27. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  28. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  29. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  31. data/lib/phronomy/engine/event_loop.rb +89 -190
  32. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  33. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  34. data/lib/phronomy/engine/runtime.rb +45 -158
  35. data/lib/phronomy/engine/task.rb +136 -277
  36. data/lib/phronomy/llm_adapter/base.rb +14 -14
  37. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  38. data/lib/phronomy/llm_adapter.rb +2 -2
  39. data/lib/phronomy/metrics.rb +15 -30
  40. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  41. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  42. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  43. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  44. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  45. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  46. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  47. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  48. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  49. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  50. data/lib/phronomy/testing/eval/runner.rb +52 -0
  51. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  52. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  53. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  54. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  55. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  56. data/lib/phronomy/testing/eval.rb +9 -0
  57. data/lib/phronomy/testing/fake_clock.rb +6 -53
  58. data/lib/phronomy/testing.rb +2 -6
  59. data/lib/phronomy/tools/agent.rb +141 -6
  60. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  61. data/lib/phronomy/vector_store/base.rb +2 -2
  62. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  63. data/lib/phronomy/version.rb +1 -1
  64. data/lib/phronomy/workflow_runner.rb +2 -4
  65. data/lib/phronomy.rb +7 -4
  66. data/scripts/api_snapshot.rb +4 -4
  67. metadata +23 -31
  68. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  69. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  70. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  71. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  72. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  73. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  74. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  75. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  76. data/lib/phronomy/engine/task/backend.rb +0 -80
  77. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  78. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  79. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  80. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  81. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  82. data/lib/phronomy/engine/task_group.rb +0 -193
  83. data/lib/phronomy/eval/comparison.rb +0 -47
  84. data/lib/phronomy/eval/dataset.rb +0 -45
  85. data/lib/phronomy/eval/eval_case.rb +0 -17
  86. data/lib/phronomy/eval/eval_result.rb +0 -29
  87. data/lib/phronomy/eval/metrics.rb +0 -66
  88. data/lib/phronomy/eval/runner.rb +0 -94
  89. data/lib/phronomy/eval/scorer/base.rb +0 -22
  90. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  91. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  92. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  93. data/lib/phronomy/eval/scorer.rb +0 -9
  94. data/lib/phronomy/eval.rb +0 -7
  95. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  96. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -19,18 +19,25 @@ class ImportContext
19
19
  end
20
20
  end
21
21
 
22
+ # Example application service for synchronous work that must stay off EventLoop.
23
+ # The worker Thread belongs to Phronomy's bounded OffloadPool; the Workflow
24
+ # itself never blocks EventLoop waiting for the operation.
25
+ def start_import_async
26
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) do
27
+ # Replace with blocking file/DB/network work or another long synchronous call.
28
+ 100
29
+ end
30
+ end
31
+
22
32
  workflow = nil
23
33
 
24
34
  workflow = Phronomy::Workflow.define(ImportContext) do
25
35
  initial :importing
26
36
 
27
37
  state :importing, action: ->(context) {
28
- task = Phronomy::Runtime.instance.spawn do
29
- # Replace with application-owned asynchronous work.
30
- 100
31
- end
38
+ operation = start_import_async
32
39
 
33
- task.on_complete do |record_count, error|
40
+ operation.on_complete do |record_count, error|
34
41
  workflow.signal(
35
42
  thread_id: context.thread_id,
36
43
  event: error ? :import_failed : :import_completed,
@@ -41,7 +48,8 @@ workflow = Phronomy::Workflow.define(ImportContext) do
41
48
  )
42
49
  end
43
50
 
44
- # Do not return task. The state is active after this synchronous entry ends.
51
+ # Do not return the completion handle. The state remains active after this
52
+ # synchronous action returns, and later completion arrives as an FSM event.
45
53
  context
46
54
  }
47
55
 
@@ -4,7 +4,7 @@ module Phronomy
4
4
  module Agent
5
5
  # Builds FSMSession instances for AgentInvocation objects.
6
6
  #
7
- # Blocking/provider work returns through explicit Agent-internal events.
7
+ # Offloaded/provider work returns through explicit Agent-internal events.
8
8
  # Entry actions start operations and return synchronously.
9
9
  # Every LLM Call is prepared from a canonical Manifest and RuntimeProjection.
10
10
  #
@@ -272,7 +272,7 @@ module Phronomy
272
272
  def self.prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
273
273
  activation = invocation.config.fetch(:phronomy_activation)
274
274
  if invocation.user_message_sent
275
- preparation = runtime.blocking_io.submit do
275
+ preparation = runtime.offload.submit(on_full: :raise) do
276
276
  activation.coordinator.prepare_next_llm_call(activation)
277
277
  end
278
278
  preparation.on_complete do |projection, error|
@@ -9,7 +9,7 @@ module Phronomy
9
9
  if invocation_context
10
10
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
11
11
  end
12
- _check_scheduler_reentrancy(:invoke, :invoke_async)
12
+ _check_event_loop_reentrancy(:invoke, :invoke_async)
13
13
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
14
14
  result = invoke_async(
15
15
  input,
@@ -58,7 +58,7 @@ module Phronomy
58
58
  if invocation_context
59
59
  thread_id, config = _apply_invocation_context(thread_id, config, invocation_context)
60
60
  end
61
- _check_scheduler_reentrancy(:stream, :stream_async)
61
+ _check_event_loop_reentrancy(:stream, :stream_async)
62
62
  trace("agent.stream", input: input, **_build_caller_meta(config)) do |_span|
63
63
  result = stream_async(
64
64
  input,
@@ -98,7 +98,7 @@ module Phronomy
98
98
  end
99
99
 
100
100
  def approve(execution_id, approval_request_id:, approved: true, config: {})
101
- _check_scheduler_reentrancy(:approve, :approve_async)
101
+ _check_event_loop_reentrancy(:approve, :approve_async)
102
102
  approve_async(
103
103
  execution_id,
104
104
  approval_request_id: approval_request_id,
@@ -553,35 +553,20 @@ module Phronomy
553
553
  [effective_thread_id, effective_config]
554
554
  end
555
555
 
556
- def _check_scheduler_reentrancy(sync_method, async_method)
556
+ def _check_event_loop_reentrancy(sync_method, async_method)
557
557
  if Phronomy::Runtime.instance.event_loop.current?
558
- raise Phronomy::SchedulerReentrancyError,
558
+ raise Phronomy::EventLoopReentrancyError,
559
559
  "#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
560
560
  "Use #{async_method} and return immediately."
561
561
  end
562
-
563
- return unless Phronomy::Task.current
564
-
565
- msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
566
- "This blocks the scheduler until the inner invocation completes, preventing " \
567
- "other tasks from making progress. Use #{async_method} + await instead."
568
- if Phronomy.configuration.strict_runtime_guards
569
- raise Phronomy::SchedulerReentrancyError, msg
570
- elsif Phronomy.configuration.logger
571
- Phronomy.configuration.logger.warn(msg)
572
- else
573
- Kernel.warn("[phronomy] WARNING: #{msg}")
574
- end
575
562
  end
576
563
 
577
564
  def _complete_result_task(task, result)
578
- task.backend.unblock(result, nil)
579
- task.transition!(:completed, value: result)
565
+ task.complete(result)
580
566
  end
581
567
 
582
568
  def _fail_result_task(task, error)
583
- task.backend.unblock(nil, error)
584
- task.transition!(:failed, error: error)
569
+ task.fail(error)
585
570
  end
586
571
 
587
572
  def _translated_error(error)
@@ -782,6 +767,10 @@ module Phronomy
782
767
  return resolved if result_filters.empty?
783
768
 
784
769
  effective_name = resolved.new.name
770
+ custom_async_call =
771
+ resolved.instance_method(:call_async).owner !=
772
+ Phronomy::Agent::Context::Capability::Base
773
+
785
774
  Class.new(resolved) do
786
775
  tool_name effective_name
787
776
  define_method(:call) do |args, **kwargs|
@@ -790,6 +779,33 @@ module Phronomy
790
779
  filter.call(val, tool_name: name, args: args)
791
780
  }
792
781
  end
782
+
783
+ # Base#call_async ultimately executes #call, so the synchronous wrapper
784
+ # above already applies filters for ordinary Tools. Agent-backed Tools
785
+ # override #call_async and bypass #call; only those custom async Tools
786
+ # need an asynchronous result-filter wrapper here.
787
+ if custom_async_call
788
+ define_method(:call_async) do |args, **kwargs|
789
+ source = super(args, **kwargs)
790
+ filtered = Phronomy::Task.deferred(name: "tool-filter-#{name}")
791
+ source.on_complete do |value, error|
792
+ if error
793
+ filtered.fail(error)
794
+ next
795
+ end
796
+
797
+ begin
798
+ result = result_filters.inject(value) { |val, filter|
799
+ filter.call(val, tool_name: name, args: args)
800
+ }
801
+ filtered.complete(result)
802
+ rescue => filter_error
803
+ filtered.fail(filter_error)
804
+ end
805
+ end
806
+ filtered
807
+ end
808
+ end
793
809
  end
794
810
  end
795
811
  end
@@ -113,16 +113,22 @@ module Phronomy
113
113
 
114
114
  public
115
115
 
116
+ # Declares whether Tool work is safe to run cooperatively on the EventLoop
117
+ # or must be offloaded to a bounded worker pool.
118
+ #
119
+ # Phronomy does not classify the reason for offloading. Blocking I/O,
120
+ # CPU-bound synchronous work, and other long synchronous calls all use
121
+ # +:offloaded+. The application owns that workload classification.
116
122
  # @api public
117
123
  def execution_mode(value = nil)
118
124
  if value.nil?
119
125
  return @execution_mode if instance_variable_defined?(:@execution_mode)
120
126
  return superclass.execution_mode if superclass.respond_to?(:execution_mode)
121
127
 
122
- return :blocking_io
128
+ return :offloaded
123
129
  end
124
130
 
125
- valid = %i[cooperative blocking_io cpu_bound external_process]
131
+ valid = %i[cooperative offloaded]
126
132
  unless valid.include?(value)
127
133
  raise ArgumentError,
128
134
  "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
@@ -296,7 +302,11 @@ module Phronomy
296
302
  end
297
303
 
298
304
  # @api public
299
- def call_async(args, cancellation_token: nil, config: {})
305
+ def call_async(
306
+ args,
307
+ cancellation_token: nil,
308
+ config: {}
309
+ )
300
310
  Phronomy::Agent::ToolExecutor.call_async(
301
311
  tool: self,
302
312
  args: args,
@@ -19,7 +19,7 @@ module Phronomy
19
19
  )
20
20
  result_task = Phronomy::Task.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
21
21
  runtime = Phronomy::Runtime.instance
22
- preparation = runtime.blocking_io.submit do
22
+ preparation = runtime.offload.submit(on_full: :raise) do
23
23
  prepare(input, thread_id: thread_id, config: config)
24
24
  end
25
25
  preparation.on_complete do |prepared, error|
@@ -52,7 +52,7 @@ module Phronomy
52
52
  name: "agent-approval-resume:#{execution_id}"
53
53
  )
54
54
  runtime = Phronomy::Runtime.instance
55
- preparation = runtime.blocking_io.submit do
55
+ preparation = runtime.offload.submit(on_full: :raise) do
56
56
  execution = @agent.persistence.executions.load(execution_id)
57
57
  unless execution.agent_id == @agent.agent_id && execution.status == :suspended
58
58
  raise ArgumentError,
@@ -86,7 +86,7 @@ module Phronomy
86
86
  config: config
87
87
  )
88
88
  rescue => start_error
89
- commit = runtime.blocking_io.submit do
89
+ commit = runtime.offload.submit(on_full: :raise) do
90
90
  commit_failed(activation, activation.invocation, start_error)
91
91
  end
92
92
  commit.on_complete do |terminal, commit_error|
@@ -107,7 +107,7 @@ module Phronomy
107
107
 
108
108
  # Persists all Runtime events from the previous call, fixes the next
109
109
  # Canonical LLM Input Manifest, and materializes the next Runtime Projection.
110
- # This method is executed on the blocking adapter pool, never on EventLoop.
110
+ # This method is executed on an OffloadPool worker, never on EventLoop.
111
111
  def prepare_next_llm_call(activation)
112
112
  snapshot = activation.runtime_snapshot
113
113
  manifest = manifest_ref = updated = root = nil
@@ -397,7 +397,7 @@ module Phronomy
397
397
 
398
398
  def finish(activation, result_task, invocation, error)
399
399
  runtime = Phronomy::Runtime.instance
400
- operation = runtime.blocking_io.submit do
400
+ operation = runtime.offload.submit(on_full: :raise) do
401
401
  compute_terminal(activation, invocation, error)
402
402
  end
403
403
  operation.on_complete do |outcome, commit_error|
@@ -1069,7 +1069,7 @@ module Phronomy
1069
1069
  def dispatch_approval_listener(invocation, request)
1070
1070
  listener = invocation.approval_listener
1071
1071
  return unless listener
1072
- Phronomy::Runtime.instance.blocking_io.submit { listener.call(request) }
1072
+ Phronomy::Runtime.instance.offload.submit(on_full: :raise) { listener.call(request) }
1073
1073
  end
1074
1074
 
1075
1075
  def json_value(value)
@@ -144,6 +144,7 @@ module Phronomy
144
144
  tool_name "read_store"
145
145
  description "Read all current findings from the shared knowledge store. " \
146
146
  "Call this to see what other researchers have discovered."
147
+ execution_mode :cooperative
147
148
 
148
149
  define_method(:execute) { store.read_all.to_json }
149
150
  end
@@ -152,6 +153,7 @@ module Phronomy
152
153
  tool_name "write_finding"
153
154
  description "Record a new finding into the shared knowledge store so " \
154
155
  "that other researchers can build on your discovery."
156
+ execution_mode :cooperative
155
157
  param :content, type: :string, desc: "The finding to record"
156
158
 
157
159
  define_method(:execute) do |content:|
@@ -2,90 +2,48 @@
2
2
 
3
3
  module Phronomy
4
4
  module Agent
5
- # Centralises Tool execution routing based on execution_mode.
5
+ # Routes Tool work according to the Tool execution contract.
6
6
  #
7
- # Tool-specific timeout and retry belong to the Tool implementation or its
8
- # underlying client. This executor only chooses the Phronomy execution
9
- # resource and propagates cooperative cancellation.
10
- # @api private
7
+ # :cooperative Tool calls execute inline and must return quickly. call_async
8
+ # wraps their result in an already-settled Task and never consumes an
9
+ # OffloadPool worker.
10
+ #
11
+ # :offloaded Tool calls route synchronous work through OffloadPool. Phronomy
12
+ # does not distinguish whether the reason is blocking I/O, CPU-bound work, or
13
+ # another long synchronous operation.
11
14
  module ToolExecutor
12
- WARNED_MODES = Set.new
13
- WARNED_MODES_MUTEX = Mutex.new
14
- private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
15
-
16
- # Agent-owned execution boundary. Only a ToolInvocation that has consumed
17
- # authorization may enter this method.
18
- def self.call_invocation_async(
19
- tool_invocation:,
20
- cancellation_token: nil,
21
- config: {},
22
- runtime: Phronomy::Runtime.instance
23
- )
24
- unless tool_invocation.dispatchable?
25
- raise Phronomy::ToolError,
26
- "ToolInvocation #{tool_invocation.id} is not authorized for dispatch"
27
- end
28
-
29
- call_async(
30
- tool: tool_invocation.tool,
31
- args: tool_invocation.arguments,
32
- cancellation_token: cancellation_token,
33
- config: config,
34
- runtime: runtime
35
- )
36
- end
37
-
38
- # Low-level Tool API used by direct Tool#call_async callers. Agent execution
39
- # must use .call_invocation_async so authorization cannot be bypassed.
40
- #
41
- # +config+ remains available for invocation metadata, but Phronomy does not
42
- # interpret it as a Tool timeout or retry policy.
43
15
  def self.call_async(
44
16
  tool:,
45
17
  args:,
46
18
  cancellation_token: nil,
47
19
  config: {},
48
- runtime: Phronomy::Runtime.instance
20
+ runtime: nil,
21
+ on_full: :raise
49
22
  )
50
- ct = cancellation_token
51
23
  mode = tool.class.execution_mode
52
24
 
53
- if mode == :cpu_bound || mode == :external_process
54
- warn_key = [tool.class.name, mode]
55
- newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
56
- if newly_warned
57
- message = if mode == :cpu_bound
58
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
59
- "which has no dedicated executor. Falling back to blocking_io " \
60
- "(BlockingAdapterPool). Use :blocking_io explicitly to suppress this warning."
61
- else
62
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
63
- "which has no dedicated process manager. Falling back to blocking_io " \
64
- "(BlockingAdapterPool)."
65
- end
66
- if Phronomy.configuration.logger
67
- Phronomy.configuration.logger.warn(message)
68
- else
69
- warn message
70
- end
25
+ case mode
26
+ when :cooperative
27
+ task = Phronomy::Task.deferred(name: "tool-#{tool.name}")
28
+ begin
29
+ task.complete(
30
+ tool.call(args, cancellation_token: cancellation_token)
31
+ )
32
+ rescue => error
33
+ task.fail(error)
71
34
  end
72
- mode = :blocking_io
73
- end
74
-
75
- pool = begin
76
- runtime&.blocking_io
77
- rescue
78
- nil
79
- end
80
-
81
- if mode == :cooperative || pool.nil?
82
- runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
83
- tool.call(args, cancellation_token: ct)
35
+ task
36
+ when :offloaded
37
+ runtime ||= Phronomy::Runtime.instance
38
+ runtime.offload.submit(
39
+ cancellation_token: cancellation_token,
40
+ on_full: on_full
41
+ ) do
42
+ tool.call(args, cancellation_token: cancellation_token)
84
43
  end
85
44
  else
86
- pool.submit(cancellation_token: ct) do
87
- tool.call(args, cancellation_token: ct)
88
- end
45
+ raise Phronomy::ConfigurationError,
46
+ "unknown Tool execution_mode: #{mode.inspect}"
89
47
  end
90
48
  end
91
49
  end
@@ -119,8 +119,7 @@ module Phronomy
119
119
  end
120
120
 
121
121
  if schema_error
122
- if @tool.class.respond_to?(:on_schema_error) &&
123
- @tool.class.on_schema_error == :raise
122
+ if @tool.class.respond_to?(:on_schema_error) && @tool.class.on_schema_error == :raise
124
123
  @error = Phronomy::ToolError.new(
125
124
  "#{@tool.class.name} schema error: #{schema_error}"
126
125
  )
@@ -141,64 +140,75 @@ module Phronomy
141
140
  self
142
141
  end
143
142
 
144
- 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
+
145
148
  pool = runtime.pool(
146
149
  :authorization,
147
150
  size: Phronomy.configuration.authorization_pool_size,
148
151
  queue_size: Phronomy.configuration.authorization_queue_size
149
152
  )
150
- timeout = @config.fetch(:authorization_timeout, Phronomy.configuration.authorization_timeout)
151
- pending = pool.submit(
153
+ timeout = @config.fetch(
154
+ :authorization_timeout,
155
+ Phronomy.configuration.authorization_timeout
156
+ )
157
+ operation = pool.submit(
152
158
  timeout: timeout,
153
159
  cancellation_token: @config[:cancellation_token],
154
160
  on_full: :raise
155
161
  ) { evaluate_authorization }
156
-
157
- task = Phronomy::Task.deferred(name: "tool-authorization:#{@tool_name}")
158
- pending.on_complete do |outcome, error|
159
- resolved = error ? authorization_failure_outcome(error) : outcome
160
- task.backend.unblock(resolved, nil)
161
- task.transition!(:completed, value: resolved)
162
+ operation.on_complete do |outcome, error|
163
+ callback.call(error ? authorization_failure_outcome(error) : outcome)
162
164
  end
163
- task
165
+ self
164
166
  rescue => error
165
- task = Phronomy::Task.deferred(name: "tool-authorization:#{@tool_name}")
166
- outcome = authorization_failure_outcome(error)
167
- task.backend.unblock(outcome, nil)
168
- task.transition!(:completed, value: outcome)
169
- task
167
+ callback.call(authorization_failure_outcome(error))
168
+ self
170
169
  end
171
170
 
172
- def execution_task(runtime: Phronomy::Runtime.instance)
173
- pending = Phronomy::Agent::ToolExecutor.call_invocation_async(
174
- tool_invocation: self,
175
- cancellation_token: @config[:cancellation_token],
176
- config: @config,
177
- runtime: runtime
178
- )
179
- task = Phronomy::Task.deferred(name: "tool-execution:#{@tool_name}")
180
- pending.on_complete do |result, error|
181
- outcome = if error
182
- ExecutionOutcome.new(
183
- error: error,
184
- cancelled: error.is_a?(Phronomy::CancellationError)
185
- )
186
- else
187
- ExecutionOutcome.new(result: result)
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
191
+
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"
188
198
  end
189
- task.backend.unblock(outcome, nil)
190
- task.transition!(:completed, value: outcome)
199
+
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
+ )))
191
207
  end
192
- task
208
+ self
193
209
  rescue => error
194
- task = Phronomy::Task.deferred(name: "tool-execution:#{@tool_name}")
195
- outcome = ExecutionOutcome.new(
196
- error: error,
197
- cancelled: error.is_a?(Phronomy::CancellationError)
198
- )
199
- task.backend.unblock(outcome, nil)
200
- task.transition!(:completed, value: outcome)
201
- task
210
+ callback.call(execution_outcome(nil, error))
211
+ self
202
212
  end
203
213
 
204
214
  def mark_awaiting_approval! = (@status = :awaiting_approval
@@ -256,6 +266,48 @@ module Phronomy
256
266
 
257
267
  private
258
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
+
259
311
  def evaluate_authorization
260
312
  request = build_request(facts: {}, default_decision: nil)
261
313
  facts = evaluate_facts
@@ -272,9 +324,7 @@ module Phronomy
272
324
  end
273
325
 
274
326
  reason = if decision == :require_approval
275
- (@origin == :mcp) ?
276
- "MCP Tool execution requires approval" :
277
- "Tool execution requires approval"
327
+ (@origin == :mcp) ? "MCP Tool execution requires approval" : "Tool execution requires approval"
278
328
  end
279
329
  AuthorizationOutcome.new(decision: decision, facts: facts, reason: reason)
280
330
  end