Module: Rubino::LLM::ToolBridge
- Defined in:
- lib/rubino/llm/tool_bridge.rb
Overview
Wraps a Rubino::Tools::Base instance into a RubyLLM::Tool subclass so that ruby_llm can register it, serialize its schema to the LLM, and dispatch tool calls through our full execution pipeline.
When a ToolExecutor is provided (always the case in production), tool execution goes through:
ApprovalPolicy → tool.call() → truncation → ToolCallRepository.record
This ensures identical behavior regardless of LLM provider — there is now a single tool-execution path in the entire application.
Constant Summary collapse
- CACHE_CONTROL_PROVIDER_PARAMS =
Returns a RubyLLM::Tool instance wrapping agent_tool.
call_id_provider: a 0-arity callable returning the id of the tool_call ruby_llm is about to dispatch (captured by the adapter’s before_tool_call callback). Threaded through so the STREAMING path populates the same call_id the non-streaming Loop#execute_tool_calls already passes — which is what makes spill_full_output fire and the messages.tool_call_id / tool_calls metadata persist (STRM-2). nil in the test/one-shot fallback path, where the bridge calls tool.call directly and no real id exists. Anthropic prompt-cache breakpoint (#311) placed on the LAST tool’s wire definition. RubyLLM’s Anthropic::Tools.function_for deep_merges a tool’s provider_params onto its wire def, and Anthropic caches the WHOLE tool block up to and including the breakpoint — so one cache_control on the final tool caches every tool definition.
{ cache_control: { type: "ephemeral" } }.freeze
Class Method Summary collapse
-
.bridge_class_for(tool_name) ⇒ Object
rubocop:enable Metrics/ParameterLists.
-
.build_class(tool_name) ⇒ Object
rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity.
-
.for(agent_tool, ui: nil, event_bus: nil, tool_executor: nil, call_id_provider: nil, cache_breakpoint: false, budget_exhausted: nil, cancel_token: nil, error_marker: false) ⇒ Object
rubocop:disable Metrics/ParameterLists.
-
.install(chat, tools, ui: nil, event_bus: nil, tool_executor: nil, cache_tools: false, budget_exhausted: nil, production: nil, cancel_token: nil, error_marker: false) ⇒ Object
Registers every Rubino tool (wrapped as a bridge) on a ruby_llm chat AND wires the call-id capture the streaming path needs.
- .result_from_tool_output(name, output) ⇒ Object
-
.tool_result_payload(result, call_id, error_marker) ⇒ Object
The value handed back to ruby_llm for a tool that ran MID-STREAM (#583).
Class Method Details
.bridge_class_for(tool_name) ⇒ Object
rubocop:enable Metrics/ParameterLists
97 98 99 100 |
# File 'lib/rubino/llm/tool_bridge.rb', line 97 def self.bridge_class_for(tool_name) @cache ||= {} @cache[tool_name] ||= build_class(tool_name) end |
.build_class(tool_name) ⇒ Object
rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 |
# File 'lib/rubino/llm/tool_bridge.rb', line 132 def self.build_class(tool_name) klass = Class.new(::RubyLLM::Tool) do define_method(:name) { tool_name } define_method(:initialize) do |agent_tool, ui:, event_bus:, tool_executor:, call_id_provider: nil, cache_breakpoint: false, budget_exhausted: nil, cancel_token: nil, error_marker: false| @agent_tool = agent_tool @ui = ui @event_bus = event_bus @tool_executor = tool_executor @call_id_provider = call_id_provider @cache_breakpoint = cache_breakpoint # #583: when true (anthropic-family path), a denied/errored result # run MID-STREAM is handed back to ruby_llm as a typed-error # tool_result (Content::Raw with is_error:true) instead of a plain # string, so the model can't read the denial as an ordinary result # and fabricate an answer. The non-streaming Loop path does the same # via RubyLLMAdapter#build_tool_message on the next turn's history. @error_marker = error_marker # 0-arity predicate the Loop wires so a tool dispatched mid-stream can # be HALTED once the per-turn iteration/time budget is spent (#355a). @budget_exhausted = budget_exhausted # The turn's CancelToken, checked at the tool-dispatch boundary so a # user interrupt that lands between a tool returning and ruby_llm # issuing the NEXT round-trip request unwinds cleanly instead of # sending a malformed continuation (#48) / lagging the unwind (#52). @cancel_token = cancel_token end define_method(:description) { @agent_tool.description } define_method(:params_schema) { @agent_tool.input_schema } # PER-INSTANCE provider_params (#311). RubyLLM::Tool#provider_params # is normally class-level, but bridge classes are CACHED and shared # across tools/turns — a class-level write would leak the breakpoint # onto every tool of that name. Overriding per instance keeps the # cache_control on exactly the one final tool the installer marked. define_method(:provider_params) do @cache_breakpoint ? Rubino::LLM::ToolBridge::CACHE_CONTROL_PROVIDER_PARAMS : {} end define_method(:execute) do |**kwargs| name = @agent_tool.name args = kwargs.transform_keys(&:to_s) # Tool-dispatch BOUNDARY interrupt (#48/#52). ruby_llm runs the whole # model↔tool loop inside one ask(); the only in-loop cancel poll is the # per-chunk check in the streaming callback. If the user hits Esc in # the window between a tool RETURNING and ruby_llm issuing the next # round-trip request, that poll never runs — ruby_llm sends a # continuation the provider rejects ("invalid params"), and the unwind # lags until the post-cancel stream settles. Checking the token here — # before AND after each mid-stream dispatch — raises Interrupted on the # streaming thread at the boundary, so the clean `⎿ interrupted` path # runs PROMPTLY and no malformed request is ever sent. @cancel_token&.check! # Budget-exhausted graceful abort (#355a). ruby_llm runs the whole # model↔tool loop inside one ask(); the Loop can't re-check its budget # between the intermediate round-trips. So before running THIS tool, # consult the per-turn predicate the Loop wired: when the iteration/ # time budget is spent, DON'T execute — return RubyLLM::Tool::Halt, # which makes Chat#handle_tool_calls stop recursing (it sets # halt_result and returns) after adding a valid trailing tool message # (no orphaned tool_use). Control returns to the Loop, which runs its # existing budget-exhausted summary. The Halt content is the same # MAX_ITERATIONS nudge the non-streaming path uses, so the model is # told why it was cut off if the Loop chooses to surface it. if @budget_exhausted&.call return ::RubyLLM::Tool::Halt.new(Rubino::Agent::Loop::MAX_ITERATIONS_SUMMARY_NUDGE) end output = if @tool_executor # Full pipeline: approval check → tool.call → truncation → audit record. # Thread the real tool_call id (captured by the adapter's # before_tool_call callback) so the streaming path populates the # spill file + tool_call_id/tool_calls linkage exactly like the # non-streaming Loop#execute_tool_calls (STRM-2). call_id = @call_id_provider&.call result = @tool_executor.execute( name: name, arguments: args, call_id: call_id ) Rubino::LLM::ToolBridge.tool_result_payload(result, call_id, @error_marker) else # Fallback: direct call (tests / one-shot mode without full Lifecycle) @event_bus&.emit(Rubino::Interaction::Events::TOOL_STARTED, name: name) @ui&.tool_started(name, arguments: args) begin raw = @agent_tool.call(args) result = Rubino::LLM::ToolBridge.result_from_tool_output(name, raw) @event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name) @ui&.tool_finished(name, result: result) result.output rescue StandardError => e @event_bus&.emit(Rubino::Interaction::Events::TOOL_FINISHED, name: name) @ui&.tool_finished(name) "Error: #{e.}" end end # Post-return boundary: the tool finished cleanly, but if the user hit # Esc WHILE it ran (e.g. a long shell command that SIGTERM-settled), # ruby_llm is about to issue the next round-trip. Re-check the token so # the interrupt unwinds here instead of riding out the doomed request. @cancel_token&.check! output end end const_name = "Bridge_#{tool_name.gsub(/[^a-zA-Z0-9]/, "_")}" unless Rubino::LLM::ToolBridge.const_defined?(const_name, false) Rubino::LLM::ToolBridge.const_set(const_name, klass) end klass end |
.for(agent_tool, ui: nil, event_bus: nil, tool_executor: nil, call_id_provider: nil, cache_breakpoint: false, budget_exhausted: nil, cancel_token: nil, error_marker: false) ⇒ Object
rubocop:disable Metrics/ParameterLists
36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/rubino/llm/tool_bridge.rb', line 36 def self.for(agent_tool, ui: nil, event_bus: nil, tool_executor: nil, call_id_provider: nil, cache_breakpoint: false, budget_exhausted: nil, cancel_token: nil, error_marker: false) klass = bridge_class_for(agent_tool.name) klass.new(agent_tool, ui: ui || Rubino.ui, event_bus: event_bus || Rubino.event_bus, tool_executor: tool_executor, call_id_provider: call_id_provider, cache_breakpoint: cache_breakpoint, budget_exhausted: budget_exhausted, cancel_token: cancel_token, error_marker: error_marker) end |
.install(chat, tools, ui: nil, event_bus: nil, tool_executor: nil, cache_tools: false, budget_exhausted: nil, production: nil, cancel_token: nil, error_marker: false) ⇒ Object
Registers every Rubino tool (wrapped as a bridge) on a ruby_llm chat AND wires the call-id capture the streaming path needs. ruby_llm hands the bridge only the parsed arguments (Tool#call(args)), not the tool_call object — so we latch the id from the before_tool_call callback (fired right before each sequential, tool_concurrency=false dispatch) into a holder the bridge reads back as call_id. Without this the streaming path has no id and spill_full_output / messages.tool_call_id die (STRM-2). rubocop:disable Metrics/ParameterLists
59 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 |
# File 'lib/rubino/llm/tool_bridge.rb', line 59 def self.install(chat, tools, ui: nil, event_bus: nil, tool_executor: nil, cache_tools: false, budget_exhausted: nil, production: nil, cancel_token: nil, error_marker: false) list = Array(tools) # Security invariant (#355 defensive): approval + audit only fire when a # ToolExecutor is wired — the nil fallback path calls the tool DIRECTLY, # bypassing ApprovalPolicy#decide and record_audit. That fallback exists # ONLY for unit/one-shot doubles. The production wiring (the adapter built # by AdapterFactory) ALWAYS passes a tool_executor. Guard it so a future # refactor can't silently install the unguarded bridge on a real run: # when explicitly told this is a production install AND there are tools to # install, a nil executor is a hard error rather than a silent # approval/audit bypass. (No tools ⇒ nothing to guard, e.g. a probe.) if production && tool_executor.nil? && !list.empty? raise Rubino::Error, "ToolBridge.install: refusing to install tools on a production path " \ "without a tool_executor — approval and audit would be bypassed." end current_call_id = nil chat.before_tool_call { |tc| current_call_id = tc&.id } if chat.respond_to?(:before_tool_call) # #311: cache the whole tool block by putting a single cache_control # breakpoint on the LAST tool. Tools arrive in the registry's # deterministic insertion order (register_defaults!), so "last" is # stable across turns — the cache key over the tool block holds. last_index = list.size - 1 list.each_with_index do |tool, idx| chat.with_tool(self.for(tool, ui: ui, event_bus: event_bus, tool_executor: tool_executor, call_id_provider: -> { current_call_id }, cache_breakpoint: cache_tools && idx == last_index, budget_exhausted: budget_exhausted, cancel_token: cancel_token, error_marker: error_marker)) end end |
.result_from_tool_output(name, output) ⇒ Object
102 103 104 105 106 |
# File 'lib/rubino/llm/tool_bridge.rb', line 102 def self.result_from_tool_output(name, output) return output if output.is_a?(Rubino::Tools::Result) Rubino::Tools::Result.success(name: name, call_id: nil, output: output.to_s) end |
.tool_result_payload(result, call_id, error_marker) ⇒ Object
The value handed back to ruby_llm for a tool that ran MID-STREAM (#583). A SUCCESS is the plain output string — byte-identical to before, so a passing tool’s result is unchanged. A denied/errored result on the anthropic-family path (error_marker true) is wrapped as a typed-error tool_result block (Content::Raw → Anthropic is_error:true), so the model sees it as an error it must not confabulate over, not as a normal result. Without a tool_use_id (the test/one-shot fallback has none) or off the anthropic path, fall back to the plain string + stronger wording.
116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/rubino/llm/tool_bridge.rb', line 116 def self.tool_result_payload(result, call_id, error_marker) output = result.output errored = (result.respond_to?(:denied?) && result.denied?) || (result.respond_to?(:errorish?) && result.errorish?) return output unless error_marker && errored && call_id block = { type: "tool_result", tool_use_id: call_id, content: output.to_s, is_error: true } ::RubyLLM::Content::Raw.new([block]) end |