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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalCase = Data.define(:input, :expected, :metadata) do
7
+ def initialize(input:, expected:, metadata: {})
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
7
+ def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
8
+ super
9
+ end
10
+
11
+ def pass? = score >= 1.0
12
+ def scorer_error? = !error.nil?
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Metrics
7
+ def initialize(results)
8
+ @results = results
9
+ end
10
+
11
+ def pass_rate
12
+ return 0.0 if @results.empty?
13
+ @results.count(&:pass?).to_f / @results.size
14
+ end
15
+
16
+ def average_score
17
+ return 0.0 if @results.empty?
18
+ @results.sum(&:score) / @results.size
19
+ end
20
+
21
+ def total_usage
22
+ @results.map(&:usage).compact.reduce(Phronomy::TokenUsage.zero, :+)
23
+ end
24
+
25
+ def average_latency_ms
26
+ return 0.0 if @results.empty?
27
+ @results.sum(&:latency_ms).to_f / @results.size
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ total: @results.size,
33
+ pass_count: @results.count(&:pass?),
34
+ pass_rate: pass_rate,
35
+ average_score: average_score,
36
+ total_usage: total_usage.to_h,
37
+ average_latency_ms: average_latency_ms
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ # Sequential test-only evaluator. Concurrency belongs to the subsystem
7
+ # under test, not to the evaluation helper itself.
8
+ class Runner
9
+ def initialize(scorer: Scorer::ExactMatch.new)
10
+ @scorer = scorer
11
+ end
12
+
13
+ def run(dataset, callable)
14
+ dataset.to_a.map { |eval_case| run_one(eval_case, callable) }
15
+ end
16
+
17
+ private
18
+
19
+ def run_one(eval_case, callable)
20
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
21
+ result = callable.call(eval_case.input)
22
+ latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at
23
+ actual, usage = extract(result)
24
+ score, score_error = score_safely(
25
+ @scorer,
26
+ actual: actual,
27
+ expected: eval_case.expected,
28
+ input: eval_case.input
29
+ )
30
+ EvalResult.new(
31
+ eval_case: eval_case,
32
+ actual: actual,
33
+ score: score,
34
+ usage: usage,
35
+ latency_ms: latency_ms,
36
+ error: score_error
37
+ )
38
+ end
39
+
40
+ def extract(result)
41
+ result.is_a?(Hash) ? [result[:output].to_s, result[:usage]] : [result.to_s, nil]
42
+ end
43
+
44
+ def score_safely(scorer, **kwargs)
45
+ [scorer.score(**kwargs), nil]
46
+ rescue => error
47
+ [0.0, error]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class Base
8
+ def score(actual:, expected:, input: nil)
9
+ raise NotImplementedError, "#{self.class}#score is not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class ExactMatch < Base
8
+ def initialize(case_sensitive: true)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s.strip
14
+ expected_value = expected.to_s.strip
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ (actual_value == expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class IncludesScorer < Base
8
+ def initialize(case_sensitive: false)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s
14
+ expected_value = expected.to_s
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ actual_value.include?(expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class LlmJudge < Base
8
+ DEFAULT_PROMPT = <<~PROMPT
9
+ You are an impartial judge evaluating the quality of an AI assistant response.
10
+ Rate the response on a scale from 0.0 (completely wrong or unhelpful) to 1.0 (perfect).
11
+ Respond with ONLY a single decimal number between 0.0 and 1.0 — no other text.
12
+
13
+ Question: %<input>s
14
+ Expected answer: %<expected>s
15
+ Actual response: %<actual>s
16
+
17
+ Score:
18
+ PROMPT
19
+
20
+ def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
21
+ @model = model
22
+ @prompt_template = prompt_template
23
+ @raise_on_error = raise_on_error
24
+ end
25
+
26
+ def score(actual:, expected:, input: nil)
27
+ prompt = format(
28
+ @prompt_template,
29
+ input: input.to_s,
30
+ expected: expected.to_s,
31
+ actual: actual.to_s
32
+ )
33
+ response = Phronomy::Runtime.instance.offload.submit do
34
+ RubyLLM.chat(model: @model).ask(prompt)
35
+ end.blocking_wait
36
+ response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
37
+ rescue => error
38
+ raise if @raise_on_error
39
+ warn "[LlmJudge] Scoring failed: #{error.message}"
40
+ 0.0
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # Test-only evaluation helpers.
6
+ module Eval
7
+ end
8
+ end
9
+ end
@@ -2,32 +2,16 @@
2
2
 
3
3
  module Phronomy
4
4
  module Testing
5
- # A deterministic, manually-advanced clock for use in tests.
6
- #
7
- # Replaces real +Process.clock_gettime+ calls so that time-sensitive code
8
- # can be tested without relying on wall-clock sleeps.
9
- #
10
- # @example
11
- # clock = Phronomy::Testing::FakeClock.new
12
- # clock.now # => 0.0
13
- # clock.advance(5) # advance by 5 seconds
14
- # clock.now # => 5.0
5
+ # Deterministic manually-advanced clock for tests.
15
6
  class FakeClock
16
- # @return [Float] the current logical time in seconds since the epoch (t=0)
17
7
  attr_reader :now
18
8
 
19
9
  def initialize
20
10
  @now = 0.0
21
- @callbacks = [] # [[fire_at, block], ...]
11
+ @callbacks = []
22
12
  @mutex = Mutex.new
23
13
  end
24
14
 
25
- # Advance the clock by +seconds+ and fire any registered callbacks whose
26
- # deadline has passed.
27
- #
28
- # @param seconds [Numeric]
29
- # @return [self]
30
- # @api private
31
15
  def advance(seconds)
32
16
  @mutex.synchronize do
33
17
  @now += seconds.to_f
@@ -36,65 +20,34 @@ module Phronomy
36
20
  self
37
21
  end
38
22
 
39
- # Register a one-shot callback that fires when the clock reaches +at+.
40
- #
41
- # @param at [Numeric] logical time to fire
42
- # @yield called with no arguments when the clock reaches +at+
43
- # @return [self]
44
- # @api private
45
23
  def at(at, &block)
46
24
  @mutex.synchronize { @callbacks << [at.to_f, block] }
47
25
  self
48
26
  end
49
27
 
50
- # Schedule a one-shot callback to fire after +seconds+ from the current
51
- # logical time. This is the same interface as {Runtime::TimerQueue#schedule}
52
- # so that a +FakeClock+ can be passed as a +timer_queue:+ argument in tests.
53
- #
54
- # @param seconds [Numeric] delay in logical seconds
55
- # @yield called when the clock reaches the scheduled time
56
- # @return [self]
57
- # @api private
58
28
  def schedule(seconds:, &block)
59
29
  at(@now + seconds.to_f, &block)
60
30
  end
61
31
 
62
- # Returns the number of pending (un-fired) callbacks.
63
- # @return [Integer]
64
- # @api private
65
32
  def pending_callbacks
66
33
  @mutex.synchronize { @callbacks.size }
67
34
  end
68
35
 
69
- # Returns the logical time of the next pending callback, or +nil+ if
70
- # there are no pending callbacks.
71
- #
72
- # @return [Float, nil]
73
- # @api private
74
36
  def next_timer_at
75
37
  @mutex.synchronize { @callbacks.min_by { |(t, _)| t }&.first }
76
38
  end
77
39
 
78
- # Advance the clock exactly to the next pending callback and fire it.
79
- # Raises +RuntimeError+ when there are no pending callbacks.
80
- #
81
- # @return [self]
82
- # @api private
83
40
  def advance_to_next_timer
84
41
  target = next_timer_at
85
42
  raise "No pending timers to advance to" unless target
86
-
87
43
  advance(target - @now)
88
44
  end
89
45
 
90
- # Returns descriptive entries for all pending callbacks.
91
- # Used by {Phronomy::Runtime::FakeScheduler#pending_timers}.
92
- #
93
- # @return [Array<Hash>] each entry: +{ fire_at:, description: nil }+
94
- # @api private
95
46
  def pending_timer_entries
96
47
  @mutex.synchronize do
97
- @callbacks.sort_by { |(t, _)| t }.map { |(t, _)| {fire_at: t, description: nil} }
48
+ @callbacks.sort_by { |(t, _)| t }.map do |(time, _)|
49
+ {fire_at: time, description: nil}
50
+ end
98
51
  end
99
52
  end
100
53
 
@@ -102,7 +55,7 @@ module Phronomy
102
55
 
103
56
  def fire_expired_callbacks!
104
57
  fired, @callbacks = @callbacks.partition { |(t, _)| t <= @now }
105
- fired.sort_by { |(t, _)| t }.each { |(_, cb)| cb.call }
58
+ fired.sort_by { |(t, _)| t }.each { |(_, callback)| callback.call }
106
59
  end
107
60
  end
108
61
  end
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Test helpers for deterministic, timer-independent testing.
5
- #
6
- # @example
7
- # require "phronomy/testing"
8
- # clock = Phronomy::Testing::FakeClock.new
9
- # scheduler = Phronomy::Testing::FakeScheduler.new
4
+ # Test-only helpers. Nothing under this namespace participates in the
5
+ # production Runtime control plane.
10
6
  module Testing
11
7
  end
12
8
  end
@@ -2,11 +2,17 @@
2
2
 
3
3
  module Phronomy
4
4
  module Tools
5
- # Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
6
- # agent can delegate a one-shot sub-task through the same stateful pipeline.
5
+ # Wraps a Phronomy::Agent::Base subclass as a callable Tool.
6
+ #
7
+ # Agent-backed Tools are logically asynchronous rather than offloaded
8
+ # synchronous operations. Their ToolInvocation starts the child Agent and then
9
+ # returns to EventLoop immediately; the Tool completion handle settles when the
10
+ # child Agent FSMSession finishes. An OffloadPool worker is therefore never
11
+ # consumed merely to wait for another Agent.
7
12
  class Agent < Phronomy::Agent::Context::Capability::Base
8
- description "Wraps an agent as a tool"
9
- param :input, type: :string, desc: "The input to forward to the wrapped agent"
13
+ execution_mode :cooperative
14
+ description "Wraps a Phronomy::Agent as a Tool"
15
+ param :input, type: :string, desc: "The input to forward to the wrapped Agent"
10
16
 
11
17
  class << self
12
18
  def from_agent(agent_class, tool_name: nil, description: nil)
@@ -25,13 +31,38 @@ module Phronomy
25
31
 
26
32
  klass.tool_name(effective_name)
27
33
  klass.description(effective_desc)
28
- klass.define_method(:execute) do |input:|
34
+
35
+ # Preserve the synchronous Tool API for top-level callers. ToolInvocation
36
+ # never uses this path for Agent-backed Tools; it calls #call_async.
37
+ klass.define_method(:execute) do |input:, cancellation_token: nil|
38
+ invoke_options = {}
39
+ if cancellation_token
40
+ invoke_options[:config] = {cancellation_token: cancellation_token}
41
+ end
29
42
  result = Phronomy::Agent.run_once(
30
43
  definition: agent_class,
31
- input: input
44
+ input: input,
45
+ **invoke_options
32
46
  )
33
47
  result[:output].to_s
34
48
  end
49
+
50
+ # Internal asynchronous execution protocol used by Agent#call_async.
51
+ # The child Agent owns its own FSMSession/EventLoop lifecycle; this
52
+ # method only returns its completion handle and performs a short map.
53
+ klass.define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
54
+ persistence = Phronomy::Persistence::InMemory.new
55
+ agent = agent_class.create(persistence: persistence)
56
+ task_config = (config || {}).dup
57
+ if cancellation_token && !task_config[:cancellation_token]
58
+ task_config[:cancellation_token] = cancellation_token
59
+ end
60
+
61
+ agent.invoke_async(input, config: task_config).map do |result|
62
+ result[:output].to_s
63
+ end
64
+ end
65
+ klass.send(:private, :execute_async)
35
66
  klass
36
67
  end
37
68
 
@@ -49,6 +80,110 @@ module Phronomy
49
80
  .sub(/_tool$/, "")
50
81
  end
51
82
  end
83
+
84
+ # Agent-backed Tools have an asynchronous implementation that does not use
85
+ # ToolExecutor/OffloadPool. Validation and Tool error policy still match
86
+ # Capability::Base#call.
87
+ def call_async(
88
+ args,
89
+ cancellation_token: nil,
90
+ config: {}
91
+ )
92
+ cancellation_token&.raise_if_cancelled!
93
+ validated_args, schema_error = send(:validate_and_coerce, args)
94
+
95
+ if schema_error
96
+ return schema_error_task(schema_error)
97
+ end
98
+
99
+ source = execute_async(
100
+ **(validated_args || {}),
101
+ cancellation_token: cancellation_token,
102
+ config: config || {}
103
+ )
104
+ unless source.respond_to?(:on_complete)
105
+ raise Phronomy::ToolError,
106
+ "#{self.class.name} asynchronous execution must return a completion handle"
107
+ end
108
+
109
+ result_task = Phronomy::Task.deferred(name: "agent-tool-#{name}")
110
+ source.on_complete do |result, error|
111
+ if error
112
+ settle_async_error(result_task, error)
113
+ next
114
+ end
115
+
116
+ begin
117
+ result_task.complete(send(:truncate_result_if_needed, result))
118
+ rescue => result_error
119
+ settle_async_error(result_task, result_error)
120
+ end
121
+ end
122
+ result_task
123
+ rescue Phronomy::ToolError, Phronomy::CancellationError => error
124
+ failed_task(error)
125
+ rescue => error
126
+ result_task = Phronomy::Task.deferred(name: "agent-tool-#{name}")
127
+ settle_async_error(result_task, error)
128
+ result_task
129
+ end
130
+
131
+ private
132
+
133
+ # Subclasses created by .from_agent and Orchestrator override this method.
134
+ # It deliberately remains private so it is not part of the public Tool API.
135
+ def execute_async(input:, cancellation_token: nil, config: {})
136
+ task = Phronomy::Task.deferred(name: "agent-tool-#{name}-fallback")
137
+ begin
138
+ task.complete(execute(input: input, cancellation_token: cancellation_token))
139
+ rescue => error
140
+ task.fail(error)
141
+ end
142
+ task
143
+ end
144
+
145
+ def schema_error_task(schema_error)
146
+ task = Phronomy::Task.deferred(name: "agent-tool-#{name}-schema")
147
+ if self.class.on_schema_error == :raise
148
+ task.fail(Phronomy::ToolError.new(
149
+ "#{self.class.name} schema error: #{schema_error}"
150
+ ))
151
+ else
152
+ task.complete("Schema validation failed: #{schema_error}")
153
+ end
154
+ task
155
+ end
156
+
157
+ def failed_task(error)
158
+ Phronomy::Task.deferred(name: "agent-tool-#{name}-failed").tap do |task|
159
+ task.fail(error)
160
+ end
161
+ end
162
+
163
+ def settle_async_error(task, error)
164
+ if error.is_a?(Phronomy::ToolError) || error.is_a?(Phronomy::CancellationError)
165
+ task.fail(error)
166
+ return task
167
+ end
168
+
169
+ if self.class.on_error == :suppress
170
+ message = "[Phronomy] Tool #{self.class.name} suppressed error: " \
171
+ "#{error.class}: #{error.message}"
172
+ if Phronomy.configuration.logger
173
+ Phronomy.configuration.logger.warn(message)
174
+ else
175
+ warn message
176
+ end
177
+ task.complete("Tool error suppressed: #{error.message}")
178
+ else
179
+ wrapped = Phronomy::ToolError.new(
180
+ "#{self.class.name} execution failed: #{error.message}"
181
+ )
182
+ wrapped.set_backtrace(error.backtrace)
183
+ task.fail(wrapped)
184
+ end
185
+ task
186
+ end
52
187
  end
53
188
  end
54
189
  end
@@ -7,7 +7,7 @@ module Phronomy
7
7
  # Mixing this module into a VectorStore class provides three choices:
8
8
  #
9
9
  # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
- # that route through {BlockingAdapterPool} (the previous behaviour).
10
+ # that route through {OffloadPool}.
11
11
  #
12
12
  # 2. **Override selectively** — override only the async methods where the
13
13
  # backend has a native async driver, while the remaining methods fall back
@@ -30,7 +30,7 @@ module Phronomy
30
30
  module AsyncBackend
31
31
  # Async variant of {VectorStore::Base#add}.
32
32
  #
33
- # Submits the add call to {BlockingAdapterPool} by default.
33
+ # Submits the add call to {OffloadPool} by default.
34
34
  # Override to use a native async driver.
35
35
  #
36
36
  # @param id [String]
@@ -38,12 +38,13 @@ module Phronomy
38
38
  # @param metadata [Hash]
39
39
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
40
40
  # @param timeout [Numeric, nil]
41
- # @return [BlockingAdapterPool::PendingOperation]
41
+ # @return [OffloadPool::PendingOperation]
42
42
  # @api public
43
43
  def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
- Phronomy::Runtime.instance.blocking_io.submit(
44
+ Phronomy::Runtime.instance.offload.submit(
45
45
  timeout: timeout,
46
- cancellation_token: cancellation_token
46
+ cancellation_token: cancellation_token,
47
+ on_full: :raise
47
48
  ) do
48
49
  add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
50
  end
@@ -51,19 +52,20 @@ module Phronomy
51
52
 
52
53
  # Async variant of {VectorStore::Base#search}.
53
54
  #
54
- # Submits the search call to {BlockingAdapterPool} by default.
55
+ # Submits the search call to {OffloadPool} by default.
55
56
  # Override to use a native async driver.
56
57
  #
57
58
  # @param query_embedding [Array<Float>]
58
59
  # @param k [Integer]
59
60
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
60
61
  # @param timeout [Numeric, nil]
61
- # @return [BlockingAdapterPool::PendingOperation]
62
+ # @return [OffloadPool::PendingOperation]
62
63
  # @api public
63
64
  def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
- Phronomy::Runtime.instance.blocking_io.submit(
65
+ Phronomy::Runtime.instance.offload.submit(
65
66
  timeout: timeout,
66
- cancellation_token: cancellation_token
67
+ cancellation_token: cancellation_token,
68
+ on_full: :raise
67
69
  ) do
68
70
  search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
71
  end
@@ -71,18 +73,19 @@ module Phronomy
71
73
 
72
74
  # Async variant of {VectorStore::Base#remove}.
73
75
  #
74
- # Submits the remove call to {BlockingAdapterPool} by default.
76
+ # Submits the remove call to {OffloadPool} by default.
75
77
  # Override to use a native async driver.
76
78
  #
77
79
  # @param id [String]
78
80
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
79
81
  # @param timeout [Numeric, nil]
80
- # @return [BlockingAdapterPool::PendingOperation]
82
+ # @return [OffloadPool::PendingOperation]
81
83
  # @api public
82
84
  def remove_async(id:, cancellation_token: nil, timeout: nil)
83
- Phronomy::Runtime.instance.blocking_io.submit(
85
+ Phronomy::Runtime.instance.offload.submit(
84
86
  timeout: timeout,
85
- cancellation_token: cancellation_token
87
+ cancellation_token: cancellation_token,
88
+ on_full: :raise
86
89
  ) do
87
90
  remove(id: id)
88
91
  end
@@ -90,17 +93,18 @@ module Phronomy
90
93
 
91
94
  # Async variant of {VectorStore::Base#clear}.
92
95
  #
93
- # Submits the clear call to {BlockingAdapterPool} by default.
96
+ # Submits the clear call to {OffloadPool} by default.
94
97
  # Override to use a native async driver.
95
98
  #
96
99
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
97
100
  # @param timeout [Numeric, nil]
98
- # @return [BlockingAdapterPool::PendingOperation]
101
+ # @return [OffloadPool::PendingOperation]
99
102
  # @api public
100
103
  def clear_async(cancellation_token: nil, timeout: nil)
101
- Phronomy::Runtime.instance.blocking_io.submit(
104
+ Phronomy::Runtime.instance.offload.submit(
102
105
  timeout: timeout,
103
- cancellation_token: cancellation_token
106
+ cancellation_token: cancellation_token,
107
+ on_full: :raise
104
108
  ) do
105
109
  clear
106
110
  end
@@ -9,8 +9,8 @@ module Phronomy
9
9
  #
10
10
  # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
11
11
  # are provided by the {AsyncBackend} mixin which defaults to routing calls
12
- # through {BlockingAdapterPool}. Backends with native async drivers may
13
- # override individual async methods without touching the pool at all.
12
+ # through {OffloadPool}. Backends with native async drivers may override
13
+ # individual async methods without touching the pool at all.
14
14
  class Base
15
15
  include AsyncBackend
16
16