Skip to content
Merged
2 changes: 2 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,8 @@ export async function loadCliConfig(
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
maxWallTimeSeconds: resolveMaxWallTimeSeconds(argv, settings),
maxToolCalls: resolveMaxToolCalls(argv, settings),
// Undefined flows through to Config's default (5) and clamp logic.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No --max-subagent-depth CLI flag, unlike sibling settings --max-session-turns (resolved via resolveMaxSessionTurns(argv, settings)) and --max-tool-calls (resolved via resolveMaxToolCalls(argv, settings)). Users cannot override nesting depth for a single run from the command line. Consider adding a --max-subagent-depth flag with a resolver for consistency.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 2c50077 — --max-subagent-depth mirrors the sibling budget flags: flag takes precedence over model.maxSubagentDepth, validates loudly as an integer in 1–100 (yargs NaN from a typo fails at startup instead of silently falling back), and the setting is now documented in settings.md alongside the flag.

maxSubagentDepth: settings.model?.maxSubagentDepth,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
cronEnabled: settings.experimental?.cron ?? true,
agentTeamEnabled: settings.experimental?.agentTeam ?? false,
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,6 +1360,16 @@ const SETTINGS_SCHEMA = {
'Cumulative tool-call budget for a run (counts every executed tool, success or failure; structured_output under --json-schema is exempt). -1 means unlimited; 0 means "no tool calls allowed" (first call aborts). Capped at 1,000,000 to catch typos. Overridable via --max-tool-calls.',
showInDialog: false,
},
maxSubagentDepth: {
type: 'number',
label: 'Max Sub-agent Nesting Depth',
category: 'Model',
requiresRestart: false,
default: 5,
description:
'Maximum number of nested sub-agent levels. 1 disables nesting (sub-agents cannot spawn sub-agents); the default 5 allows a sub-agent to spawn sub-agents up to five levels deep. Values below 1 are clamped to 1. Teammates, forks, and the workflow tool are excluded from nesting.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This schema description is already stale relative to the runtime and settings.md: it only mentions clamping values below 1 and says "the workflow tool" is excluded, but the implementation now caps values at 100, falls back to the default for non-finite values, and the scoped exclusion is workflow-spawned agents. The generated VS Code schema carries the same wording, so settings UI/schema consumers will see behavior that disagrees with the actual config handling.

Suggested change
'Maximum number of nested sub-agent levels. 1 disables nesting (sub-agents cannot spawn sub-agents); the default 5 allows a sub-agent to spawn sub-agents up to five levels deep. Values below 1 are clamped to 1. Teammates, forks, and the workflow tool are excluded from nesting.',
'Maximum sub-agent nesting depth (1-based levels: a top-level sub-agent is level 1). 1 keeps sub-agents available but disables nesting; the default 5 allows nesting up to five levels deep. Values clamp to the range 1-100; non-finite values fall back to the default. Teammates, forks, and workflow-spawned agents never nest regardless of this setting. Overridable via --max-subagent-depth.',

— gpt-5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5477164 — took the suggested wording for settingsSchema.ts and regenerated the VS Code schema from it (scripts/generate-settings-schema.ts), so both now match settings.md and the runtime behavior.

showInDialog: false,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/agents/agent-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface AgentMeta {
agentColor?: string;
/** Number of explicit resume attempts performed so far. */
resumeCount?: number;
/** Nesting depth at launch time; restored on background/foreground resume. */
depth?: number;
/** Last terminal error, if any. */
lastError?: string;
}
Expand All @@ -135,6 +137,12 @@ export interface AgentPersistedCliFlags {
model?: string;
maxSessionTurns?: number;
maxToolCalls?: number;
/**
* Launch-time nesting cap. Interprets the persisted `depth` — without it a
* nested agent launched under a lower cap would resume after restart under
* the new session's (or default) cap and regain spawn capacity.
*/
maxSubagentDepth?: number;
}

/**
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,11 @@ export class BackgroundAgentResumeService {
}
};

const framedRunBody = () => runWithAgentContext(meta.agentId, runBody);
// Restore the persisted launch depth so a resumed nested agent keeps
// its original nesting level (and spawn eligibility) instead of
// recomputing to depth 0 from this top-level resume frame.
const framedRunBody = () =>
runWithAgentContext(meta.agentId, runBody, meta.depth);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meta.depth flows into the depth override unvalidated — the other half of the tampered-sidecar defense is missing.

meta comes from readAgentMeta(), which is a bare JSON.parse(...) as AgentMeta, and nothing downstream clamps the value: runWithAgentContext only does depthOverride ?? ... (catches null/undefined only), and childLaunchDepth() / canSpawnNestedAgent() do raw arithmetic.

So a malformed or hand-edited sidecar with "depth": -50 pins the resumed frame at −50 → childLaunchDepth() = −49 → canSpawnNestedAgent() passes for every cap, and the resumed subtree gains ~50 levels beyond maxSubagentDepth. Worse, "depth": -1e309 parses to -Infinity, and since -Infinity + 1 === -Infinity, every descendant inherits it — the cap is permanently defeated for the whole subtree.

This is exactly the threat model the same resume path already defends against for the sibling field: applyPersistedCliFlagOverrides re-normalizes persistedCliFlags.maxSubagentDepth because "a malformed or tampered sidecar … must not bypass the nesting cap". The cap check is effectively depth + 2 <= maxDepth; right now the right-hand operand is hardened and the left-hand operand is trusted raw from the same JSON file. This is also the only place a disk-persisted depth re-enters the runtime, so it's the complete fix site.

Suggest mirroring the normalize pattern before pinning:

const restoredDepth = Number.isFinite(meta.depth)
  ? Math.max(0, Math.floor(meta.depth!))
  : undefined; // fall back to auto-increment
const framedRunBody = () =>
  runWithAgentContext(meta.agentId, runBody, restoredDepth);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4b0c5ac — normalizeResumedAgentDepth() (next to the AgentMeta type) now guards the depthOverride. Absent values resume as a fresh launch; anything but an integer in 0–100 fails CLOSED to the depth ceiling — clamping a corrupt value down to 0 would fail open by granting full spawn capacity, so the resumed agent keeps running but cannot spawn. Unit tests cover -50, ±Infinity, NaN, fractionals, and >100; the resume-path test is parameterized over a tampered -50 sidecar asserting the frame pins at 100.

void (target.isFork ? runInForkContext(framedRunBody) : framedRunBody());
return entry;
} catch (error) {
Expand Down
26 changes: 15 additions & 11 deletions packages/core/src/agents/runtime/agent-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,16 +200,20 @@ describe('agent-context (depth) — #3731 Phase 3', () => {
});
});

it('callers do not pass depth — it is computed from parent frame only', async () => {
// Defensive: confirm `runWithAgentContext`'s signature still takes
// only (agentId, fn). Phase 3 depth tracking must remain a
// caller-invisible internal concern.
await runWithAgentContext('outer', async () => {
const before = getCurrentAgentDepth();
// No way to pass depth in — the helper computes it.
await runWithAgentContext('inner', async () => {
expect(getCurrentAgentDepth()).toBe(before + 1);
});
});
it('depthOverride pins the depth for background/foreground resume', async () => {
// Resume runs from a top-level frame; without the override a resumed
// nested agent would recompute to depth 0 and regain spawn capacity.
// Passing the persisted launch depth restores the original level, and a
// child frame still auto-increments from the restored value.
await runWithAgentContext(
'resumed',
async () => {
expect(getCurrentAgentDepth()).toBe(3);
await runWithAgentContext('child', async () => {
expect(getCurrentAgentDepth()).toBe(4);
});
},
3,
);
});
});
46 changes: 44 additions & 2 deletions packages/core/src/agents/runtime/agent-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,16 @@ const storage = new AsyncLocalStorage<AgentContext>();
export function runWithAgentContext<T>(
agentId: string,
fn: () => Promise<T>,
depthOverride?: number,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Stale comment at line 38: the AgentContext.depth docstring says "Auto-incremented; callers do not pass it" but this new depthOverride parameter explicitly allows callers to pin the depth (used by background-resume, AgentInteractive, and deferred-approval paths). Update to e.g. "Auto-incremented by default; depthOverride restores the original launch depth on resume paths."

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2c50077 — the docstring now says the depth is auto-incremented by default and that resume paths (background resume, AgentInteractive, deferred approvals) pin the original launch depth via depthOverride.

): Promise<T> {
const current = storage.getStore() ?? {};
// Auto-increment depth: top-level = 0, nested = parent+1. No caller has
// to know about it; telemetry reads it back via getCurrentAgentDepth
// (#3731 Phase 3 subagent spans).
const depth = (current.depth ?? -1) + 1;
// (#3731 Phase 3 subagent spans). depthOverride restores the original
// launch depth on background/foreground resume — otherwise the frame
// recomputes from a top-level (depth 0) parent and a resumed nested agent
// would regain spawn capacity it should not have.
const depth = depthOverride ?? (current.depth ?? -1) + 1;
return storage.run({ ...current, agentId, depth }, fn);
}

Expand Down Expand Up @@ -89,3 +93,41 @@ export function getRuntimeContentGenerator():
| undefined {
return storage.getStore()?.runtimeView;
}

/**
* True when there is no active agent frame — i.e. we are in the top-level
* user session, not inside a sub-agent. The canonical "top-level only"
* predicate for gating capabilities (teammate spawning, forking) that must
* not be reachable from a nested sub-agent.
*/
export function isTopLevelSession(): boolean {
return getCurrentAgentId() === null;
}

/**
* The 0-based depth a child spawned by the current invoker would have:
* 0 when spawning from the top-level session (no agent frame), parent
* depth + 1 inside a sub-agent frame. Single source of the launch-depth
* formula — used for subagent telemetry spans, persisted in AgentMeta so
* background/foreground resume can restore the original nesting level (via
* the runWithAgentContext depthOverride), and underlying
* {@link canSpawnNestedAgent}.
*/
export function childLaunchDepth(): number {
return getCurrentAgentId() !== null ? getCurrentAgentDepth() + 1 : 0;
}

/**
* Whether the current invoker may spawn a nested sub-agent given the
* configured maximum nesting depth (1-based levels; a top-level sub-agent is
* level 1). Single source of truth for the depth relationship, shared by
* AgentCore.prepareTools() (schema gating) and AgentTool.execute() (runtime
* guard) so the two cannot drift apart.
*
* The would-be child sits at level `childLaunchDepth() + 1` (levels are
* 1-based, depths 0-based), which must not exceed `maxDepth`. See
* knowledge/qwen-code/design/nested-subagents.md.
*/
export function canSpawnNestedAgent(maxDepth: number): boolean {
return childLaunchDepth() + 1 <= maxDepth;
}
135 changes: 134 additions & 1 deletion packages/core/src/agents/runtime/agent-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { describe, it, expect, vi } from 'vitest';
import type { FunctionDeclaration } from '@google/genai';
import { AgentCore } from './agent-core.js';
import {
getCurrentAgentDepth,
getCurrentAgentId,
getRuntimeContentGenerator,
runWithAgentContext,
runWithRuntimeContentGenerator,
type RuntimeContentGeneratorView,
} from './agent-context.js';
import { subagentNameContext } from '../../utils/subagentNameContext.js';
import { ToolNames } from '../../tools/tool-names.js';
import {
getAgentName,
getTeammateContext,
Expand All @@ -33,7 +35,6 @@ import type {
ContentGenerator,
ContentGeneratorConfig,
} from '../../core/contentGenerator.js';
import { ToolNames } from '../../tools/tool-names.js';

describe('AgentCore.runInAgentFrames', () => {
// The deferred-approval `respond` callback that AgentCore hands to the
Expand Down Expand Up @@ -221,6 +222,49 @@ describe('AgentCore.runInAgentFrames', () => {
expect(observedAgentId).toBe('agent-123');
});

it('restores the nesting depth for deferred-approval continuations', async () => {
// Regression (codex review): the respond closure captured only the agent
// id, so runWithAgentContext recomputed depth 0 from the UI's frame-less
// chain — a deferred-approved `agent` tool call from a leaf-depth
// sub-agent would then bypass maxSubagentDepth. The closure must carry
// the depth captured at emit time.
const core = makeCore('approval-agent');

let respondClosure: (() => Promise<void>) | undefined;
let observedDepth: number | null = null;
const onConfirm = async () => {
observedDepth = getCurrentAgentDepth();
};

// Emit from a nested frame (depth 2), mirroring a sub-agent of a
// sub-agent whose tool call parks for approval.
await runWithAgentContext('lvl1', () =>
runWithAgentContext('lvl2', () =>
runWithAgentContext('lvl3', async () => {
const inheritedAgentId = getCurrentAgentId();
const inheritedAgentDepth = getCurrentAgentDepth();
expect(inheritedAgentDepth).toBe(2);
respondClosure = () =>
core.runInAgentFrames(
onConfirm,
undefined,
inheritedAgentId ?? undefined,
undefined,
inheritedAgentDepth,
);
}),
),
);

// The frames are gone; respond fires from a fresh chain like the UI.
expect(getCurrentAgentId()).toBeNull();
await new Promise((resolve) => setImmediate(resolve));

await respondClosure!();

expect(observedDepth).toBe(2);
});

it('restores the teammate identity for deferred-approval continuations', async () => {
// Regression: a teammate's `send_message`/`task_update` that requires
// confirmation resumes from the UI's async chain, outside the
Expand Down Expand Up @@ -305,6 +349,7 @@ describe('AgentCore.prepareTools', () => {
function buildAgentForTools(
toolConfig: ToolConfig | undefined,
fnDeclarations: FunctionDeclaration[],
maxSubagentDepth = 5,
): {
core: AgentCore;
debugSpy: ReturnType<typeof vi.fn>;
Expand All @@ -323,6 +368,7 @@ describe('AgentCore.prepareTools', () => {
getFunctionDeclarations: getFunctionDeclarationsSpy,
getFunctionDeclarationsFiltered: getFunctionDeclarationsFilteredSpy,
}),
getMaxSubagentDepth: vi.fn().mockReturnValue(maxSubagentDepth),
} as unknown as Config;

const core = new AgentCore(
Expand Down Expand Up @@ -607,4 +653,91 @@ describe('AgentCore.prepareTools', () => {
expect(response?.error).not.toContain('not found');
},
);

// ─── Nested sub-agents ──────────────────────────────────────────
// The AgentTool is depth-gated: available to a sub-agent only while
// maxSubagentDepth still permits another level. prepareTools() reads the
// sub-agent's own 0-based depth from getCurrentAgentDepth(); a child sits
// at level (depth + 2), which must not exceed the cap.
const nestingDecls = (): FunctionDeclaration[] => [
{ name: 'read_file', description: 'read' } as FunctionDeclaration,
{
name: ToolNames.AGENT,
description: 'spawn subagent',
} as FunctionDeclaration,
];

it('nesting: includes AgentTool for a shallow subagent when depth permits', async () => {
const { core } = buildAgentForTools({ tools: ['*'] }, nestingDecls(), 5);
// One frame → getCurrentAgentDepth() === 0 (a top-level sub-agent).
const tools = await runWithAgentContext('lvl1', () => core.prepareTools());
expect(tools.map((t) => t.name)).toContain(ToolNames.AGENT);
});

it('nesting: excludes AgentTool at the leaf depth', async () => {
const { core } = buildAgentForTools({ tools: ['*'] }, nestingDecls(), 2);
// Two nested frames → depth === 1 → level 2 → the leaf when max === 2.
const tools = await runWithAgentContext('lvl1', () =>
runWithAgentContext('lvl2', () => core.prepareTools()),
);
const names = tools.map((t) => t.name);
expect(names).not.toContain(ToolNames.AGENT);
expect(names).toContain('read_file');
});

it('nesting: maxSubagentDepth=1 reproduces the old no-nesting behavior', async () => {
const { core } = buildAgentForTools({ tools: ['*'] }, nestingDecls(), 1);
const tools = await runWithAgentContext('lvl1', () => core.prepareTools());
expect(tools.map((t) => t.name)).not.toContain(ToolNames.AGENT);
});

it('nesting: frameless prepareTools fails closed — AgentTool excluded', async () => {
// prepareTools() only ever serves agents, never the top-level session.
// A missing agent frame means the launch path forgot runWithAgentContext
// (codex review: AgentInteractive.start() before it established its
// frame); such an agent must not be depth-gated as the top-level session.
const { core } = buildAgentForTools({ tools: ['*'] }, nestingDecls(), 5);
const tools = await core.prepareTools();
const names = tools.map((t) => t.name);
expect(names).not.toContain(ToolNames.AGENT);
expect(names).toContain('read_file');
});

it('nesting: teammates never receive the AgentTool regardless of depth', async () => {
const { core } = buildAgentForTools({ tools: ['*'] }, nestingDecls(), 5);
const identity: TeammateIdentity = {
agentId: 'scribe@demo',
agentName: 'scribe',
teamName: 'demo',
isTeamLead: false,
};
const tools = await runWithTeammateIdentity(identity, () =>
runWithAgentContext('lvl1', () => core.prepareTools()),
);
expect(tools.map((t) => t.name)).not.toContain(ToolNames.AGENT);
});

it('nesting: explicit tools list includes AgentTool only when nesting is allowed', async () => {
const allowed = buildAgentForTools(
{ tools: ['read_file', ToolNames.AGENT] },
nestingDecls(),
5,
);
const allowedTools = await runWithAgentContext('lvl1', () =>
allowed.core.prepareTools(),
);
expect(allowedTools.map((t) => t.name)).toContain(ToolNames.AGENT);

const denied = buildAgentForTools(
{ tools: ['read_file', ToolNames.AGENT] },
nestingDecls(),
1,
);
const deniedTools = await runWithAgentContext('lvl1', () =>
denied.core.prepareTools(),
);
const deniedNames = deniedTools.map((t) => t.name);
expect(deniedNames).not.toContain(ToolNames.AGENT);
expect(deniedNames).toContain('read_file');
});
});
Loading
Loading