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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user