Skip to content

Commit f89b880

Browse files
committed
🤖 fix: enforce agent task queue + depth tool gating
- Queue agent tasks without creating worktrees until dequeued. - Persist queued prompts in metadata and render them in queued workspaces without auto-resume/backoff. - Disable task/task_* tools once maxTaskNestingDepth is reached. Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `mux` • Model: unknown • Thinking: unknown_ <!-- mux-attribution: model=unknown thinking=unknown --> Change-Id: Icf17d2634b2aff2061f75b44fdd8a6b63b887247
1 parent a045958 commit f89b880

File tree

9 files changed

+741
-156
lines changed

9 files changed

+741
-156
lines changed

src/browser/components/AIView.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import { ReviewsBanner } from "./ReviewsBanner";
7373
import type { ReviewNoteData } from "@/common/types/review";
7474
import { PopoverError } from "./PopoverError";
7575
import { ConnectionStatusIndicator } from "./ConnectionStatusIndicator";
76+
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
7677

7778
interface AIViewProps {
7879
workspaceId: string;
@@ -99,6 +100,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
99100
status,
100101
}) => {
101102
const { api } = useAPI();
103+
const { workspaceMetadata } = useWorkspaceContext();
102104
const chatAreaRef = useRef<HTMLDivElement>(null);
103105

104106
// Track which right sidebar tab is selected (listener: true to sync with RightSidebar changes)
@@ -134,6 +136,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
134136
const { statsTabState } = useFeatureFlags();
135137
const statsEnabled = Boolean(statsTabState?.enabled);
136138
const workspaceState = useWorkspaceState(workspaceId);
139+
const meta = workspaceMetadata.get(workspaceId);
140+
const isQueuedAgentTask = Boolean(meta?.parentWorkspaceId) && meta?.taskStatus === "queued";
141+
const queuedAgentTaskPrompt =
142+
isQueuedAgentTask && typeof meta?.taskPrompt === "string" && meta.taskPrompt.trim().length > 0
143+
? meta.taskPrompt
144+
: null;
145+
const shouldShowQueuedAgentTaskPrompt =
146+
Boolean(queuedAgentTaskPrompt) && (workspaceState?.messages.length ?? 0) === 0;
137147
const aggregator = useWorkspaceAggregator(workspaceId);
138148
const workspaceUsage = useWorkspaceUsage(workspaceId);
139149

@@ -727,6 +737,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
727737
}
728738
/>
729739
)}
740+
{shouldShowQueuedAgentTaskPrompt && (
741+
<QueuedMessage
742+
message={{
743+
id: `queued-agent-task-${workspaceId}`,
744+
content: queuedAgentTaskPrompt ?? "",
745+
}}
746+
/>
747+
)}
730748
{workspaceState?.queuedMessage && (
731749
<QueuedMessage
732750
message={workspaceState.queuedMessage}
@@ -775,7 +793,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
775793
onMessageSent={handleMessageSent}
776794
onTruncateHistory={handleClearHistory}
777795
onProviderConfig={handleProviderConfig}
778-
disabled={!projectName || !workspaceName}
796+
disabled={!projectName || !workspaceName || isQueuedAgentTask}
779797
isCompacting={isCompacting}
780798
editingMessage={editingMessage}
781799
onCancelEdit={handleCancelEdit}

src/common/orpc/schemas/project.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export const WorkspaceConfigSchema = z.object({
5050
taskThinkingLevel: ThinkingLevelSchema.optional().meta({
5151
description: "Thinking level used for this agent task (used for restart-safe resumptions).",
5252
}),
53+
taskPrompt: z.string().optional().meta({
54+
description:
55+
"Initial prompt for a queued agent task (persisted only until the task actually starts).",
56+
}),
57+
taskTrunkBranch: z.string().optional().meta({
58+
description:
59+
"Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).",
60+
}),
5361
mcp: WorkspaceMCPOverridesSchema.optional().meta({
5462
description: "Per-workspace MCP overrides (disabled servers, tool allowlists)",
5563
}),

src/common/orpc/schemas/workspace.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { z } from "zod";
22
import { RuntimeConfigSchema } from "./runtime";
33
import { WorkspaceAISettingsSchema } from "./workspaceAiSettings";
44

5+
const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]);
6+
57
export const WorkspaceMetadataSchema = z.object({
68
id: z.string().meta({
79
description:
@@ -38,6 +40,27 @@ export const WorkspaceMetadataSchema = z.object({
3840
description:
3941
'If set, selects an agent preset for this workspace (e.g., "research" or "explore").',
4042
}),
43+
taskStatus: z.enum(["queued", "running", "awaiting_report", "reported"]).optional().meta({
44+
description:
45+
"Agent task lifecycle status for child workspaces (queued|running|awaiting_report|reported).",
46+
}),
47+
reportedAt: z.string().optional().meta({
48+
description: "ISO 8601 timestamp for when an agent task reported completion (optional).",
49+
}),
50+
taskModelString: z.string().optional().meta({
51+
description: "Model string used to run this agent task (used for restart-safe resumptions).",
52+
}),
53+
taskThinkingLevel: ThinkingLevelSchema.optional().meta({
54+
description: "Thinking level used for this agent task (used for restart-safe resumptions).",
55+
}),
56+
taskPrompt: z.string().optional().meta({
57+
description:
58+
"Initial prompt for a queued agent task (persisted only until the task actually starts).",
59+
}),
60+
taskTrunkBranch: z.string().optional().meta({
61+
description:
62+
"Trunk branch used to create/init this agent task workspace (used for restart-safe init on queued tasks).",
63+
}),
4164
status: z.enum(["creating"]).optional().meta({
4265
description:
4366
"Workspace creation status. 'creating' = pending setup (ephemeral, not persisted). Absent = ready.",

src/node/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,12 @@ export class Config {
358358
aiSettings: workspace.aiSettings,
359359
parentWorkspaceId: workspace.parentWorkspaceId,
360360
agentType: workspace.agentType,
361+
taskStatus: workspace.taskStatus,
362+
reportedAt: workspace.reportedAt,
363+
taskModelString: workspace.taskModelString,
364+
taskThinkingLevel: workspace.taskThinkingLevel,
365+
taskPrompt: workspace.taskPrompt,
366+
taskTrunkBranch: workspace.taskTrunkBranch,
361367
};
362368

363369
// Migrate missing createdAt to config for next load
@@ -403,6 +409,12 @@ export class Config {
403409
// Preserve tree/task metadata when present in config (metadata.json won't have it)
404410
metadata.parentWorkspaceId ??= workspace.parentWorkspaceId;
405411
metadata.agentType ??= workspace.agentType;
412+
metadata.taskStatus ??= workspace.taskStatus;
413+
metadata.reportedAt ??= workspace.reportedAt;
414+
metadata.taskModelString ??= workspace.taskModelString;
415+
metadata.taskThinkingLevel ??= workspace.taskThinkingLevel;
416+
metadata.taskPrompt ??= workspace.taskPrompt;
417+
metadata.taskTrunkBranch ??= workspace.taskTrunkBranch;
406418
// Migrate to config for next load
407419
workspace.id = metadata.id;
408420
workspace.name = metadata.name;
@@ -429,6 +441,12 @@ export class Config {
429441
aiSettings: workspace.aiSettings,
430442
parentWorkspaceId: workspace.parentWorkspaceId,
431443
agentType: workspace.agentType,
444+
taskStatus: workspace.taskStatus,
445+
reportedAt: workspace.reportedAt,
446+
taskModelString: workspace.taskModelString,
447+
taskThinkingLevel: workspace.taskThinkingLevel,
448+
taskPrompt: workspace.taskPrompt,
449+
taskTrunkBranch: workspace.taskTrunkBranch,
432450
};
433451

434452
// Save to config for next load
@@ -456,6 +474,12 @@ export class Config {
456474
aiSettings: workspace.aiSettings,
457475
parentWorkspaceId: workspace.parentWorkspaceId,
458476
agentType: workspace.agentType,
477+
taskStatus: workspace.taskStatus,
478+
reportedAt: workspace.reportedAt,
479+
taskModelString: workspace.taskModelString,
480+
taskThinkingLevel: workspace.taskThinkingLevel,
481+
taskPrompt: workspace.taskPrompt,
482+
taskTrunkBranch: workspace.taskTrunkBranch,
459483
};
460484
workspaceMetadata.push(this.addPathsToMetadata(metadata, workspace.path, projectPath));
461485
}

src/node/services/agentPresets.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ const RESEARCH_PRESET: AgentPreset = {
3030
"Rules:",
3131
"- Do not edit files.",
3232
"- Do not run bash commands unless explicitly enabled (assume it is not).",
33-
"- If you need repository exploration beyond file_read, delegate to an Explore sub-agent via the task tool.",
33+
"- If the task tool is available and you need repository exploration beyond file_read, delegate to an Explore sub-agent.",
3434
"",
3535
"Delegation:",
36-
'- Use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.',
36+
'- If available, use: task({ subagent_type: "explore", prompt: "..." }) when you need repo exploration.',
3737
"",
3838
"Reporting:",
3939
"- When you have a final answer, call agent_report exactly once.",
@@ -66,10 +66,10 @@ const EXPLORE_PRESET: AgentPreset = {
6666
"Rules:",
6767
"- Do not edit files.",
6868
"- Treat bash as read-only: prefer commands like rg, ls, cat, git show, git diff (read-only).",
69-
"- If you need external information, delegate to a Research sub-agent via the task tool.",
69+
"- If the task tool is available and you need external information, delegate to a Research sub-agent.",
7070
"",
7171
"Delegation:",
72-
'- Use: task({ subagent_type: "research", prompt: "..." }) when you need web research.',
72+
'- If available, use: task({ subagent_type: "research", prompt: "..." }) when you need web research.',
7373
"",
7474
"Reporting:",
7575
"- When you have a final answer, call agent_report exactly once.",

src/node/services/aiService.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type { MCPServerManager, MCPWorkspaceStats } from "@/node/services/mcpSer
5151
import type { TaskService } from "@/node/services/taskService";
5252
import { buildProviderOptions } from "@/common/utils/ai/providerOptions";
5353
import type { ThinkingLevel } from "@/common/types/thinking";
54+
import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks";
5455
import type {
5556
StreamAbortEvent,
5657
StreamDeltaEvent,
@@ -344,6 +345,36 @@ function parseModelString(modelString: string): [string, string] {
344345
return [providerName, modelId];
345346
}
346347

348+
function getTaskDepthFromConfig(
349+
config: ReturnType<Config["loadConfigOrDefault"]>,
350+
workspaceId: string
351+
): number {
352+
const parentById = new Map<string, string | undefined>();
353+
for (const project of config.projects.values()) {
354+
for (const workspace of project.workspaces) {
355+
if (!workspace.id) continue;
356+
parentById.set(workspace.id, workspace.parentWorkspaceId);
357+
}
358+
}
359+
360+
let depth = 0;
361+
let current = workspaceId;
362+
for (let i = 0; i < 32; i++) {
363+
const parent = parentById.get(current);
364+
if (!parent) break;
365+
depth += 1;
366+
current = parent;
367+
}
368+
369+
if (depth >= 32) {
370+
throw new Error(
371+
`getTaskDepthFromConfig: possible parentWorkspaceId cycle starting at ${workspaceId}`
372+
);
373+
}
374+
375+
return depth;
376+
}
377+
347378
export class AIService extends EventEmitter {
348379
private readonly streamManager: StreamManager;
349380
private readonly historyService: HistoryService;
@@ -1199,7 +1230,23 @@ export class AIService extends EventEmitter {
11991230
}
12001231
}
12011232

1233+
const cfg = this.config.loadConfigOrDefault();
1234+
const taskSettings = cfg.taskSettings ?? DEFAULT_TASK_SETTINGS;
1235+
const taskDepth = getTaskDepthFromConfig(cfg, workspaceId);
1236+
const shouldDisableTaskToolsForDepth = taskDepth >= taskSettings.maxTaskNestingDepth;
1237+
12021238
const agentPreset = getAgentPreset(metadata.agentType);
1239+
const agentSystemPrompt = agentPreset
1240+
? shouldDisableTaskToolsForDepth
1241+
? [
1242+
agentPreset.systemPrompt,
1243+
"",
1244+
"Nesting:",
1245+
`- Task delegation is disabled in this workspace (taskDepth=${taskDepth}, maxTaskNestingDepth=${taskSettings.maxTaskNestingDepth}).`,
1246+
"- Do not call task/task_await/task_list/task_terminate.",
1247+
].join("\n")
1248+
: agentPreset.systemPrompt
1249+
: undefined;
12031250

12041251
// Build system message from workspace metadata
12051252
const systemMessage = await buildSystemMessage(
@@ -1210,7 +1257,7 @@ export class AIService extends EventEmitter {
12101257
effectiveAdditionalInstructions,
12111258
modelString,
12121259
mcpServers,
1213-
agentPreset ? { variant: "agent", agentSystemPrompt: agentPreset.systemPrompt } : undefined
1260+
agentSystemPrompt ? { variant: "agent", agentSystemPrompt } : undefined
12141261
);
12151262

12161263
// Count system message tokens for cost tracking
@@ -1303,10 +1350,18 @@ export class AIService extends EventEmitter {
13031350
mcpTools
13041351
);
13051352

1306-
// Preset tool policy must be applied last so callers cannot re-enable restricted tools.
1307-
const effectiveToolPolicy = agentPreset
1308-
? [...(toolPolicy ?? []), ...agentPreset.toolPolicy]
1309-
: toolPolicy;
1353+
const depthToolPolicy: ToolPolicy = shouldDisableTaskToolsForDepth
1354+
? [
1355+
{ regex_match: "task", action: "disable" },
1356+
{ regex_match: "task_.*", action: "disable" },
1357+
]
1358+
: [];
1359+
1360+
// Preset + depth tool policies must be applied last so callers cannot re-enable restricted tools.
1361+
const effectiveToolPolicy =
1362+
agentPreset || depthToolPolicy.length > 0
1363+
? [...(toolPolicy ?? []), ...(agentPreset?.toolPolicy ?? []), ...depthToolPolicy]
1364+
: toolPolicy;
13101365

13111366
// Apply tool policy FIRST - this must happen before PTC to ensure sandbox
13121367
// respects allow/deny filters. The policy-filtered tools are passed to

0 commit comments

Comments
 (0)