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

Class Method Details

.bridge_class_for(tool_name) ⇒ Object

rubocop:enable Metrics/ParameterLists



93
94
95
96
# File 'lib/rubino/llm/tool_bridge.rb', line 93

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



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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
# File 'lib/rubino/llm/tool_bridge.rb', line 99

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|
      @agent_tool       = agent_tool
      @ui               = ui
      @event_bus        = event_bus
      @tool_executor    = tool_executor
      @call_id_provider = call_id_provider
      @cache_breakpoint = cache_breakpoint
      # 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
    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)

      # 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

      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
        )
        result.output
      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
          output = @agent_tool.call(args)
          result = Rubino::Tools::Result.success(
            name: name, call_id: nil, output: output.to_s
          )
          @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.message}"
        end
      end
    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) ⇒ Object

rubocop:disable Metrics/ParameterLists



36
37
38
39
40
41
42
43
44
45
46
# 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)
  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)
end

.install(chat, tools, ui: nil, event_bus: nil, tool_executor: nil, cache_tools: false, budget_exhausted: nil, production: nil) ⇒ 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



57
58
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
# File 'lib/rubino/llm/tool_bridge.rb', line 57

def self.install(chat, tools, ui: nil, event_bus: nil, tool_executor: nil, cache_tools: false,
                 budget_exhausted: nil, production: nil)
  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))
  end
end