Class: Rubino::Tools::TaskTool
- 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
-
.noop_result?(text) ⇒ Boolean
True when a subagent's final result text is the no-op placeholder, i.e.
- .truncated?(stop_reason) ⇒ Boolean
-
.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.
Instance Method Summary collapse
- #call(arguments) ⇒ Object
-
#config_key ⇒ Object
taskis the config gate; absent from config ⇒ enabled (opt-out model), same as every other tool. -
#description ⇒ Object
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.
- #input_schema ⇒ Object
- #name ⇒ Object
-
#risk_level ⇒ Object
Spawns a gated nested run, not a destructive op — the nested tools carry their own approval/risk gates.
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.
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
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.(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.}" end |
#config_key ⇒ Object
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 |
#description ⇒ Object
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_schema ⇒ Object
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 |
#name ⇒ Object
84 85 86 |
# File 'lib/rubino/tools/task_tool.rb', line 84 def name "task" end |
#risk_level ⇒ Object
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 |