Module: Phronomy::ToolExecutor Private

Defined in:
lib/phronomy/tool_executor.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

Note:

Non-goals ToolExecutor deliberately does NOT provide:

  • A CPU-bound process pool. CPU-intensive tool work must be handled at the application layer (e.g., fork, Sidekiq, separate OS processes). The framework will not add a +ProcessPoolExecutor+ equivalent.
  • An external process manager. Spawning or supervising subprocesses is out of scope for this module.
  • Additional core execution routes beyond scheduler-backed cooperative execution and BlockingAdapterPool-backed blocking I/O isolation. The +:cpu_bound+ and +:external_process+ modes are accepted for compatibility but both fall back to +:blocking_io+ routing with a one-time warning. If a genuinely new core execution route is needed, a new ADR is required. These non-goals follow from the cooperative-first, non-preemptive concurrency model (ADR-010): framework components must not assume the caller's concurrency model, and CPU/process management belongs to the application layer.

Centralises tool execution routing based on Phronomy::Tool::Base.execution_mode.

This is the single place in the framework that decides how a tool call is dispatched:

  • +:cooperative+ — dispatched via +Runtime#spawn+ through the configured scheduler. Under the +:fiber+ backend this avoids an extra OS thread; under the +:thread+ backend it is backed by +ThreadScheduler+ (one thread per task).
  • +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime provides a pool; falls back to +Runtime#spawn+ otherwise.
  • +:cpu_bound+ — emits a deprecation-style warning then falls back to +:blocking_io+ routing (no process pool available yet).
  • +:external_process+ — falls back to +:blocking_io+ routing (no process manager available yet).

All paths return an object that responds to +#await+ (+Phronomy::Task+ or +BlockingAdapterPool::PendingOperation+), so callers can collect results uniformly.

Class Method Summary collapse

Class Method Details

.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance) ⇒ #await

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Dispatches a single tool call asynchronously according to its +execution_mode+ and returns an awaitable.

Parameters:

Returns:



60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/phronomy/tool_executor.rb', line 60

def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
  ct = cancellation_token
  mode = tool.class.execution_mode

  # Warn and normalise unsupported modes to :blocking_io.
  # Each (tool class, mode) pair emits the warning at most once per process
  # lifetime to avoid log flooding in high-throughput scenarios.
  if mode == :cpu_bound || mode == :external_process
    warn_key = [tool.class.name, mode]
    newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
    if newly_warned
      msg = if mode == :cpu_bound
        "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
        "which has no dedicated executor. " \
        "Falling back to blocking_io (BlockingAdapterPool). " \
        "Use :blocking_io explicitly to suppress this warning."
      else
        "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
        "which has no dedicated process manager. " \
        "Falling back to blocking_io (BlockingAdapterPool)."
      end
      if Phronomy.configuration.logger
        Phronomy.configuration.logger.warn(msg)
      else
        warn msg
      end
    end
    mode = :blocking_io
  end

  pool = begin
    runtime&.blocking_io
  rescue
    nil
  end

  if mode == :cooperative || pool.nil?
    runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
      tool.call(args, cancellation_token: ct)
    end
  else
    # Submit directly to pool — no wrapping Task thread required.
    pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
  end
end