Skip to content

Commit dd1173d

Browse files
committed
🤖 fix: simplify subagents + plan-mode delegation
- Enforce subagent_type enum (explore|exec)\n- Make subagents non-recursive; Plan Mode spawns explore only\n- Hide Exec sub-agent settings; exec always inherits\n\n---\n_Generated with • Model: unknown • Thinking: unknown_\n<!-- mux-attribution: model=unknown thinking=unknown --> Change-Id: Ib7e8e5b9340bc9a1117e349f696779caea60ac18 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0f59586 commit dd1173d

File tree

12 files changed

+113
-65
lines changed

12 files changed

+113
-65
lines changed

‎src/browser/stories/App.settings.stories.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ export const Tasks: AppStory = {
111111
await body.findByText(/Max Parallel Agent Tasks/i);
112112
await body.findByText(/Max Task Nesting Depth/i);
113113
await body.findByText(/Sub-agents/i);
114-
await body.findByText(/Research/i);
115114
await body.findByText(/Explore/i);
115+
if (body.queryByText(/Exec/i)) {
116+
throw new Error("Expected Exec sub-agent settings to be hidden (always inherits)");
117+
}
116118

117119
const inputs = await body.findAllByRole("spinbutton");
118120
if (inputs.length !== 2) {
Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
export const BUILT_IN_SUBAGENTS = [
2-
{ agentType: "research", label: "Research" },
3-
{ agentType: "explore", label: "Explore" },
4-
] as const;
1+
export const BUILT_IN_SUBAGENT_TYPES = ["explore", "exec"] as const;
2+
export type BuiltInSubagentType = (typeof BUILT_IN_SUBAGENT_TYPES)[number];
53

6-
export type BuiltInSubagentType = (typeof BUILT_IN_SUBAGENTS)[number]["agentType"];
4+
export const BUILT_IN_SUBAGENTS = [{ agentType: "explore", label: "Explore" }] as const;

‎src/common/orpc/schemas/project.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ export const WorkspaceConfigSchema = z.object({
3434
"If set, this workspace is a child workspace spawned from the parent workspaceId (enables nesting in UI and backend orchestration).",
3535
}),
3636
agentType: z.string().optional().meta({
37-
description:
38-
'If set, selects an agent preset for this workspace (e.g., "research" or "explore").',
37+
description: 'If set, selects an agent preset for this workspace (e.g., "explore" or "exec").',
3938
}),
4039
taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({
4140
description:

‎src/common/orpc/schemas/workspace.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ export const WorkspaceMetadataSchema = z.object({
3737
"If set, this workspace is a child workspace spawned from the parent workspaceId (enables nesting in UI and backend orchestration).",
3838
}),
3939
agentType: z.string().optional().meta({
40-
description:
41-
'If set, selects an agent preset for this workspace (e.g., "research" or "explore").',
40+
description: 'If set, selects an agent preset for this workspace (e.g., "explore" or "exec").',
4241
}),
4342
taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({
4443
description:

‎src/common/types/tasks.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export function normalizeSubagentAiDefaults(raw: unknown): SubagentAiDefaults {
3131
for (const [agentTypeRaw, entryRaw] of Object.entries(record)) {
3232
const agentType = agentTypeRaw.trim().toLowerCase();
3333
if (!agentType) continue;
34+
if (agentType === "exec") continue;
3435
if (!entryRaw || typeof entryRaw !== "object") continue;
3536

3637
const entry = entryRaw as Record<string, unknown>;

‎src/common/utils/tools/toolDefinitions.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
STATUS_MESSAGE_MAX_LENGTH,
1414
WEB_FETCH_MAX_OUTPUT_BYTES,
1515
} from "@/common/constants/toolLimits";
16+
import { BUILT_IN_SUBAGENT_TYPES } from "@/common/constants/agents";
1617
import { TOOL_EDIT_WARNING } from "@/common/types/tools";
1718

1819
import { zodToJsonSchema } from "zod-to-json-schema";
@@ -87,9 +88,14 @@ export const AskUserQuestionToolResultSchema = z
8788
// task (sub-workspaces as subagents)
8889
// -----------------------------------------------------------------------------
8990

91+
const SubagentTypeSchema = z.preprocess(
92+
(value) => (typeof value === "string" ? value.trim().toLowerCase() : value),
93+
z.enum(BUILT_IN_SUBAGENT_TYPES)
94+
);
95+
9096
export const TaskToolArgsSchema = z
9197
.object({
92-
subagent_type: z.string().min(1),
98+
subagent_type: SubagentTypeSchema,
9399
prompt: z.string().min(1),
94100
description: z.string().optional(),
95101
run_in_background: z.boolean().default(false),
@@ -467,7 +473,7 @@ export const TOOL_DEFINITIONS = {
467473
task: {
468474
description:
469475
"Spawn a sub-agent task in a child workspace. " +
470-
"Use this to delegate work to specialized presets like research or explore. " +
476+
'Use this to delegate work to specialized presets like "explore" (read-only investigation) or "exec" (general-purpose coding in a child workspace). ' +
471477
"If run_in_background is false, this tool blocks until the sub-agent calls agent_report, then returns the report. " +
472478
"If run_in_background is true, you can await it later with task_await.",
473479
schema: TaskToolArgsSchema,

‎src/common/utils/ui/modeUtils.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ NOTE that this is the only file you are allowed to edit - other than this you ar
1717
1818
Keep the plan crisp and focused on actionable recommendations. Put historical context, alternatives considered, or lengthy rationale into collapsible \`<details>/<summary>\` blocks so the core plan stays scannable.
1919
20-
If you need investigation (codebase exploration or deeper research) before you can produce a good plan, delegate it to sub-agents via the \`task\` tool:
21-
- Use \`subagent_type: "explore"\` for quick, read-only repo/code exploration (identify relevant files/symbols, callsites, and facts).
22-
- Use \`subagent_type: "research"\` for deeper investigation and feasibility analysis in this codebase (it may delegate to \`explore\`; web research is optional when relevant).
20+
If you need investigation (codebase exploration, tracing callsites, locating patterns, feasibility checks) before you can produce a good plan, delegate it to Explore sub-agents via the \`task\` tool:
21+
- In Plan Mode, you MUST ONLY spawn \`subagent_type: "explore"\` tasks. Do NOT spawn \`subagent_type: "exec"\` tasks in Plan Mode.
22+
- Use \`subagent_type: "explore"\` for read-only repo/code exploration and optional web lookups when relevant.
2323
- In each task prompt, specify explicit deliverables (what questions to answer, what files/symbols to locate, and the exact output format you want back).
24-
- Run tasks in parallel with \`run_in_background: true\`, then use \`task_await\` (optionally with \`task_ids\`) until all spawned tasks are \`completed\`.
25-
- After spawning one or more tasks, do NOT continue with your own investigation/planning in parallel. Await the task reports first, then synthesize and proceed.
24+
- Prefer running multiple Explore tasks in parallel with \`run_in_background: true\`, then use \`task_await\` (optionally with \`task_ids\`) until all spawned tasks are \`completed\`.
25+
- While Explore tasks run, do NOT perform broad repo exploration yourself. Wait for the reports, then synthesize the plan in this session.
2626
- Do NOT call \`propose_plan\` until you have awaited and incorporated sub-agent reports.
2727
2828
If you need clarification from the user before you can finalize the plan, you MUST use the ask_user_question tool.
@@ -35,6 +35,7 @@ If you need clarification from the user before you can finalize the plan, you MU
3535
3636
When you have finished writing your plan and are ready for user approval, call the propose_plan tool.
3737
Do not make other edits in plan mode. You may have tools like bash but only use them for read-only operations.
38+
Read-only bash means: no redirects/heredocs, no rm/mv/cp/mkdir/touch, no git add/commit, and no dependency installs.
3839
3940
If the user suggests that you should make edits to other files, ask them to switch to Exec mode first!
4041
`;

‎src/node/services/agentPresets.ts‎

Lines changed: 35 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
import type { ToolPolicy } from "@/common/utils/tools/toolPolicy";
22

33
export interface AgentPreset {
4-
/** Normalized agentType key (e.g., "research") */
4+
/** Normalized agentType key (e.g., "explore" or "exec") */
55
agentType: string;
66
toolPolicy: ToolPolicy;
77
systemPrompt: string;
88
}
99

10-
const TASK_TOOL_NAMES = [
11-
"task",
12-
"task_await",
13-
"task_list",
14-
"task_terminate",
15-
"agent_report",
16-
] as const;
10+
const REPORTING_TOOL_NAMES = ["agent_report"] as const;
1711

1812
function enableOnly(...toolNames: readonly string[]): ToolPolicy {
1913
return [
@@ -25,14 +19,14 @@ function enableOnly(...toolNames: readonly string[]): ToolPolicy {
2519
const REPORTING_PROMPT_LINES = [
2620
"Reporting:",
2721
"- When you have a final answer, call agent_report exactly once.",
28-
"- Do not call agent_report until any spawned sub-tasks have completed and you have integrated their results.",
22+
"- Do not call agent_report until you have completed the assigned task and integrated all relevant findings.",
2923
] as const;
3024

3125
function buildSystemPrompt(args: {
3226
agentLabel: string;
3327
goals: string[];
3428
rules: string[];
35-
delegation: string[];
29+
delegation?: string[];
3630
}): string {
3731
return [
3832
`You are a ${args.agentLabel} sub-agent running inside a child workspace.`,
@@ -43,30 +37,32 @@ function buildSystemPrompt(args: {
4337
"Rules:",
4438
...args.rules,
4539
"",
46-
"Delegation:",
47-
...args.delegation,
48-
"",
40+
...(args.delegation && args.delegation.length > 0
41+
? ["Delegation:", ...args.delegation, ""]
42+
: []),
4943
...REPORTING_PROMPT_LINES,
5044
].join("\n");
5145
}
5246

53-
const RESEARCH_PRESET: AgentPreset = {
54-
agentType: "research",
55-
toolPolicy: enableOnly("web_search", "web_fetch", "file_read", ...TASK_TOOL_NAMES),
47+
const EXEC_PRESET: AgentPreset = {
48+
agentType: "exec",
49+
toolPolicy: [
50+
// Non-recursive: subagents must not spawn more subagents.
51+
{ regex_match: "task", action: "disable" },
52+
{ regex_match: "task_.*", action: "disable" },
53+
// Only the main plan-mode session should call propose_plan.
54+
{ regex_match: "propose_plan", action: "disable" },
55+
],
5656
systemPrompt: buildSystemPrompt({
57-
agentLabel: "Research",
57+
agentLabel: "Exec",
5858
goals: [
59-
"- Gather accurate, relevant information efficiently.",
60-
"- Prefer primary sources and official docs when possible.",
59+
"- Complete the assigned coding task end-to-end in this child workspace.",
60+
"- Make minimal, correct changes that match existing codebase patterns.",
6161
],
6262
rules: [
63-
"- Do not edit files.",
64-
"- Do not run bash commands unless explicitly enabled (assume it is not).",
65-
"- If the task tool is available and you need repository exploration beyond file_read, delegate to an Explore sub-agent.",
66-
"- Use task_list only for discovery (e.g. after interruptions). Do not poll task_list to wait; use task_await to wait for completion.",
67-
],
68-
delegation: [
69-
'- If available, use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.',
63+
"- Do not call task/task_await/task_list/task_terminate (subagent recursion is disabled).",
64+
"- Do not call propose_plan.",
65+
"- Prefer small, reviewable diffs and run targeted checks when feasible.",
7066
],
7167
}),
7268
};
@@ -79,29 +75,32 @@ const EXPLORE_PRESET: AgentPreset = {
7975
"bash_output",
8076
"bash_background_list",
8177
"bash_background_terminate",
82-
...TASK_TOOL_NAMES
78+
"web_fetch",
79+
"web_search",
80+
"google_search",
81+
...REPORTING_TOOL_NAMES
8382
),
8483
systemPrompt: buildSystemPrompt({
8584
agentLabel: "Explore",
8685
goals: [
8786
"- Explore the repository to answer the prompt using read-only investigation.",
88-
"- Keep output concise and actionable (paths, symbols, and findings).",
87+
"- Return concise, actionable findings (paths, symbols, callsites, and facts).",
8988
],
9089
rules: [
91-
"- Do not edit files.",
92-
"- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).",
93-
"- If the task tool is available and you need external information, delegate to a Research sub-agent.",
94-
"- Use task_list only for discovery (e.g. after interruptions). Do not poll task_list to wait; use task_await to wait for completion.",
95-
],
96-
delegation: [
97-
'- If available, use: task({ subagent_type: "research", prompt: "..." }) when you need web research.',
90+
"=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===",
91+
"- You MUST NOT create, edit, delete, move, or copy files.",
92+
"- You MUST NOT create temporary files anywhere (including /tmp).",
93+
"- You MUST NOT use redirect operators (>, >>, |) or heredocs to write to files.",
94+
"- You MUST NOT run commands that change system state (rm, mv, cp, mkdir, touch, git add/commit, installs, etc.).",
95+
"- Use bash only for read-only operations (rg, ls, cat, git diff/show/log, etc.).",
96+
"- Do not call task/task_await/task_list/task_terminate (subagent recursion is disabled).",
9897
],
9998
}),
10099
};
101100

102101
const PRESETS_BY_AGENT_TYPE: Record<string, AgentPreset> = {
103-
research: RESEARCH_PRESET,
104102
explore: EXPLORE_PRESET,
103+
exec: EXEC_PRESET,
105104
};
106105

107106
export function getAgentPreset(agentType: string | undefined): AgentPreset | null {

‎src/node/services/taskService.test.ts‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -926,9 +926,9 @@ describe("TaskService", () => {
926926
{
927927
path: path.join(projectPath, "task"),
928928
id: taskId,
929-
name: "agent_research_task",
929+
name: "agent_exec_task",
930930
parentWorkspaceId: rootWorkspaceId,
931-
agentType: "research",
931+
agentType: "exec",
932932
taskStatus: "running",
933933
},
934934
],
@@ -982,9 +982,9 @@ describe("TaskService", () => {
982982
{
983983
path: path.join(projectPath, "parent-task"),
984984
id: parentTaskId,
985-
name: "agent_research_parent",
985+
name: "agent_exec_parent",
986986
parentWorkspaceId: rootWorkspaceId,
987-
agentType: "research",
987+
agentType: "exec",
988988
taskStatus: "running",
989989
},
990990
{
@@ -1224,9 +1224,9 @@ describe("TaskService", () => {
12241224
{
12251225
path: path.join(projectPath, "parent-task"),
12261226
id: parentTaskId,
1227-
name: "agent_research_parent",
1227+
name: "agent_exec_parent",
12281228
parentWorkspaceId: rootWorkspaceId,
1229-
agentType: "research",
1229+
agentType: "exec",
12301230
taskStatus: "running",
12311231
},
12321232
{
@@ -1279,9 +1279,9 @@ describe("TaskService", () => {
12791279
{
12801280
path: path.join(projectPath, "parent-task"),
12811281
id: parentTaskId,
1282-
name: "agent_research_parent",
1282+
name: "agent_exec_parent",
12831283
parentWorkspaceId: rootWorkspaceId,
1284-
agentType: "research",
1284+
agentType: "exec",
12851285
taskStatus: "awaiting_report",
12861286
},
12871287
{

‎src/node/services/tools/task.test.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,42 @@ describe("task tool", () => {
106106
expect(caught.message).toMatch(/maxTaskNestingDepth/i);
107107
}
108108
});
109+
110+
it('should reject spawning "exec" tasks while in plan mode', async () => {
111+
using tempDir = new TestTempDir("test-task-tool");
112+
const baseConfig = createTestToolConfig(tempDir.path, { workspaceId: "parent-workspace" });
113+
114+
const create = mock(() =>
115+
Ok({ taskId: "child-task", kind: "agent" as const, status: "running" as const })
116+
);
117+
const waitForAgentReport = mock(() =>
118+
Promise.resolve({
119+
reportMarkdown: "Hello from child",
120+
title: "Result",
121+
})
122+
);
123+
const taskService = { create, waitForAgentReport } as unknown as TaskService;
124+
125+
const tool = createTaskTool({
126+
...baseConfig,
127+
mode: "plan",
128+
taskService,
129+
});
130+
131+
let caught: unknown = null;
132+
try {
133+
await Promise.resolve(
134+
tool.execute!({ subagent_type: "exec", prompt: "do it" }, mockToolCallOptions)
135+
);
136+
} catch (error: unknown) {
137+
caught = error;
138+
}
139+
140+
expect(caught).toBeInstanceOf(Error);
141+
if (caught instanceof Error) {
142+
expect(caught.message).toMatch(/plan mode/i);
143+
}
144+
expect(create).not.toHaveBeenCalled();
145+
expect(waitForAgentReport).not.toHaveBeenCalled();
146+
});
109147
});

0 commit comments

Comments
 (0)