feat(coding-agent/task): added task recursion depth control and hierarchical subagent naming

- Added task.maxRecursionDepth setting to control how many levels deep subagents can spawn their own subagents (0=none, 1=one level, 2=two levels, -1=unlimited).
- Added nested task artifact naming with parent task prefixes (e.g., '0-Auth.1-Subtask') to support hierarchical task identification.
- Added taskDepth and parentTaskPrefix options to CreateAgentSessionOptions for supporting nested/subagent sessions.
- Changed task tool spawns configuration from 'explore' to '*' to allow spawning any type of subagent.
- Updated system prompt to unconditionally include parallel delegation guidance for all agent types instead of only coordinators.
- Implemented automatic task tool disabling at maximum recursion depth to prevent excessive task nesting.
This commit is contained in:
can1357
2026-02-05 02:18:10 +01:00
parent 1b81e820bf
commit f82a5d121f
11 changed files with 96 additions and 24 deletions
+7 -1
View File
@@ -1,9 +1,11 @@
# Changelog
## [Unreleased]
### Added
- Added `task.maxRecursionDepth` setting to control how many levels deep subagents can spawn their own subagents (0=none, 1=one level, 2=two levels, -1=unlimited)
- Added support for nested task artifact naming with parent task prefixes (e.g., "0-Auth.1-Subtask") to organize hierarchical task outputs
- Added `taskDepth` and `parentTaskPrefix` options to `CreateAgentSessionOptions` for tracking subagent recursion depth and organizing nested artifacts
- Added Grafana Pyroscope continuous profiling integration for CPU and heap profiling
- Added `pyroscope.enabled`, `pyroscope.serverAddress`, `pyroscope.appName`, `pyroscope.basicAuthUser`, `pyroscope.basicAuthPassword`, `pyroscope.tenantID`, and `pyroscope.flushIntervalMs` settings for profiling configuration
- Added support for Pyroscope configuration via environment variables (`PYROSCOPE_URL`, `PYROSCOPE_APP_NAME`, `PYROSCOPE_BASIC_AUTH_USER`, `PYROSCOPE_BASIC_AUTH_PASSWORD`, `PYROSCOPE_TENANT_ID`, `PYROSCOPE_FLUSH_INTERVAL_MS`)
@@ -13,6 +15,9 @@
### Changed
- Changed task tool spawns configuration from "explore" to "*" to allow subagents to spawn any agent type
- Changed system prompt to enable parallel delegation guidance for all agents (removed coordinator-only restriction)
- Changed task tool to automatically disable itself when maximum recursion depth is reached, preventing infinite nesting
- Changed task concurrency from hardcoded constant to configurable setting via `task.maxConcurrency`
- Changed concurrency limit calculation to support unlimited concurrency when set to 0
@@ -22,6 +27,7 @@
### Fixed
- Fixed task ID display formatting to show hierarchical structure for nested tasks (e.g., "0.1 Auth>Subtask" instead of "0-Auth.1-Subtask")
- Improved frontmatter parsing error messages to include source context for better debugging
## [10.6.1] - 2026-02-04
@@ -410,6 +410,17 @@ export const SETTINGS_SCHEMA = {
submenu: true,
},
},
"task.maxRecursionDepth": {
type: "number",
default: 2,
ui: {
tab: "tools",
label: "Task max recursion depth",
description:
"How many levels deep subagents can spawn their own subagents (0=none, 1=one level, 2=two levels, -1=unlimited)",
submenu: true,
},
},
// ─────────────────────────────────────────────────────────────────────────
// Startup settings
@@ -281,7 +281,6 @@ Suppress:
{{appendSystemPrompt}}
{{/if}}
{{#if isCoordinator}}
{{#has tools "task"}}
<parallel_reflex>
When work forks, you fork.
@@ -290,7 +289,7 @@ Notice sequential habit:
- Comfort: one thing at a time
- Illusion: order = correctness
- Assumption: finish A before starting B
**Triggers requiring Task tool:**
**Triggers requiring Task tool:**
- Editing 4+ files with no dependencies between edits
- Investigating 2+ independent subsystems or questions
- Work decomposing into pieces not needing each other's results
@@ -303,7 +302,6 @@ If cannot articulate why B depends on A: parallel.
Don't carry whole problem in one skull. Split load; bring facts back; then cut code.
</parallel_reflex>
{{/has}}
{{/if}}
<stakes>
This is not practice.
+9 -3
View File
@@ -183,6 +183,10 @@ export interface CreateAgentSessionOptions {
outputSchema?: unknown;
/** Whether to include the submit_result tool by default */
requireSubmitResultTool?: boolean;
/** Task recursion depth (for subagent sessions). Default: 0 */
taskDepth?: number;
/** Parent task ID prefix for nested artifact naming (e.g., "6-Extensions") */
parentTaskPrefix?: string;
/** Session manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager;
@@ -728,6 +732,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
eventBus,
outputSchema: options.outputSchema,
requireSubmitResultTool: options.requireSubmitResultTool,
taskDepth: options.taskDepth ?? 0,
getSessionFile: () => sessionManager.getSessionFile() ?? null,
getSessionId: () => sessionManager.getSessionId?.() ?? null,
getSessionSpawns: () => options.spawns ?? "*",
@@ -772,7 +777,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
);
toolSession.internalRouter = internalRouter;
toolSession.getArtifactsDir = getArtifactsDir;
toolSession.agentOutputManager = new AgentOutputManager(getArtifactsDir);
toolSession.agentOutputManager = new AgentOutputManager(
getArtifactsDir,
options.parentTaskPrefix ? { parentPrefix: options.parentTaskPrefix } : undefined,
);
debugStartup("sdk:createTools:start");
// Create and wrap tools with meta notice formatting
@@ -996,7 +1004,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
toolNames,
rules: rulebookRules,
skillsSettings: settingsInstance.getGroup("skills") as SkillsSettings,
isCoordinator: options.hasUI,
});
if (options.systemPrompt === undefined) {
@@ -1013,7 +1020,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
rules: rulebookRules,
skillsSettings: settingsInstance.getGroup("skills") as SkillsSettings,
customPrompt: options.systemPrompt,
isCoordinator: options.hasUI,
});
}
return options.systemPrompt(defaultPrompt);
@@ -444,8 +444,6 @@ export interface BuildSystemPromptOptions {
preloadedSkills?: Skill[];
/** Pre-loaded rulebook rules (rules with descriptions, excluding TTSR and always-apply). */
rules?: Array<{ name: string; description?: string; path: string; globs?: string[] }>;
/** Whether this is the main coordinator agent (not a subagent). Enables parallel delegation emphasis. */
isCoordinator?: boolean;
}
/** Build the system prompt with tools, guidelines, and context */
@@ -465,7 +463,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
skills: providedSkills,
preloadedSkills: providedPreloadedSkills,
rules,
isCoordinator,
} = options;
const resolvedCwd = cwd ?? process.cwd();
const resolvedCustomPrompt = await resolvePromptInput(customPrompt, "system prompt");
@@ -533,7 +530,6 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
rules: rules ?? [],
dateTime,
cwd: resolvedCwd,
isCoordinator: isCoordinator ?? false,
});
}
@@ -550,6 +546,5 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
dateTime,
cwd: resolvedCwd,
appendSystemPrompt: resolvedAppendPrompt ?? "",
isCoordinator: isCoordinator ?? false,
});
}
+1 -1
View File
@@ -46,7 +46,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
frontmatter: {
name: "task",
description: "General-purpose subagent with full capabilities for delegated multi-step tasks",
spawns: "explore",
spawns: "*",
model: "default",
},
template: taskMd,
+21 -3
View File
@@ -157,6 +157,8 @@ export interface ExecutorOptions {
modelOverride?: string | string[];
thinkingLevel?: ThinkingLevel;
outputSchema?: unknown;
/** Parent task recursion depth (0 = top-level, 1 = first child, etc.) */
taskDepth?: number;
enableLsp?: boolean;
signal?: AbortSignal;
onProgress?: (progress: AgentProgress) => void;
@@ -428,17 +430,25 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
subtaskSessionFile = path.join(options.artifactsDir, `${id}.jsonl`);
}
const settings = options.settings ?? Settings.isolated();
const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
const parentDepth = options.taskDepth ?? 0;
const childDepth = parentDepth + 1;
const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
// Add tools if specified
let toolNames: string[] | undefined;
if (agent.tools && agent.tools.length > 0) {
toolNames = agent.tools;
// Auto-include task tool if spawns defined but task not in tools
if (agent.spawns !== undefined && !toolNames.includes("task")) {
if (agent.spawns !== undefined && !toolNames.includes("task") && !atMaxDepth) {
toolNames = [...toolNames, "task"];
}
}
const settings = options.settings ?? Settings.isolated();
if (atMaxDepth && toolNames?.includes("task")) {
toolNames = toolNames.filter(name => name !== "task");
}
const pythonToolMode = settings.get("python.toolMode") ?? "both";
if (toolNames?.includes("exec")) {
const expanded = toolNames.filter(name => name !== "exec");
@@ -454,7 +464,13 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
const modelPatterns = normalizeModelPatterns(modelOverride ?? agent.model);
const sessionFile = subtaskSessionFile ?? null;
const spawnsEnv = agent.spawns === undefined ? "" : agent.spawns === "*" ? "*" : agent.spawns.join(",");
const spawnsEnv = atMaxDepth
? ""
: agent.spawns === undefined
? ""
: agent.spawns === "*"
? "*"
: agent.spawns.join(",");
const lspEnabled = enableLsp ?? true;
const skipPythonPreflight = Array.isArray(toolNames) && !toolNames.includes("python");
@@ -877,6 +893,8 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
sessionManager,
hasUI: false,
spawns: spawnsEnv,
taskDepth: childDepth,
parentTaskPrefix: id,
enableLsp: lspEnabled,
skipPythonPreflight,
enableMCP,
+3
View File
@@ -158,6 +158,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
const { agent: agentName, context, schema: outputSchema, isolated } = params;
const isIsolated = isolated === true;
const maxConcurrency = this.session.settings.get("task.maxConcurrency");
const taskDepth = this.session.taskDepth ?? 0;
// Validate agent exists
const agent = getAgent(agents, agentName);
@@ -447,6 +448,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
index,
id: task.id,
context: undefined, // Already prepended above
taskDepth,
modelOverride,
thinkingLevel: thinkingLevelOverride,
outputSchema: effectiveOutputSchema,
@@ -492,6 +494,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
index,
id: task.id,
context: undefined, // Already prepended above
taskDepth,
modelOverride,
thinkingLevel: thinkingLevelOverride,
outputSchema: effectiveOutputSchema,
@@ -3,24 +3,33 @@
*
* Ensures unique output IDs across task tool invocations within a session.
* Prefixes each ID with a sequential number (e.g., "0-AuthProvider", "1-AuthApi").
* If a parent prefix is provided, IDs are nested (e.g., "0-Auth.1-Subtask").
*
* This enables reliable agent:// URL resolution and prevents artifact collisions.
*/
import * as fs from "node:fs/promises";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Manages agent output ID allocation to ensure uniqueness.
*
* Each allocated ID gets a numeric prefix based on allocation order.
* If configured with a parent prefix, the numeric prefix is appended after
* the parent (e.g., "0-Parent.0-Child").
* On resume, scans existing files to find the next available index.
*/
export class AgentOutputManager {
#nextId = 0;
#initialized = false;
readonly #getArtifactsDir: () => string | null;
readonly #parentPrefix: string | undefined;
constructor(getArtifactsDir: () => string | null) {
constructor(getArtifactsDir: () => string | null, options?: { parentPrefix?: string }) {
this.#getArtifactsDir = getArtifactsDir;
this.#parentPrefix = options?.parentPrefix;
}
/**
@@ -41,12 +50,15 @@ export class AgentOutputManager {
return; // Directory doesn't exist yet
}
const pattern = this.#parentPrefix
? new RegExp(`^${escapeRegExp(this.#parentPrefix)}\\.(\\d+)-.*\\.md$`)
: /^(\d+)-.*\.md$/;
let maxId = -1;
for (const file of files) {
// Agent outputs are named: {index}-{id}.md (e.g., "0-AuthProvider.md")
const match = file.match(/^(\d+)-.*\.md$/);
const match = file.match(pattern);
if (match) {
const id = parseInt(match[1], 10);
const id = Number.parseInt(match[1], 10);
if (id > maxId) maxId = id;
}
}
@@ -61,7 +73,8 @@ export class AgentOutputManager {
*/
async allocate(id: string): Promise<string> {
await this.#ensureInitialized();
return `${this.#nextId++}-${id}`;
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
return `${prefix}${this.#nextId++}-${id}`;
}
/**
@@ -72,7 +85,8 @@ export class AgentOutputManager {
*/
async allocateBatch(ids: string[]): Promise<string[]> {
await this.#ensureInitialized();
return ids.map(id => `${this.#nextId++}-${id}`);
const prefix = this.#parentPrefix ? `${this.#parentPrefix}.` : "";
return ids.map(id => `${prefix}${this.#nextId++}-${id}`);
}
/**
+16 -2
View File
@@ -77,6 +77,18 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
return "";
}
function formatTaskId(id: string): string {
const segments = id.split(".");
if (segments.length < 2) return id;
const parsed = segments.map(segment => segment.match(/^(\d+)-(.+)$/));
if (parsed.some(match => !match)) return id;
const indices = parsed.map(match => match![1]).join(".");
const labels = parsed.map(match => match![2]).join(">");
return `${indices} ${labels}`;
}
const MISSING_SUBMIT_RESULT_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling submit_result tool";
function extractMissingSubmitResultWarning(output: string): { warning?: string; rest: string } {
@@ -518,7 +530,8 @@ function renderAgentProgress(
// Main status line: id: description [status] · stats · ⟨agent⟩
const description = progress.description?.trim();
const titlePart = description ? `${theme.bold(progress.id)}: ${description}` : progress.id;
const displayId = formatTaskId(progress.id);
const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
// Only show badge for non-running states (spinner already indicates running)
@@ -748,7 +761,8 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
// Main status line: id: description [status] · stats · ⟨agent⟩
const description = result.description?.trim();
const titlePart = description ? `${theme.bold(result.id)}: ${description}` : result.id;
const displayId = formatTaskId(result.id);
const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
statusText,
iconColor,
+7
View File
@@ -127,6 +127,8 @@ export interface ToolSession {
outputSchema?: unknown;
/** Whether to include the submit_result tool by default */
requireSubmitResultTool?: boolean;
/** Task recursion depth (0 = top-level, 1 = first child, etc.) */
taskDepth?: number;
/** Get session file */
getSessionFile: () => string | null;
/** Get session ID */
@@ -287,6 +289,11 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "lsp") return session.settings.get("lsp.enabled");
if (name === "calc") return session.settings.get("calc.enabled");
if (name === "browser") return session.settings.get("browser.enabled");
if (name === "task") {
const maxDepth = session.settings.get("task.maxRecursionDepth") ?? 2;
const currentDepth = session.taskDepth ?? 0;
return maxDepth < 0 || currentDepth < maxDepth;
}
return true;
};
if (includeSubmitResult && requestedTools && !requestedTools.includes("submit_result")) {