Class: Rubino::Agent::ToolExecutor

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/agent/tool_executor.rb

Overview

Executes tool calls with approval checks and result formatting.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(registry:, approval_policy:, ui:, config:, tool_call_repository: Tools::ToolCallRepository.new, cancel_token: nil, read_tracker: nil, event_bus: nil, on_result: nil) ⇒ ToolExecutor

Returns a new instance of ToolExecutor.



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# File 'lib/rubino/agent/tool_executor.rb', line 12

def initialize(registry:, approval_policy:, ui:, config:,
               tool_call_repository: Tools::ToolCallRepository.new,
               cancel_token: nil, read_tracker: nil, event_bus: nil,
               on_result: nil)
  @registry             = registry
  @approval_policy      = approval_policy
  @ui                   = ui
  @config               = config
  @tool_call_repository = tool_call_repository
  @cancel_token         = cancel_token
  # Optional sink the Loop registers so a tool that runs on the STREAMING
  # path (ruby_llm dispatches it mid-stream via ToolBridge → straight into
  # #execute, never returning through Loop#execute_tool_calls) is still
  # counted in the turn summary and persisted as a `tool` message. Called
  # once per completed/denied tool with (name:, arguments:, call_id:,
  # result:). The non-streaming path routes through the same sink so the
  # count/persist happens in exactly one place regardless of mode.
  @on_result            = on_result
  # Optional event bus so this executor emits TOOL_STARTED/TOOL_FINISHED
  # for the API mode timeline. ToolBridge already emits these when no
  # executor is wired (test/one-shot path); the production path went
  # through here and dropped them, so the web UI timeline never saw
  # the tool call as a discrete event.
  @event_bus            = event_bus
  # One tracker shared across every tool call so the read registered by
  # ReadTool is visible to a later EditTool. The production path
  # (Interaction::Lifecycle) injects the SESSION-scoped tracker so the
  # gate spans turns (#151). Default to a fresh tracker if the caller
  # didn't supply one; an isolated unit test can pass
  # `read_tracker: nil` to skip the gate.
  @read_tracker         = read_tracker.equal?(false) ? nil : (read_tracker || Tools::ReadTracker.new)
end

Instance Attribute Details

#on_result=(value) ⇒ Object (writeonly)

The Loop registers its count+persist sink here after construction (the executor is built first so the adapter/ToolBridge can share it). See Loop#handle_tool_result.



10
11
12
# File 'lib/rubino/agent/tool_executor.rb', line 10

def on_result=(value)
  @on_result = value
end

Instance Method Details

#execute(name:, arguments:, call_id:) ⇒ Object

Executes a single tool call, returns a Tools::Result.

Raises:



46
47
48
49
50
51
52
53
54
55
56
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
# File 'lib/rubino/agent/tool_executor.rb', line 46

def execute(name:, arguments:, call_id:)
  tool = @registry.find(name)
  raise ToolError, "Unknown tool: #{name}" unless tool

  case @approval_policy.decide(tool, arguments: arguments)
  when :deny
    # A policy denial must NOT read "denied by user" to the model — the
    # policy records why it fired (#last_deny_reason) and the Result
    # maps it to a reason-specific message, so a child agent never
    # blames the human for an automatic deny (#143).
    denied = Tools::Result.denied(name: name, call_id: call_id, reason: policy_deny_reason)
    record_denied(name: name, call_id: call_id, arguments: arguments,
                  result: denied, reason: "policy-denied")
    return finish(name, arguments, call_id, denied)
  when :ask
    unless request_approval(tool, arguments)
      denied = Tools::Result.denied(name: name, call_id: call_id, reason: :user)
      record_denied(name: name, call_id: call_id, arguments: arguments,
                    result: denied, reason: "user-denied")
      return finish(name, arguments, call_id, denied)
    end
  end

  notify_yolo_if_applicable(tool, arguments)
  emit_started(name, arguments)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  result = nil
  begin
    result = run_tool(tool, name: name, arguments: arguments, call_id: call_id)
  ensure
    duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round
    emit_artifact(result) if result.respond_to?(:artifact) && result&.artifact
    emit_finished(name, result: result, duration_ms: duration_ms, arguments: arguments)
  end
  finish(name, arguments, call_id, result)
end