Class: Brute::Middleware::ToolCall
- Inherits:
-
Object
- Object
- Brute::Middleware::ToolCall
- Defined in:
- lib/brute/middleware/070_tool_call.rb
Overview
Executes pending tool calls from the LLM response.
Existing features (ref: opencode tool.ts wrap / truncate.ts):
-
Universal output truncation — after every tool.call(), pass the result string through Brute::Truncation.truncate() which enforces a 2000-line / 50 KB cap. This is a safety net so no single tool result can blow up the context window, regardless of whether the tool itself has internal limits.
-
Overflow to disk — when truncating, the full output is saved to a temp file under the truncation directory. The path is included in the truncated result with a hint.
-
Configurable limits — MAX_LINES / MAX_BYTES default to 2000 / 50 KB.
-
Skip truncation when tool already truncated — if the tool result already contains the truncation marker (e.g. Shell or FSSearch truncated internally), don’t double-truncate.
Concurrency model (Async)
Tool calls are executed concurrently using the ‘async` gem’s fiber-based scheduler. Each tool call is dispatched as an Async::Task inside an Async::Barrier, so all tools run in parallel and we wait for every task to complete before moving on.
Key design decisions:
-
Sync {} (not Async{}.wait) — reuses an existing event loop if one is already running, or creates one on demand. Blocks the caller until all inner work completes, which is what the middleware stack requires.
-
Async::Barrier — the idiomatic fan-out / join primitive. Each tool call becomes a child task via barrier.async; barrier.wait blocks until every task finishes. This is preferable to Async::Queue for a fixed batch of work with no producer/consumer relationship.
-
Deterministic result ordering — tool results are collected into an array during concurrent execution, then sorted back into the original tools_to_run key order before appending to env. This ensures the LLM always sees results in a stable order regardless of which tool finishes first.
-
Fiber-safe shared state — appending to the results array from multiple fibers is safe because Async fibers are cooperatively scheduled (only one fiber runs at a time within a Sync block). No mutex needed.
-
FileMutationQueue compatibility — tools that mutate files use Brute::Queue::FileMutationQueue.serialize, which uses Ruby 3.4’s fiber-scheduler-aware Mutex. Operations on the same file are serialized; operations on different files proceed in parallel.
Instance Method Summary collapse
- #call(env) ⇒ Object
-
#initialize(app) ⇒ ToolCall
constructor
A new instance of ToolCall.
Constructor Details
#initialize(app) ⇒ ToolCall
Returns a new instance of ToolCall.
62 63 64 |
# File 'lib/brute/middleware/070_tool_call.rb', line 62 def initialize(app) @app = app end |
Instance Method Details
#call(env) ⇒ Object
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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/brute/middleware/070_tool_call.rb', line 66 def call(env) @app.call(env) tools_to_run = pending_tool_calls(env[:messages].last) if tools_to_run.any? available_tools = resolve_tools(env[:tools]) env[:events] << on_tool_call_start_event(tools_to_run) results = [] Sync do = Async::Barrier.new tools_to_run.each do |id, tool_call| .async do tool = available_tools[tool_call.name.to_sym] result = tool.call(tool_call.arguments) # Coerce to String so RubyLLM::Message doesn't treat Hash results # (e.g. Shell's {stdout:, stderr:, exit_code:}) as attachments. content = result.is_a?(String) ? result : result.to_s # Universal truncation safety net — skip if already truncated unless Brute::Truncation.already_truncated?(content) content = Brute::Truncation.truncate(content) end results << [id, tool_call, content] rescue => e # Capture the error as a tool result so the LLM can see it # and reason about the failure, rather than crashing the # entire middleware chain. env[:events] << { type: :error, data: { error: e, message: e. } } results << [id, tool_call, "Error: #{e.class}: #{e.}"] end end .wait ensure &.cancel end # Append events and messages in the original tool_call order so the # LLM sees a deterministic sequence regardless of completion order. order = tools_to_run.keys results.sort_by! { |id, _, _| order.index(id) } results.each do |_id, tool_call, content| env[:events] << { type: :tool_result, data: { name: tool_call.name, content: content } } env[:messages] << RubyLLM::Message.new(role: :tool, content: content, tool_call_id: tool_call.id) end end return env end |