Class: Rubino::Tools::TaskTool

Inherits:
Base
  • Object
show all
Defined in:
lib/rubino/tools/task_tool.rb

Overview

Delegates a bounded sub-task to a specialized subagent (the "agents-as-tools" pattern). Modeled on Claude Code's Task/Agent tool, which runs subagents in the BACKGROUND via run_in_background — here background is the DEFAULT:

- background (default): spawn the subagent on its own thread and return
IMMEDIATELY with a task id (`sa_…`). The subagent works while the parent
keeps going. On completion the parent is NOTIFIED — a `[background-task]`
message is injected into its live turn (via the parent's InputQueue, the
same channel mid-turn steering uses) — and the result is also fetchable
with `task_result(<id>)` or stoppable with `task_stop(<id>)`. This is
the SendMessage/poll/notify trio Claude Code exposes for background
agents, mapped onto the gem's existing async substrate.
- synchronous (`background: false`): the legacy path — run the nested turn
to completion inline and return ONLY the subagent's final message as the
tool result. For callers that cannot proceed without the answer now.

Isolation contract (unchanged, both paths):

- the nested run gets a FRESH session seeded with ONLY the `prompt`
string — the parent transcript never leaks into the child;
- each background child gets its OWN Interaction::EventBus (like
Run::Executor does per top-level run) so its tool events never pollute
the parent recorder;
- the only parent→child channel is the `prompt`, so the parent model must
put any needed file paths / errors into it.

Scoped nesting (S1): a subagent CAN now spawn its own subagents (the delegation tools are no longer stripped from a subagent's tool list). The tree is bounded in ONE place — BackgroundTasks#reserve — by three caps: max nesting depth (tasks.max_depth), per-owner live children (tasks.max_children_per_node), and a global live ceiling (tasks.max_concurrent_total). When a cap is hit reserve returns nil and this tool surfaces a clear, reason-specific message (#capacity_message) so the model knows whether to retry later, do the work inline, or report back.

Constant Summary collapse

NOOP_RESULT_SUFFIX =

Suffix of the placeholder a subagent run lands on when it produced no final assistant text — a no-op or a fully-denied run (every tool denied, nothing said). Used as the single signal that a completion was a no-op so both the background completion line and the foreground delegation row can show a neutral indicator instead of a misleading green ✓ (#16).

"returned no output)"
TRUNCATED_REASONS =

Agent::Loop#stop_reason values that mean the child was CUT OFF before finishing (force-summarized at a budget rail, or a truncated stream) — so its "result" is a partial progress recap, NOT the delegated task. Used to report the completion as PARTIAL rather than a misleading "completed".

%i[max_time max_iterations stream_incomplete].freeze
COMPLETION_ICONS =

Main-timeline glyph per completion status (see #completion_status).

{ "no-op" => "", "partial" => "", "done" => "" }.freeze

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk, #stream_kind

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

#cancellation_requested?, #display_name, #emit_chunk, #mcp?, #risky?, #to_tool_definition, workspace_root, workspace_roots

Class Method Details

.noop_result?(text) ⇒ Boolean

True when a subagent's final result text is the no-op placeholder, i.e. the run did nothing / was denied. Shared by completion_marker so the background path mirrors the foreground delegation row.

Returns:

  • (Boolean)


49
50
51
# File 'lib/rubino/tools/task_tool.rb', line 49

def self.noop_result?(text)
  text.to_s.strip.end_with?(NOOP_RESULT_SUFFIX)
end

.truncated?(stop_reason) ⇒ Boolean

Returns:

  • (Boolean)


62
63
64
# File 'lib/rubino/tools/task_tool.rb', line 62

def self.truncated?(stop_reason)
  TRUNCATED_REASONS.include?(stop_reason)
end

.truncation_banner(stop_reason) ⇒ Object

The model-facing banner prepended to a truncated child's result, spelling out WHY it stopped and that the task is UNFINISHED. Shared by the background completion notice, the sync tool-result, and task_result so all three surfaces tell the same truth. nil for a clean (non-truncated) run.



70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/rubino/tools/task_tool.rb', line 70

def self.truncation_banner(stop_reason)
  reason =
    case stop_reason
    when :max_time         then "hit its per-turn time budget (agent.max_turn_seconds) before finishing"
    when :max_iterations   then "hit its per-turn tool-iteration budget before finishing"
    when :stream_incomplete then "had its model stream end early before finishing"
    else return nil
    end
  "⚠ INCOMPLETE — the subagent #{reason}. The text below is a PARTIAL " \
    "progress summary, NOT the finished task. Treat the delegated work as " \
    "UNFINISHED: re-delegate a narrower slice, or complete the remaining " \
    "work yourself."
end

Instance Method Details

#call(arguments) ⇒ Object



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
# File 'lib/rubino/tools/task_tool.rb', line 147

def call(arguments)
  subagent   = (arguments["subagent"] || arguments[:subagent]).to_s.strip
  prompt     = (arguments["prompt"]   || arguments[:prompt]).to_s
  background = background_arg(arguments)

  return "Error: subagent is required" if subagent.empty?
  return "Error: prompt is required"   if prompt.strip.empty?

  definition = registry.find(subagent)
  unless definition&.subagent?
    return "Error: unknown subagent '#{subagent}'. " \
           "Valid subagents: #{available_subagent_names.join(", ")}."
  end

  # Force FOREGROUND in headless one-shot (#380): a `rubino prompt`/-q run
  # has no IdleCardHost to fold a background child's result back in, and the
  # process exits the instant the parent's answer is ready — so a background
  # subagent's result would be silently dropped (its notice sink is nil and
  # its thread is killed on exit). Running synchronously returns the child's
  # final text as THIS tool's result, so it lands in the parent transcript
  # and is factored into the one-shot answer, making `task` reliable headless.
  if background && !Rubino.headless?
    run_background(definition, prompt)
  else
    run_subagent(definition, prompt)
  end
rescue StandardError => e
  "Error: subagent '#{subagent}' failed: #{e.message}"
end

#config_keyObject

task is the config gate; absent from config ⇒ enabled (opt-out model), same as every other tool.



90
91
92
# File 'lib/rubino/tools/task_tool.rb', line 90

def config_key
  "task"
end

#descriptionObject

The "NEVER claim a task was started…" sentence is the #149 guardrail: the model was observed confirming a spawn ("Started: sa_…") with a task id RECYCLED from earlier context, without calling this tool at all. The ids are unguessable (SecureRandom), so the only honest source of a NEW id is this tool's own return value — the description says so explicitly. Prompt-level by design: a render-time transcript scanner would be a far bigger surface for a model-behavior bug the user can already audit via the turn footer (0 tools) and /agents.



102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/rubino/tools/task_tool.rb', line 102

def description
  "Delegate a bounded sub-task to a specialized subagent. By DEFAULT the " \
    "subagent runs in the BACKGROUND: this call returns immediately with a " \
    "task id and the subagent keeps working while you continue with other " \
    "tools or reasoning — do NOT wait for it. When it finishes you will " \
    "automatically receive a `[background-task] <id> completed` message with " \
    "its result; you can also fetch the result anytime with `task_result(<id>)` " \
    "or stop it with `task_stop(<id>)`. Set `background: false` ONLY when you " \
    "cannot proceed without the subagent's answer in this same step (this " \
    "blocks until it finishes and returns the result inline). The subagent " \
    "runs in an isolated fresh context (it does NOT see this conversation) and " \
    "returns only its final message — put every file path / error / detail it " \
    "needs into `prompt`. NEVER claim a task was started unless THIS call just " \
    "returned its id in the current turn — `sa_…` ids from earlier in the " \
    "conversation belong to old tasks and must not be reported as new ones. " \
    "Available subagents: #{available_subagents_description}."
end

#input_schemaObject



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/rubino/tools/task_tool.rb', line 120

def input_schema
  {
    type: "object",
    properties: {
      subagent: { type: "string",
                  description: "Name of the subagent to delegate to (#{available_subagent_names.join(", ")})" },
      prompt: { type: "string",
                description: "The full self-contained task for the subagent (the only context it receives)" },
      background: {
        type: "boolean",
        description: "Run the subagent in the background (default true). " \
                     "true = return immediately with a task id, keep working, get " \
                     "notified on completion. false = block until the subagent " \
                     "finishes and return its result inline."
      }
    },
    required: %w[subagent prompt]
  }
end

#nameObject



84
85
86
# File 'lib/rubino/tools/task_tool.rb', line 84

def name
  "task"
end

#risk_levelObject

Spawns a gated nested run, not a destructive op — the nested tools carry their own approval/risk gates. Low risk keeps it auto-available so the model can auto-delegate from the description.



143
144
145
# File 'lib/rubino/tools/task_tool.rb', line 143

def risk_level
  :low
end