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)"
Instance Attribute Summary
Attributes inherited from Base
#cancel_token, #read_tracker, #stream_chunk
Class Method Summary collapse
-
.noop_result?(text) ⇒ Boolean
True when a subagent’s final result text is the no-op placeholder, i.e.
Instance Method Summary collapse
- #call(arguments) ⇒ Object
-
#config_key ⇒ Object
‘task` is 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?, #emit_chunk, #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_summary 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 |
Instance Method Details
#call(arguments) ⇒ Object
116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 |
# File 'lib/rubino/tools/task_tool.rb', line 116 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 if background 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.
59 60 61 |
# File 'lib/rubino/tools/task_tool.rb', line 59 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.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/rubino/tools/task_tool.rb', line 71 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
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/rubino/tools/task_tool.rb', line 89 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
53 54 55 |
# File 'lib/rubino/tools/task_tool.rb', line 53 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.
112 113 114 |
# File 'lib/rubino/tools/task_tool.rb', line 112 def risk_level :low end |