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, session_id: nil) ⇒ ToolExecutor

Returns a new instance of ToolExecutor.



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/rubino/agent/tool_executor.rb', line 22

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, session_id: nil)
  @registry             = registry
  @approval_policy      = approval_policy
  @ui                   = ui
  @config               = config
  @tool_call_repository = tool_call_repository
  @cancel_token         = cancel_token
  # Session the audit row is attributed to. The tool_calls table requires
  # a non-null session_id FK, so without this every audit insert violated
  # the constraint and was swallowed by the repository's rescue — leaving
  # the table empty on every execution, streaming or not (#262).
  @session_id           = session_id
  # 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.



12
13
14
# File 'lib/rubino/agent/tool_executor.rb', line 12

def on_result=(value)
  @on_result = value
end

Instance Method Details

#blocked_for_approval?Boolean

True once any tool was BLOCKED for approval in a non-interactive session (#260): a write/edit/shell that needed a prompt no one could answer. The one-shot CLI reads this after the run to exit NON-ZERO so CI/automation fails loudly instead of treating a silently-skipped action as success.

Returns:

  • (Boolean)


18
19
20
# File 'lib/rubino/agent/tool_executor.rb', line 18

def blocked_for_approval?
  @blocked_for_approval == true
end

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

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

Raises:



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
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
# File 'lib/rubino/agent/tool_executor.rb', line 61

def execute(name:, arguments:, call_id:)
  # Cancellation checkpoint BEFORE the tool runs (#335b). On the streaming
  # path ruby_llm dispatches tool calls mid-stream through ToolBridge into
  # here, and the loop's per-iteration #check! is far above us — so without
  # this a cancel that arrived while a PREVIOUS tool was running (or during
  # the thinking phase) wouldn't be observed until the model resumed
  # streaming, letting the next tool fire after the user already hit
  # interrupt. Raising here halts the in-flight turn at the next tool
  # boundary, the soonest safe checkpoint, so "esc to interrupt" actually
  # stops the agent instead of letting it run one more tool.
  @cancel_token&.check!

  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
    # Headless FAIL-CLOSED floor (#260). A tool the policy wants to ASK
    # about — a write/edit, or a shell command not covered by the
    # permissions allowlist / read-only auto-allow — cannot be approved
    # when there is no interactive session (one-shot `rubino prompt`/`-q`,
    # a pipe, a gate-less embed). Auto-running it (the old UI::Null#confirm
    # → true bug) is the prompt-injection→RCE foot-gun; hanging on a prompt
    # no one can answer is the opencode bug. So DENY with a clear,
    # single-line block message and record the block so the run can exit
    # non-zero. Anything the user already allowlisted resolved to :allow
    # before reaching here, so this never regresses a configured command.
    unless @ui.interactive?
      @blocked_for_approval = true
      message = approval_block_message(tool, arguments)
      @ui.warning(message) if @ui.respond_to?(:warning)
      # Let the headless adapter latch the block so the one-shot CLI can
      # exit non-zero (#260) without threading a flag up through the loop.
      @ui.tool_blocked(message) if @ui.respond_to?(:tool_blocked)
      blocked = Tools::Result.denied(name: name, call_id: call_id, reason: :noninteractive)
      record_denied(name: name, call_id: call_id, arguments: arguments,
                    result: blocked, reason: "noninteractive-blocked")
      return finish(name, arguments, call_id, blocked)
    end

    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

  # Warn-not-block doom-loop guard (#414): when the detector tripped but
  # hard_stop is off (the default), the call is ALLOWED — surface a
  # one-time warning so a stuck autopilot is visible without hard-denying a
  # legitimate repeated/idempotent call.
  if @approval_policy.respond_to?(:doom_loop_warning) &&
     @approval_policy.doom_loop_warning && @ui.respond_to?(:warning)
    @ui.warning(
      "doom-loop guard: '#{name}' called with identical arguments repeatedly — " \
      "proceeding (set doom_loop.hard_stop:true to block)"
    )
  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