Class: Rubino::Agent::ToolExecutor
- Inherits:
-
Object
- Object
- Rubino::Agent::ToolExecutor
- Defined in:
- lib/rubino/agent/tool_executor.rb
Overview
Executes tool calls with approval checks and result formatting.
Instance Attribute Summary collapse
-
#on_result ⇒ Object
writeonly
The Loop registers its count+persist sink here after construction (the executor is built first so the adapter/ToolBridge can share it).
Instance Method Summary collapse
-
#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.
-
#execute(name:, arguments:, call_id:) ⇒ Object
Executes a single tool call, returns a Tools::Result.
-
#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
constructor
A new instance of ToolExecutor.
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.
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.
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 = (tool, arguments) @ui.warning() 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() 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 |