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)"

Instance Attribute Summary

Attributes inherited from Base

#cancel_token, #read_tracker, #stream_chunk

Class Method Summary collapse

Instance Method Summary collapse

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.

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

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.message}"
end

#config_keyObject

‘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

#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.



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_schemaObject



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

#nameObject



53
54
55
# File 'lib/rubino/tools/task_tool.rb', line 53

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.



112
113
114
# File 'lib/rubino/tools/task_tool.rb', line 112

def risk_level
  :low
end