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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -1134
- data/README.md +68 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +35 -19
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +6 -6
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +5 -29
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +45 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +23 -31
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- 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
|
-
|
|
29
|
-
# Replace with application-owned asynchronous work.
|
|
30
|
-
100
|
|
31
|
-
end
|
|
38
|
+
operation = start_import_async
|
|
32
39
|
|
|
33
|
-
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
_check_event_loop_reentrancy(:approve, :approve_async)
|
|
102
102
|
approve_async(
|
|
103
103
|
execution_id,
|
|
104
104
|
approval_request_id: approval_request_id,
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -553,35 +553,20 @@ module Phronomy
|
|
|
553
553
|
[effective_thread_id, effective_config]
|
|
554
554
|
end
|
|
555
555
|
|
|
556
|
-
def
|
|
556
|
+
def _check_event_loop_reentrancy(sync_method, async_method)
|
|
557
557
|
if Phronomy::Runtime.instance.event_loop.current?
|
|
558
|
-
raise Phronomy::
|
|
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.
|
|
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.
|
|
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 :
|
|
128
|
+
return :offloaded
|
|
123
129
|
end
|
|
124
130
|
|
|
125
|
-
valid = %i[cooperative
|
|
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(
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
5
|
+
# Routes Tool work according to the Tool execution contract.
|
|
6
6
|
#
|
|
7
|
-
# Tool
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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:
|
|
20
|
+
runtime: nil,
|
|
21
|
+
on_full: :raise
|
|
49
22
|
)
|
|
50
|
-
ct = cancellation_token
|
|
51
23
|
mode = tool.class.execution_mode
|
|
52
24
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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(
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
self
|
|
164
166
|
rescue => error
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
208
|
+
self
|
|
193
209
|
rescue => error
|
|
194
|
-
|
|
195
|
-
|
|
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
|