Skip to content

Commit 67e1fb1

Browse files
committed
🤖 feat: unify bash into task_* tools
Change-Id: Ic4a03eb4953916443fcc074412498e0853c0ff17 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 6cbe539 commit 67e1fb1

File tree

11 files changed

+653
-69
lines changed

11 files changed

+653
-69
lines changed

src/browser/components/tools/TaskToolCall.tsx

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,9 @@ const TaskId: React.FC<{ id: string; className?: string }> = ({ id, className })
162162
// TASK TOOL CALL (spawn sub-agent)
163163
// ═══════════════════════════════════════════════════════════════════════════════
164164

165+
function isBashTaskArgs(args: TaskToolArgs): args is Extract<TaskToolArgs, { kind: "bash" }> {
166+
return (args as { kind?: unknown }).kind === "bash";
167+
}
165168
interface TaskToolCallProps {
166169
args: TaskToolArgs;
167170
result?: TaskToolSuccessResult;
@@ -174,27 +177,44 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
174177
const { expanded, toggleExpanded } = useToolExpansion(hasReport);
175178

176179
const isBackground = args.run_in_background ?? false;
177-
const agentType = args.subagent_type;
178-
const prompt = args.prompt;
179-
const title = args.title;
180+
181+
let isBashTask: boolean;
182+
let title: string;
183+
let promptOrScript: string;
184+
let kindBadge: React.ReactNode;
185+
186+
if (isBashTaskArgs(args)) {
187+
isBashTask = true;
188+
title = args.display_name;
189+
promptOrScript = args.script;
190+
kindBadge = <AgentTypeBadge type="bash" />;
191+
} else {
192+
isBashTask = false;
193+
title = args.title;
194+
promptOrScript = args.prompt;
195+
kindBadge = <AgentTypeBadge type={args.subagent_type} />;
196+
}
180197

181198
// Derive task state from result
182199
const taskId = result?.taskId;
183200
const taskStatus = result?.status;
184201
const reportMarkdown = result?.status === "completed" ? result.reportMarkdown : undefined;
185202
const reportTitle = result?.status === "completed" ? result.title : undefined;
203+
const exitCode = result?.status === "completed" ? result.exitCode : undefined;
186204

187-
// Show preview of prompt (first line or truncated)
188-
const promptPreview =
189-
prompt.length > 60 ? prompt.slice(0, 60).trim() + "…" : prompt.split("\n")[0];
205+
// Show preview (first line or truncated)
206+
const preview =
207+
promptOrScript.length > 60
208+
? promptOrScript.slice(0, 60).trim() + "…"
209+
: promptOrScript.split("\n")[0];
190210

191211
return (
192212
<ToolContainer expanded={expanded}>
193213
<ToolHeader onClick={toggleExpanded}>
194214
<ExpandIcon expanded={expanded}></ExpandIcon>
195215
<TaskIcon toolName="task" />
196216
<ToolName>task</ToolName>
197-
<AgentTypeBadge type={agentType} />
217+
{kindBadge}
198218
{isBackground && (
199219
<span className="text-backgrounded text-[10px] font-medium">background</span>
200220
)}
@@ -205,26 +225,33 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
205225
<ToolDetails>
206226
{/* Task info surface */}
207227
<div className="task-surface mt-1 rounded-md p-3">
208-
<div className="task-divider mb-2 flex items-center gap-2 border-b pb-2">
228+
<div className="task-divider mb-2 flex flex-wrap items-center gap-2 border-b pb-2">
209229
<span className="text-task-mode text-[12px] font-semibold">
210230
{reportTitle ?? title}
211231
</span>
212232
{taskId && <TaskId id={taskId} />}
213233
{taskStatus && <TaskStatusBadge status={taskStatus} />}
234+
{exitCode !== undefined && (
235+
<span className="text-muted text-[10px]">exit {exitCode}</span>
236+
)}
214237
</div>
215238

216-
{/* Prompt section */}
239+
{/* Prompt / script */}
217240
<div className="mb-2">
218-
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">Prompt</div>
219-
<div className="text-foreground bg-code-bg max-h-[100px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
220-
{prompt}
241+
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">
242+
{isBashTask ? "Script" : "Prompt"}
243+
</div>
244+
<div className="text-foreground bg-code-bg max-h-[140px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
245+
{promptOrScript}
221246
</div>
222247
</div>
223248

224249
{/* Report section */}
225250
{reportMarkdown && (
226251
<div className="task-divider border-t pt-2">
227-
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">Report</div>
252+
<div className="text-muted mb-1 text-[10px] tracking-wide uppercase">
253+
{isBashTask ? "Output" : "Report"}
254+
</div>
228255
<div className="text-[11px]">
229256
<MarkdownRenderer content={reportMarkdown} />
230257
</div>
@@ -243,7 +270,7 @@ export const TaskToolCall: React.FC<TaskToolCallProps> = ({ args, result, status
243270
)}
244271

245272
{/* Collapsed preview */}
246-
{!expanded && <div className="text-muted mt-1 truncate text-[10px]">{promptPreview}</div>}
273+
{!expanded && <div className="text-muted mt-1 truncate text-[10px]">{preview}</div>}
247274
</ToolContainer>
248275
);
249276
};
@@ -270,6 +297,12 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
270297
const timeoutSecs = args.timeout_secs;
271298
const results = result?.results ?? [];
272299

300+
const showConfigInfo =
301+
taskIds !== undefined ||
302+
timeoutSecs !== undefined ||
303+
args.filter !== undefined ||
304+
args.filter_exclude === true;
305+
273306
// Summary for header
274307
const completedCount = results.filter((r) => r.status === "completed").length;
275308
const totalCount = results.length;
@@ -292,10 +325,12 @@ export const TaskAwaitToolCall: React.FC<TaskAwaitToolCallProps> = ({
292325
<ToolDetails>
293326
<div className="task-surface mt-1 rounded-md p-3">
294327
{/* Config info */}
295-
{(taskIds ?? timeoutSecs) && (
328+
{showConfigInfo && (
296329
<div className="task-divider text-muted mb-2 flex flex-wrap gap-2 border-b pb-2 text-[10px]">
297-
{taskIds && <span>Waiting for: {taskIds.length} task(s)</span>}
298-
{timeoutSecs && <span>Timeout: {timeoutSecs}s</span>}
330+
{taskIds !== undefined && <span>Waiting for: {taskIds.length} task(s)</span>}
331+
{timeoutSecs !== undefined && <span>Timeout: {timeoutSecs}s</span>}
332+
{args.filter !== undefined && <span>Filter: {args.filter}</span>}
333+
{args.filter_exclude === true && <span>Exclude: true</span>}
299334
</div>
300335
)}
301336

@@ -329,20 +364,35 @@ const TaskAwaitResult: React.FC<{
329364
const reportMarkdown = isCompleted ? result.reportMarkdown : undefined;
330365
const title = isCompleted ? result.title : undefined;
331366

367+
const output = "output" in result ? result.output : undefined;
368+
const note = "note" in result ? result.note : undefined;
369+
const exitCode = "exitCode" in result ? result.exitCode : undefined;
370+
const elapsedMs = "elapsed_ms" in result ? result.elapsed_ms : undefined;
371+
332372
return (
333373
<div className="bg-code-bg rounded-sm p-2">
334-
<div className="mb-1 flex items-center gap-2">
374+
<div className="mb-1 flex flex-wrap items-center gap-2">
335375
<TaskId id={result.taskId} />
336376
<TaskStatusBadge status={result.status} />
337377
{title && <span className="text-foreground text-[11px] font-medium">{title}</span>}
378+
{exitCode !== undefined && <span className="text-muted text-[10px]">exit {exitCode}</span>}
379+
{elapsedMs !== undefined && <span className="text-muted text-[10px]">{elapsedMs}ms</span>}
338380
</div>
339381

382+
{!isCompleted && output && output.length > 0 && (
383+
<div className="text-foreground bg-code-bg max-h-[140px] overflow-y-auto rounded-sm p-2 text-[11px] break-words whitespace-pre-wrap">
384+
{output}
385+
</div>
386+
)}
387+
340388
{reportMarkdown && (
341389
<div className="mt-2 text-[11px]">
342390
<MarkdownRenderer content={reportMarkdown} />
343391
</div>
344392
)}
345393

394+
{note && <div className="text-muted mt-1 text-[10px]">{note}</div>}
395+
346396
{"error" in result && result.error && (
347397
<div className="text-danger mt-1 text-[11px]">{result.error}</div>
348398
)}

src/common/utils/tools/toolDefinitions.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const SubagentTypeSchema = z.preprocess(
9494
z.enum(BUILT_IN_SUBAGENT_TYPES)
9595
);
9696

97-
export const TaskToolArgsSchema = z
97+
const TaskToolAgentArgsSchema = z
9898
.object({
9999
subagent_type: SubagentTypeSchema,
100100
prompt: z.string().min(1),
@@ -103,6 +103,18 @@ export const TaskToolArgsSchema = z
103103
})
104104
.strict();
105105

106+
const TaskToolBashArgsSchema = z
107+
.object({
108+
kind: z.literal("bash"),
109+
script: z.string().min(1),
110+
timeout_secs: z.number().positive(),
111+
run_in_background: z.boolean().default(false),
112+
display_name: z.string().min(1),
113+
})
114+
.strict();
115+
116+
export const TaskToolArgsSchema = z.union([TaskToolAgentArgsSchema, TaskToolBashArgsSchema]);
117+
106118
export const TaskToolQueuedResultSchema = z
107119
.object({
108120
status: z.enum(["queued", "running"]),
@@ -117,6 +129,8 @@ export const TaskToolCompletedResultSchema = z
117129
reportMarkdown: z.string(),
118130
title: z.string().optional(),
119131
agentType: z.string().optional(),
132+
exitCode: z.number().optional(),
133+
note: z.string().optional(),
120134
})
121135
.strict();
122136

@@ -137,12 +151,28 @@ export const TaskAwaitToolArgsSchema = z
137151
.describe(
138152
"List of task IDs to await. When omitted, waits for all active descendant tasks of the current workspace."
139153
),
154+
filter: z
155+
.string()
156+
.optional()
157+
.describe(
158+
"Optional regex to filter bash task output lines. By default, only matching lines are returned. " +
159+
"When filter_exclude is true, matching lines are excluded instead. " +
160+
"Non-matching lines are discarded and cannot be retrieved later."
161+
),
162+
filter_exclude: z
163+
.boolean()
164+
.optional()
165+
.describe(
166+
"When true, lines matching 'filter' are excluded instead of kept. " +
167+
"Requires 'filter' to be set."
168+
),
140169
timeout_secs: z
141170
.number()
142-
.positive()
171+
.min(0)
143172
.optional()
144173
.describe(
145174
"Maximum time to wait in seconds for each task. " +
175+
"For bash tasks, this waits for NEW output (or process exit). " +
146176
"If exceeded, the result returns status=queued|running|awaiting_report (task is still active). " +
147177
"Optional, defaults to 10 minutes."
148178
),
@@ -155,13 +185,20 @@ export const TaskAwaitToolCompletedResultSchema = z
155185
taskId: z.string(),
156186
reportMarkdown: z.string(),
157187
title: z.string().optional(),
188+
output: z.string().optional(),
189+
elapsed_ms: z.number().optional(),
190+
exitCode: z.number().optional(),
191+
note: z.string().optional(),
158192
})
159193
.strict();
160194

161195
export const TaskAwaitToolActiveResultSchema = z
162196
.object({
163197
status: z.enum(["queued", "running", "awaiting_report"]),
164198
taskId: z.string(),
199+
output: z.string().optional(),
200+
elapsed_ms: z.number().optional(),
201+
note: z.string().optional(),
165202
})
166203
.strict();
167204

@@ -513,31 +550,35 @@ export const TOOL_DEFINITIONS = {
513550
},
514551
task: {
515552
description:
516-
"Spawn a sub-agent task in a child workspace. " +
517-
'Use this to delegate work to specialized presets like "explore" (read-only investigation) or "exec" (general-purpose coding in a child workspace). ' +
518-
"If run_in_background is false, this tool blocks until the sub-agent calls agent_report, then returns the report. " +
519-
"If run_in_background is true, you can await it later with task_await.",
553+
"Unified task tool for (1) spawning sub-agent tasks and (2) running bash commands. " +
554+
"\n\nAgent tasks: provide subagent_type, prompt, title, run_in_background. " +
555+
'\nBash tasks: set kind="bash" and provide script, timeout_secs, display_name, run_in_background. ' +
556+
"\n\nIf run_in_background is false, returns a completed reportMarkdown. " +
557+
"If run_in_background is true, returns a running taskId; use task_await to read incremental output and task_terminate to stop it.",
520558
schema: TaskToolArgsSchema,
521559
},
522560
task_await: {
523561
description:
524-
"Wait for one or more sub-agent tasks to finish and return their reports. " +
562+
"Wait for one or more tasks to produce output. " +
563+
"Agent tasks return reports when completed. " +
564+
"Bash tasks return incremental output while running and a final reportMarkdown when they exit. " +
525565
"Use this tool to WAIT; do not poll task_list in a loop to wait for task completion (that is misuse and wastes tool calls). " +
526566
"This is similar to Promise.allSettled(): you always get per-task results. " +
527567
"Possible statuses: completed, queued, running, awaiting_report, not_found, invalid_scope, error.",
528568
schema: TaskAwaitToolArgsSchema,
529569
},
530570
task_terminate: {
531571
description:
532-
"Terminate one or more sub-agent tasks immediately. " +
533-
"This stops their AI streams and deletes their workspaces (best-effort). " +
572+
"Terminate one or more tasks immediately (sub-agent tasks or background bash tasks). " +
573+
"For sub-agent tasks, this stops their AI streams and deletes their workspaces (best-effort). " +
534574
"No report will be delivered; any in-progress work is discarded. " +
535575
"If the task has descendant sub-agent tasks, they are terminated too.",
536576
schema: TaskTerminateToolArgsSchema,
537577
},
538578
task_list: {
539579
description:
540-
"List descendant sub-agent tasks for the current workspace, including their status and metadata. " +
580+
"List descendant tasks for the current workspace, including status + metadata. " +
581+
"This includes sub-agent tasks and background bash tasks. " +
541582
"Use this after compaction or interruptions to rediscover which tasks are still active. " +
542583
"This is a discovery tool, NOT a waiting mechanism: if you need to wait for tasks to finish, call task_await (optionally omit task_ids to await all active descendant tasks).",
543584
schema: TaskListToolArgsSchema,
@@ -961,10 +1002,6 @@ export function getAvailableTools(
9611002

9621003
// Base tools available for all models
9631004
const baseTools = [
964-
"bash",
965-
"bash_output",
966-
"bash_background_list",
967-
"bash_background_terminate",
9681005
"file_read",
9691006
"agent_skill_read",
9701007
"agent_skill_read_file",

src/common/utils/tools/tools.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { createAgentSkillReadFileTool } from "@/node/services/tools/agent_skill_
2020
import { createAgentReportTool } from "@/node/services/tools/agent_report";
2121
import { wrapWithInitWait } from "@/node/services/tools/wrapWithInitWait";
2222
import { log } from "@/node/services/log";
23+
import { getAvailableTools } from "@/common/utils/tools/toolDefinitions";
2324
import { sanitizeMCPToolsForOpenAI } from "@/common/utils/tools/schemaSanitizer";
2425

2526
import type { Runtime } from "@/node/runtime/Runtime";
@@ -139,21 +140,26 @@ export async function getToolsForModel(
139140
// to leave repository in broken state due to issues with concurrent file modifications
140141
// and line number miscalculations. Use file_edit_replace_string instead.
141142
// file_edit_replace_lines: wrap(createFileEditReplaceLinesTool(config)),
143+
144+
// Unified task abstraction (agent + bash)
145+
task: wrap(createTaskTool(config)),
146+
task_await: wrap(createTaskAwaitTool(config)),
147+
task_terminate: wrap(createTaskTerminateTool(config)),
148+
task_list: wrap(createTaskListTool(config)),
149+
150+
// Legacy bash tools (deprecated: prefer task(kind="bash"))
142151
bash: wrap(createBashTool(config)),
143152
bash_output: wrap(createBashOutputTool(config)),
144153
bash_background_list: wrap(createBashBackgroundListTool(config)),
145154
bash_background_terminate: wrap(createBashBackgroundTerminateTool(config)),
155+
146156
web_fetch: wrap(createWebFetchTool(config)),
147157
};
148158

149159
// Non-runtime tools execute immediately (no init wait needed)
150160
const nonRuntimeTools: Record<string, Tool> = {
151161
...(config.mode === "plan" ? { ask_user_question: createAskUserQuestionTool(config) } : {}),
152162
propose_plan: createProposePlanTool(config),
153-
task: createTaskTool(config),
154-
task_await: createTaskAwaitTool(config),
155-
task_terminate: createTaskTerminateTool(config),
156-
task_list: createTaskListTool(config),
157163
...(config.enableAgentReport ? { agent_report: createAgentReportTool(config) } : {}),
158164
todo_write: createTodoWriteTool(config),
159165
todo_read: createTodoReadTool(config),
@@ -220,6 +226,19 @@ export async function getToolsForModel(
220226
log.error(`No web search tools available for ${provider}:`, error);
221227
}
222228

229+
// Filter tools to the canonical allowlist so system prompt + toolset stay in sync.
230+
// Include MCP tools even if they're not in getAvailableTools().
231+
const allowlistedToolNames = new Set(
232+
getAvailableTools(modelString, config.mode, { enableAgentReport: config.enableAgentReport })
233+
);
234+
for (const toolName of Object.keys(mcpTools ?? {})) {
235+
allowlistedToolNames.add(toolName);
236+
}
237+
238+
allTools = Object.fromEntries(
239+
Object.entries(allTools).filter(([toolName]) => allowlistedToolNames.has(toolName))
240+
);
241+
223242
// Apply tool-specific instructions if provided
224243
if (toolInstructions) {
225244
const augmentedTools: Record<string, Tool> = {};

0 commit comments

Comments
 (0)