diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 227fb8cf4..b28a2a3db 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed in-process child sessions losing their admission-issued nesting depth and delegation limit. `SubagentChildPolicy` now carries both the admitted `depth` and the effective `maxSubagentDepth`, so the subagent executor can enforce the configured and inherited limits without relying on the removed process-environment bridge ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). + ## [0.9.13-alpha.1] - 2026-08-05 ### Added diff --git a/packages/coding-agent/docs/subagents.md b/packages/coding-agent/docs/subagents.md index 756f02d26..9e302b245 100644 --- a/packages/coding-agent/docs/subagents.md +++ b/packages/coding-agent/docs/subagents.md @@ -174,10 +174,11 @@ subagent({ agent: "worker", task: "Implement it in the background.", progress: t Child-safety boundaries are enforced by typed admission policy and the bundled subagent extension: -- Normal child sessions do not receive the `subagent` tool or the parent-only subagents skill. +- In-process child sessions load bundled extensions through normal discovery. The `subagent` tool may therefore be registered when the child's active tool selection permits it, including the default no-allowlist case; an explicit allowlist may omit it. Tool presence does not grant fanout. The bundled subagents skill remains parent-only and is stripped from child prompts, including fanout-authorized children. - Child context is filtered to remove parent orchestration artifacts, old control/status messages, and prior parent `subagent` tool calls/results. - Non-fanout children are instructed that they are not the parent orchestrator and must not propose or run subagents. - Nested fanout is available only for explicitly authorized agents whose resolved tools include `subagent`. Authorized fanout children receive narrower instructions that limit delegation to the assigned fanout. +- Typed admission policy lets a non-fanout child use only `list`, `get`, `status`, and `doctor`; delegation, `resume`, and `interrupt` receive the fanout refusal. A management-restricted child is also refused `create`, `update`, and `delete`. - The recursion guard has a hard maximum of five delegated subagent levels. The admitted depth policy may choose a lower value from `0` to `5`; deeper admission is refused rather than inherited from process environment state. This keeps the parent session responsible for orchestration unless you deliberately choose a fanout-capable custom agent. diff --git a/packages/coding-agent/docs/workflows.md b/packages/coding-agent/docs/workflows.md index 69e09c512..0ec7b57ef 100644 --- a/packages/coding-agent/docs/workflows.md +++ b/packages/coding-agent/docs/workflows.md @@ -2341,7 +2341,7 @@ readonly excludedTools?: readonly string[]; `tools` is an allowlist across built-in and bundled extension tools; list every tool the stage should see. `excludedTools` and `noTools: "all"` still win. -The bundled `subagent` tool is available by default with the same five delegated-level depth guard as main chat. Bundled subagent definitions from `@bastani/subagents` are available to that tool. Explicitly list tools such as `subagent`, `web_search`, `fetch_content`, or `intercom` when using an allowlist; workflow stages running inside subagent child processes retain isolated resource discovery and the nested-depth guard. +The bundled `subagent` tool is available by default with the same five delegated-level depth guard as main chat. The in-process admission door carries each child’s issued depth and effective delegation limit in its typed child policy; the executor blocks delegation when that depth reaches the stricter of the locally configured maximum and the limit inherited from the parent and the child agent’s own `maxSubagentDepth`, and the Rust `SubagentControl` admission door rejects a child beyond the hard maximum of five. Neither value is carried through process environment. Bundled subagent definitions from `@bastani/subagents` are available to that tool. Explicitly list tools such as `subagent`, `web_search`, `fetch_content`, or `intercom` when using an allowlist; nested in-process child sessions load the bundled resources while suppressing the workflow extension lifecycle and retain the nested-depth guard. Workflow stages use the same upstream-compatible `bash` tool as normal Atomic sessions. Enabled commands run through the configured shell with the stage process permissions. There is no command-text allow/deny option: expose or hide shell access with these tool fields, prefer narrow custom tools for repeatable operations, and use a container, VM, or other sandbox for stronger isolation. diff --git a/packages/coding-agent/src/core/extensions/context-types.ts b/packages/coding-agent/src/core/extensions/context-types.ts index 081d8521d..e14a20113 100644 --- a/packages/coding-agent/src/core/extensions/context-types.ts +++ b/packages/coding-agent/src/core/extensions/context-types.ts @@ -87,6 +87,13 @@ export interface SubagentChildPolicy { readonly fanoutAuthorized: boolean; readonly inheritProjectContext: boolean; readonly inheritSkills: boolean; + /** Current admitted in-process nesting depth; absent for top-level sessions. */ + readonly depth?: number; + /** + * Effective delegation limit inherited from the parent and the child's own + * agent definition; absent when only the local limit applies. + */ + readonly maxSubagentDepth?: number; /** Undefined preserves MCP configuration defaults; [] explicitly disables direct tools. */ readonly mcpDirectTools?: readonly string[]; /** Admission-issued identity/capability; never inherited through process environment. */ diff --git a/packages/subagents/CHANGELOG.md b/packages/subagents/CHANGELOG.md index 6888099d0..131dbe8da 100644 --- a/packages/subagents/CHANGELOG.md +++ b/packages/subagents/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Fixed + +- Fixed every `subagent` action being refused with "Subagent fanout is not authorized for this child." for a child without fanout authorization. The fanout check ran before the management branch, so the observing actions — `list`, `get`, `status`, and `doctor` — were rejected with a message about delegation, which they do not perform. Those four now pass the gate. Fanout authorization gates delegation and every management action that can start or continue agent execution: `resume` revives a child and `interrupt` is privileged control over a running one, so both now receive the fanout refusal instead of reaching their handlers. Mutating management (`create`, `update`, `delete`) is still refused for a management-restricted child, by the narrower gate that this bug had made unreachable. Registration is not authority: a child loads bundled extensions through normal discovery and may therefore have the `subagent` tool registered, while typed admission policy decides which of its actions run. The bundled subagents skill stays parent-only and is stripped from every child prompt, including fanout-authorized children ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). +- Fixed the in-process depth guard and nested workflow-stage children. Admission-issued child depth now travels in the typed policy into every single, parallel, chain, async, and resume path, so the executor can reject delegation at the configured limit while Rust admission keeps the hard five-level ceiling; the orphaned process-environment depth bridge and its self-fulfilling tests are gone. In-process children now load the bundled package resources needed to register `subagent`, so a nested child no longer starts with only the base built-in tools and no way to delegate, while workflow-stage children suppress only the workflow extension lifecycle ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). +- Fixed an agent's `maxSubagentDepth` being dropped at the in-process admission door. A child admitted from an agent whose definition tightened the limit received a policy carrying no maximum, so it could keep delegating as if only the global five-level ceiling applied. The effective limit — the stricter of the parent's limit and the child agent's own — now travels on the admitted child spec and policy, is reissued unchanged by a cold reload, and is applied by the executor's depth check alongside the local configuration. Admission also derives the limit from the agent definition when a caller supplies a child spec without one, so the door no longer issues an unbounded policy for an agent that declared a limit ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). +- Fixed a resumed foreground child losing the delegation limit its agent definition had narrowed. Retained resume re-derived the limit from the current stage or process configuration, so a child that ran under an agent maximum of 1 resumed with the configured maximum instead. The effective limit is now recorded per retained child — parallel and chain branches can each carry a different one — and reused on resume, so editing an agent definition between a run and its resume cannot widen that child's budget ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). + ## [0.9.13-alpha.1] - 2026-08-05 ### Breaking Changes diff --git a/packages/subagents/README.md b/packages/subagents/README.md index 53669a51c..586293900 100644 --- a/packages/subagents/README.md +++ b/packages/subagents/README.md @@ -196,7 +196,7 @@ Use the optional prompt shortcuts below when you want the pattern to be repeatab Packaged `planner`, `worker`, and `oracle` default to forked context when a launch omits `context`; pass `context: "fresh"` when you intentionally want a fresh child run. -Child-safety boundaries are enforced at runtime. Spawned child sessions do not register the `subagent` tool or receive the bundled `pi-subagents` skill unless the parent intentionally selected an explicit fanout agent whose resolved builtin `tools` includes `subagent`. Non-fanout children receive boundary instructions that they are not the parent orchestrator and must not propose or run subagents; authorized fanout children get a narrower boundary that limits nested delegation to the assigned fanout. Forked child context filtering also removes parent-only subagent artifacts (including old hidden orchestration-instruction messages, slash/status/control messages, and prior parent `subagent` tool-call/tool-result history) while preserving ordinary prose and unrelated tool calls/results. +Child-safety boundaries are enforced at runtime by typed admission policy. In-process child sessions load bundled extensions through normal discovery. The `subagent` tool may therefore be registered when the child's active tool selection permits it, including the default no-allowlist case; an explicit allowlist may omit it. Tool presence does not grant fanout: fanout is authorized only when the resolved builtin `tools` list includes `subagent`. Typed admission policy lets a non-fanout child use only `list`, `get`, `status`, and `doctor`; delegation, `resume`, and `interrupt` receive the fanout refusal. A management-restricted child is also refused `create`, `update`, and `delete`. The bundled `pi-subagents` skill remains parent-only and is stripped from child prompts, including fanout-authorized children. Non-fanout children receive boundary instructions that they are not the parent orchestrator and must not propose or run subagents; authorized fanout children get a narrower boundary that limits nested delegation to the assigned fanout. Forked child context filtering also removes parent-only subagent artifacts (including old hidden orchestration-instruction messages, slash/status/control messages, and prior parent `subagent` tool-call/tool-result history) while preserving ordinary prose and unrelated tool calls/results. ## Optional shortcuts @@ -641,7 +641,7 @@ Missing skills do not fail execution. The result summary shows a warning. ### Bundled skill -The package bundles a `subagent` skill that is automatically available to the parent agent when the extension is installed. It is for the orchestrating parent only: child subagents never receive it unless explicitly authorized for fanout, and their context is filtered to strip parent-only orchestration instructions. +The package bundles a `subagent` skill that is automatically available to the parent agent when the extension is installed. It is for the orchestrating parent only: it is stripped from every child prompt, including fanout-authorized children, and child context is filtered to strip parent-only orchestration instructions. A child may still have the `subagent` tool registered; typed admission policy, not the skill, decides which of its actions are allowed. What the bundled skill covers: - **Delegation patterns**: when to launch which agent, whether to use single, parallel, chain, or async mode, and whether to use fresh or forked context diff --git a/packages/subagents/skills/subagent/SKILL.md b/packages/subagents/skills/subagent/SKILL.md index 650f80d92..e52b4c310 100644 --- a/packages/subagents/skills/subagent/SKILL.md +++ b/packages/subagents/skills/subagent/SKILL.md @@ -640,7 +640,7 @@ For complex or risky changes, increase review and validation fanout when user in For very large work, split into serial milestones instead of launching a swarm of writers. Each milestone gets one writer, a validation contract, fresh-context review, a fix pass, and parent approval before the next milestone starts. Use parallel subagents inside a milestone for read-only context, research, and review only. -Keep orchestration authority in the parent session. Child subagents should not launch more subagents, read this skill, or run their own orchestration loops unless the parent intentionally selected an explicit fanout agent whose resolved builtin `tools` includes `subagent` for that assigned fanout. Spawned non-fanout subagents do not receive the `subagent` skill, parent-only status/control/slash messages, prior parent `subagent` tool-call/tool-result artifacts, or the `subagent` extension tool. Child context filtering also strips old hidden orchestration-instruction messages when they appear in inherited history. Every child also receives a boundary instruction that says the parent owns orchestration, the child must not propose or run subagents unless explicitly authorized for fanout, and writer children must call real edit/write tools instead of printing pseudo tool calls. Pass children concrete role-specific work instead. +Keep orchestration authority in the parent session. Child subagents should not launch more subagents or run their own orchestration loops unless the parent intentionally selected an explicit fanout agent whose resolved builtin `tools` includes `subagent` for that assigned fanout. This skill is parent-only: it is stripped from every child prompt, including fanout-authorized children. A child may still have the `subagent` extension tool registered, because bundled extensions load through normal discovery; registration is not authority. Typed admission policy lets a non-fanout child use only `list`, `get`, `status`, and `doctor`, and refuses delegation, `resume`, and `interrupt`. Spawned children also do not receive parent-only status/control/slash messages or prior parent `subagent` tool-call/tool-result artifacts, and child context filtering strips old hidden orchestration-instruction messages when they appear in inherited history. Every child also receives a boundary instruction that says the parent owns orchestration, the child must not propose or run subagents unless explicitly authorized for fanout, and writer children must call real edit/write tools instead of printing pseudo tool calls. Pass children concrete role-specific work instead. 1. Clarify only when needed. Use existing context first; gather missing code or research context selectively, then ask only unresolved questions that materially affect scope, completion criteria, constraints, or non-goals. 2. Define the validation contract. State completion expectations before implementation: expected behavior, checks to run, user flows to exercise, and evidence required in the writer handoff. For UI, CLI, integration, or workflow changes, include at least one validator angle that uses the product the way a user would rather than only reading code. diff --git a/packages/subagents/src/runs/foreground/chain-execution-dynamic-step.ts b/packages/subagents/src/runs/foreground/chain-execution-dynamic-step.ts index 288f9e1a3..6d495b496 100644 --- a/packages/subagents/src/runs/foreground/chain-execution-dynamic-step.ts +++ b/packages/subagents/src/runs/foreground/chain-execution-dynamic-step.ts @@ -171,6 +171,7 @@ export async function runDynamicParallelChainStep(input: { foregroundControl: context.foregroundControl, nestedRoute: context.params.nestedRoute, maxSubagentDepth: context.params.maxSubagentDepth, + parentDepth: context.params.parentDepth, workflowStageSubagentGuard: context.params.workflowStageSubagentGuard, runSync: context.executeRunSync, onDetachedExit: context.onDetachedExit, diff --git a/packages/subagents/src/runs/foreground/chain-execution-parallel-runner.ts b/packages/subagents/src/runs/foreground/chain-execution-parallel-runner.ts index 0c4e6eccb..88a5807a5 100644 --- a/packages/subagents/src/runs/foreground/chain-execution-parallel-runner.ts +++ b/packages/subagents/src/runs/foreground/chain-execution-parallel-runner.ts @@ -139,6 +139,7 @@ export async function runParallelChainTasks(input: ParallelChainRunInput): Promi outputPath, outputMode: behavior.outputMode, maxSubagentDepth, + parentDepth: input.parentDepth, workflowStageSubagentGuard: input.workflowStageSubagentGuard, workflowSessionMetadata: workflowSessionMetadataFromContext(input.ctx), controlConfig: input.controlConfig, diff --git a/packages/subagents/src/runs/foreground/chain-execution-parallel-step.ts b/packages/subagents/src/runs/foreground/chain-execution-parallel-step.ts index 72cd0db7b..9209340c9 100644 --- a/packages/subagents/src/runs/foreground/chain-execution-parallel-step.ts +++ b/packages/subagents/src/runs/foreground/chain-execution-parallel-step.ts @@ -154,6 +154,7 @@ export async function runStaticParallelChainStep(input: { nestedRoute: context.params.nestedRoute, worktreeSetup, maxSubagentDepth: context.params.maxSubagentDepth, + parentDepth: context.params.parentDepth, workflowStageSubagentGuard: context.params.workflowStageSubagentGuard, runSync: context.executeRunSync, onDetachedExit: (index, result) => { diff --git a/packages/subagents/src/runs/foreground/chain-execution-sequential-step.ts b/packages/subagents/src/runs/foreground/chain-execution-sequential-step.ts index b3a9a33db..a88e84c6f 100644 --- a/packages/subagents/src/runs/foreground/chain-execution-sequential-step.ts +++ b/packages/subagents/src/runs/foreground/chain-execution-sequential-step.ts @@ -131,6 +131,7 @@ export async function runSequentialChainStep(input: { outputPath, outputMode: behavior.outputMode, maxSubagentDepth, + parentDepth: context.params.parentDepth, workflowStageSubagentGuard: context.params.workflowStageSubagentGuard, workflowSessionMetadata: workflowSessionMetadataFromContext(context.params.ctx), controlConfig: context.controlConfig, diff --git a/packages/subagents/src/runs/foreground/chain-execution-types.ts b/packages/subagents/src/runs/foreground/chain-execution-types.ts index ed00a5415..763305a98 100644 --- a/packages/subagents/src/runs/foreground/chain-execution-types.ts +++ b/packages/subagents/src/runs/foreground/chain-execution-types.ts @@ -93,6 +93,7 @@ export interface ChainExecutionParams { chainDir?: string; dynamicFanoutMaxItems?: number; maxSubagentDepth: number; + parentDepth?: number; workflowStageSubagentGuard?: boolean; nestedRoute?: NestedRouteInfo; worktreeSetupHook?: string; @@ -146,6 +147,7 @@ export interface ParallelChainRunInput { dynamicGroupStatuses?: ChainExecutionDetailsInput["dynamicGroupStatuses"]; worktreeSetup?: WorktreeSetup; maxSubagentDepth: number; + parentDepth?: number; workflowStageSubagentGuard?: boolean; nestedRoute?: NestedRouteInfo; runSync: RunSyncDependency; diff --git a/packages/subagents/src/runs/foreground/inprocess-run-sync.ts b/packages/subagents/src/runs/foreground/inprocess-run-sync.ts index e593a64e9..db94c33fd 100644 --- a/packages/subagents/src/runs/foreground/inprocess-run-sync.ts +++ b/packages/subagents/src/runs/foreground/inprocess-run-sync.ts @@ -176,7 +176,7 @@ export async function runSingleInProcess( const orchestrationContext = workflowOrchestrationContext(options); const parent: ParentContext = { path: options.runId, - depth: 0, + depth: options.parentDepth ?? 0, ...(options.intercomGroup ? { intercomGroup: options.intercomGroup } : {}), ...(orchestrationContext ? { orchestrationContext } : {}), }; @@ -204,6 +204,7 @@ export async function runSingleInProcess( cwd, testSession: testSession, sessionFile: options.sessionFile, + ...(options.maxSubagentDepth === undefined ? {} : { maxSubagentDepth: options.maxSubagentDepth }), structuredOutput: options.structuredOutput ? { schema: options.structuredOutput.schema, outputPath: options.structuredOutput.outputPath } : undefined, diff --git a/packages/subagents/src/runs/foreground/subagent-executor-async.ts b/packages/subagents/src/runs/foreground/subagent-executor-async.ts index 231d4e567..c7ca9ed09 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-async.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-async.ts @@ -166,6 +166,7 @@ export async function runAsyncPath( availableModels, knownModelProviders, maxSubagentDepth: resolveChildMaxSubagentDepth(depthPolicy.maxSubagentDepth, agent.maxSubagentDepth), + parentDepth: data.parentDepth, workflowStageSubagentGuard: depthPolicy.workflowStageSubagentGuard, worktreeSetupHook: deps.config.worktreeSetupHook, worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs, diff --git a/packages/subagents/src/runs/foreground/subagent-executor-chain.ts b/packages/subagents/src/runs/foreground/subagent-executor-chain.ts index 6381e53d8..54d4104c8 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-chain.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-chain.ts @@ -1,7 +1,7 @@ import { normalizeSkillInput } from "../../agents/skills.ts"; import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts"; import type { ChainStep } from "../../shared/settings.ts"; -import { resolveSubagentDepthPolicy } from "../../shared/types.ts"; +import { resolveChildMaxSubagentDepth, resolveSubagentDepthPolicy } from "../../shared/types.ts"; import { compactForegroundDetails } from "../../shared/utils.ts"; import { updateForegroundNestedProjection } from "../inprocess/runtime-support/nested-api.ts"; import { executeChain } from "./chain-execution.ts"; @@ -70,6 +70,7 @@ export async function runChainPath( chainDir: params.chainDir, dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems, maxSubagentDepth: currentMaxSubagentDepth, + parentDepth: data.parentDepth, workflowStageSubagentGuard, worktreeSetupHook: deps.config.worktreeSetupHook, worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs, @@ -83,7 +84,19 @@ export async function runChainPath( const chainDetails = chainResult.details ? compactForegroundDetails({ ...chainResult.details, runId }) : undefined; if (foregroundControl) updateForegroundNestedProjection(foregroundControl); if (chainDetails) - rememberForegroundRun(deps.state, { runId, mode: "chain", cwd: effectiveCwd, results: chainDetails.results }); + rememberForegroundRun(deps.state, { + runId, + mode: "chain", + cwd: effectiveCwd, + results: chainDetails.results, + // Narrow per step from the definitions this run used, while it is running. + maxSubagentDepths: chainDetails.results.map((result) => + resolveChildMaxSubagentDepth( + currentMaxSubagentDepth, + agents.find((agent) => agent.name === result.agent)?.maxSubagentDepth, + ), + ), + }); const intercomReceipt = chainDetails && !chainDetails.results.some((result) => result.interrupted || result.detached) ? await maybeBuildForegroundIntercomReceipt({ diff --git a/packages/subagents/src/runs/foreground/subagent-executor-context.ts b/packages/subagents/src/runs/foreground/subagent-executor-context.ts index 5975c2a74..cc0a7ee95 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-context.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-context.ts @@ -56,8 +56,8 @@ export function checkDepthForExecution( deps: ResolvedExecutorDeps, ): SubagentToolResult | undefined { const depthPolicy = resolveSubagentDepthPolicy(ctx, deps.config.maxSubagentDepth); - const { blocked, depth, maxDepth, workflowStageGuard } = checkSubagentDepth(depthPolicy.maxSubagentDepth); - const workflowStageSubagentGuard = workflowStageGuard || depthPolicy.workflowStageSubagentGuard; + const { blocked, depth, maxDepth } = checkSubagentDepth(ctx, depthPolicy.maxSubagentDepth); + const workflowStageSubagentGuard = depthPolicy.workflowStageSubagentGuard; if (!blocked) return undefined; return { content: [ @@ -80,7 +80,7 @@ export function prepareExecutionContext(input: { }): ExecutionContextBuildResult { const { params, ctx, signal, onUpdate, deps } = input; const depthPolicy = resolveSubagentDepthPolicy(ctx, deps.config.maxSubagentDepth); - const { depth } = checkSubagentDepth(depthPolicy.maxSubagentDepth); + const { depth } = checkSubagentDepth(ctx, depthPolicy.maxSubagentDepth); const normalized = normalizeRepeatedParallelCounts(params); if (normalized.error) return { error: normalized.error }; const normalizedParams = normalized.params!; @@ -180,6 +180,7 @@ export function prepareExecutionContext(input: { sessionFileForIndex: childSessionFileForIndex, artifactConfig, artifactsDir, + parentDepth: depth, effectiveAsync, controlConfig, intercomBridge, diff --git a/packages/subagents/src/runs/foreground/subagent-executor-parallel-task.ts b/packages/subagents/src/runs/foreground/subagent-executor-parallel-task.ts index fbd5fc9c2..1dceb387a 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-parallel-task.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-parallel-task.ts @@ -38,6 +38,7 @@ interface ForegroundParallelRunInput { maxOutput?: MaxOutputConfig; paramsCwd: string; maxSubagentDepths: number[]; + parentDepth?: number; workflowStageSubagentGuard?: boolean; availableModels: ModelInfo[]; knownModelProviders: string[]; @@ -124,6 +125,7 @@ export async function runForegroundParallelTasks(input: ForegroundParallelRunInp outputPath, outputMode: behavior?.outputMode, maxSubagentDepth: input.maxSubagentDepths[index], + parentDepth: input.parentDepth, workflowStageSubagentGuard: input.workflowStageSubagentGuard, workflowSessionMetadata: workflowSessionMetadataFromContext(input.ctx), controlConfig: input.controlConfig, diff --git a/packages/subagents/src/runs/foreground/subagent-executor-parallel.ts b/packages/subagents/src/runs/foreground/subagent-executor-parallel.ts index ac37384d0..7a0939f64 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-parallel.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-parallel.ts @@ -226,6 +226,7 @@ export async function runParallelPath( foregroundControl, concurrencyLimit: parallelConcurrency, maxSubagentDepths, + parentDepth: data.parentDepth, liveResults, liveProgress, onUpdate, @@ -250,7 +251,13 @@ export async function runParallelPath( progress: params.includeProgress ? allProgress : undefined, artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined, }); - rememberForegroundRun(deps.state, { runId, mode: "parallel", cwd: effectiveCwd, results: details.results }); + rememberForegroundRun(deps.state, { + runId, + mode: "parallel", + cwd: effectiveCwd, + results: details.results, + maxSubagentDepths, + }); if (interrupted) { return { content: [ diff --git a/packages/subagents/src/runs/foreground/subagent-executor-single.ts b/packages/subagents/src/runs/foreground/subagent-executor-single.ts index 7ca72a55a..99eece39c 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-single.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-single.ts @@ -196,6 +196,7 @@ export async function runSinglePath( outputPath, outputMode: effectiveOutputMode, maxSubagentDepth, + parentDepth: data.parentDepth, workflowStageSubagentGuard, workflowSessionMetadata: workflowSessionMetadataFromContext(ctx), onUpdate: forwardSingleUpdate, @@ -262,7 +263,13 @@ export async function runSinglePath( artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined, truncation: r.truncation, }); - rememberForegroundRun(deps.state, { runId, mode: "single", cwd: effectiveCwd, results: details.results }); + rememberForegroundRun(deps.state, { + runId, + mode: "single", + cwd: effectiveCwd, + results: details.results, + maxSubagentDepths: [maxSubagentDepth], + }); if (!r.detached && !r.interrupted) { if (foregroundControl) updateForegroundNestedProjection(foregroundControl); diff --git a/packages/subagents/src/runs/foreground/subagent-executor-status.ts b/packages/subagents/src/runs/foreground/subagent-executor-status.ts index be4a2a7b5..14437d622 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-status.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-status.ts @@ -127,7 +127,14 @@ function takeEarlyDetachedResult(state: SubagentState, runId: string, index: num export function rememberForegroundRun( state: SubagentState, - input: { runId: string; mode: "single" | "parallel" | "chain"; cwd: string; results: SingleResult[] }, + input: { + runId: string; + mode: "single" | "parallel" | "chain"; + cwd: string; + results: SingleResult[]; + /** Effective delegation limit per child, aligned with `results` by index. */ + maxSubagentDepths?: readonly (number | undefined)[]; + }, ): void { state.foregroundRuns ??= new Map(); state.foregroundRuns.set(input.runId, { @@ -137,6 +144,7 @@ export function rememberForegroundRun( updatedAt: Date.now(), children: input.results.map((originalResult, index) => { const result = takeEarlyDetachedResult(state, input.runId, index) ?? originalResult; + const maxSubagentDepth = input.maxSubagentDepths?.[index]; return { agent: result.agent, index, @@ -146,6 +154,7 @@ export function rememberForegroundRun( detached: result.detached, }), ...(result.sessionFile ? { sessionFile: result.sessionFile } : {}), + ...(maxSubagentDepth === undefined ? {} : { maxSubagentDepth }), result, }; }), diff --git a/packages/subagents/src/runs/foreground/subagent-executor-types.ts b/packages/subagents/src/runs/foreground/subagent-executor-types.ts index 0629e5b8e..2b0ceab4f 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor-types.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor-types.ts @@ -118,6 +118,7 @@ export interface ExecutionContextData { sessionFileForIndex: (idx?: number) => string | undefined; artifactConfig: ArtifactConfig; artifactsDir: string; + parentDepth?: number; effectiveAsync: boolean; controlConfig: ResolvedControlConfig; intercomBridge: IntercomBridgeState; diff --git a/packages/subagents/src/runs/foreground/subagent-executor.ts b/packages/subagents/src/runs/foreground/subagent-executor.ts index ae6ccd210..d4dfb30db 100644 --- a/packages/subagents/src/runs/foreground/subagent-executor.ts +++ b/packages/subagents/src/runs/foreground/subagent-executor.ts @@ -14,7 +14,9 @@ import { toModelInfo } from "../../shared/model-info.ts"; import { resolveSingleProgress } from "../../shared/settings.ts"; import { DEFAULT_ARTIFACT_CONFIG, + getCurrentSubagentDepth, isWorkflowStageOrchestrationContext, + resolveChildMaxSubagentDepth, resolveWorkflowStageMaxSubagentDepth, SUBAGENT_ACTIONS, type SubagentToolResult, @@ -104,7 +106,16 @@ async function resumeRetainedForegroundChild( progress: resolveSingleProgress(agentConfig, params.progress, message), modelOverride: params.model, availableModels: ctx.modelRegistry.getAvailable().map(toModelInfo), - maxSubagentDepth: resolveWorkflowStageMaxSubagentDepth(ctx, deps.config.maxSubagentDepth), + // The child's own effective limit, recorded when the run was retained. An + // older record without one falls back to narrowing the current limit by the + // agent definition, so a resume never widens a child's delegation budget. + maxSubagentDepth: + child.maxSubagentDepth ?? + resolveChildMaxSubagentDepth( + resolveWorkflowStageMaxSubagentDepth(ctx, deps.config.maxSubagentDepth), + agentConfig.maxSubagentDepth, + ), + parentDepth: getCurrentSubagentDepth(ctx), workflowStageSubagentGuard: isWorkflowStageOrchestrationContext(ctx), controlConfig: resolveControlConfig(deps.config.control, params.control), controlIntercomTarget: intercomBridge.active ? intercomBridge.orchestratorTarget : undefined, @@ -115,6 +126,13 @@ async function resumeRetainedForegroundChild( }); } const MUTATING_MANAGEMENT_ACTIONS = new Set(["create", "update", "delete"]); +/** + * Management actions that only observe. Every other action either mutates agent + * definitions or starts/continues agent execution, so a child without fanout + * authorization is refused all of them. + */ +const READ_ONLY_MANAGEMENT_ACTIONS = new Set(["list", "get", "status", "doctor"]); +const FANOUT_REFUSAL_MESSAGE = "Subagent fanout is not authorized for this child."; export type { SubagentExecutorRuntimeDeps, SubagentParamsLike } from "./subagent-executor-types.ts"; @@ -134,6 +152,30 @@ async function handleManagementRequest(input: { details: { mode: "management", results: [] }, }; } + if (!(SUBAGENT_ACTIONS as readonly string[]).includes(action)) { + return { + content: [{ type: "text", text: `Unknown action: ${action}. Valid: ${SUBAGENT_ACTIONS.join(", ")}` }], + isError: true, + details: { mode: "management" as const, results: [] }, + }; + } + if (isManagementActionsRestricted(deps) && MUTATING_MANAGEMENT_ACTIONS.has(action)) { + return { + content: [{ type: "text", text: `Action '${action}' is not available from child-safe subagent fanout mode.` }], + isError: true, + details: { mode: "management" as const, results: [] }, + }; + } + // `resume` revives a child and `interrupt` is privileged control over a + // running one; both continue agent execution, so only the observing actions + // reach their handlers for a child without fanout authorization. + if (deps.childPolicy && !deps.childPolicy.fanoutAuthorized && !READ_ONLY_MANAGEMENT_ACTIONS.has(action)) { + return { + content: [{ type: "text", text: FANOUT_REFUSAL_MESSAGE }], + isError: true, + details: { mode: "management" as const, results: [] }, + }; + } if (action === "doctor") { let currentSessionFile: string | null = null; let currentSessionId = deps.state.currentSessionId; @@ -229,20 +271,6 @@ async function handleManagementRequest(input: { details: { mode: "management", results: [] }, }; } - if (!(SUBAGENT_ACTIONS as readonly string[]).includes(action)) { - return { - content: [{ type: "text", text: `Unknown action: ${action}. Valid: ${SUBAGENT_ACTIONS.join(", ")}` }], - isError: true, - details: { mode: "management" as const, results: [] }, - }; - } - if (isManagementActionsRestricted(deps) && MUTATING_MANAGEMENT_ACTIONS.has(action)) { - return { - content: [{ type: "text", text: `Action '${action}' is not available from child-safe subagent fanout mode.` }], - isError: true, - details: { mode: "management" as const, results: [] }, - }; - } return handleManagementAction(action, paramsWithResolvedCwd, { ...ctx, cwd: requestCwd }); } @@ -282,13 +310,6 @@ export function createSubagentExecutor(rawDeps: ExecutorDeps): { onUpdate: ((r: SubagentToolResult) => void) | undefined, ctx: ExtensionContext, ): Promise => { - if (deps.childPolicy && !deps.childPolicy.fanoutAuthorized) { - return { - content: [{ type: "text", text: "Subagent fanout is not authorized for this child." }], - isError: true, - details: { mode: "single", results: [] }, - }; - } deps.state.baseCwd = ctx.cwd; deps.state.foregroundRuns ??= new Map(); deps.state.foregroundControls ??= new Map(); @@ -298,6 +319,17 @@ export function createSubagentExecutor(rawDeps: ExecutorDeps): { if (params.action) { return handleManagementRequest({ params, paramsWithResolvedCwd, requestCwd, ctx, deps }); } + // Fanout authorization gates delegation and every management action that can + // start or continue agent execution. Only `list`, `get`, `status`, and + // `doctor` stay available to an unauthorized child; `resume`, `interrupt`, + // and mutating management are refused inside handleManagementRequest. + if (deps.childPolicy && !deps.childPolicy.fanoutAuthorized) { + return { + content: [{ type: "text", text: FANOUT_REFUSAL_MESSAGE }], + isError: true, + details: { mode: inferExecutionMode(params), results: [] }, + }; + } const depthError = checkDepthForExecution(ctx, deps); if (depthError) return depthError; diff --git a/packages/subagents/src/runs/inprocess/background-single.ts b/packages/subagents/src/runs/inprocess/background-single.ts index 782419844..6851a9ad5 100644 --- a/packages/subagents/src/runs/inprocess/background-single.ts +++ b/packages/subagents/src/runs/inprocess/background-single.ts @@ -46,6 +46,7 @@ export async function executeAsyncSingle(id: string, params: AsyncSingleParams): sessionRoot, sessionFile, maxSubagentDepth, + parentDepth, workflowStageSubagentGuard, controlConfig, controlIntercomTarget, @@ -123,6 +124,7 @@ export async function executeAsyncSingle(id: string, params: AsyncSingleParams): outputMode, backgroundContinuation: true, maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth), + parentDepth, workflowStageSubagentGuard, workflowSessionMetadata: ctx.workflowSessionMetadata, controlConfig, diff --git a/packages/subagents/src/runs/inprocess/background.ts b/packages/subagents/src/runs/inprocess/background.ts index f938da139..0bf989c41 100644 --- a/packages/subagents/src/runs/inprocess/background.ts +++ b/packages/subagents/src/runs/inprocess/background.ts @@ -42,6 +42,7 @@ export interface AsyncSingleParams { availableModels?: ModelInfo[]; knownModelProviders?: string[]; maxSubagentDepth: number; + parentDepth?: number; workflowStageSubagentGuard?: boolean; worktreeSetupHook?: string; worktreeSetupHookTimeoutMs?: number; diff --git a/packages/subagents/src/runs/inprocess/runner.ts b/packages/subagents/src/runs/inprocess/runner.ts index 9df4e4df3..969442192 100644 --- a/packages/subagents/src/runs/inprocess/runner.ts +++ b/packages/subagents/src/runs/inprocess/runner.ts @@ -1,5 +1,5 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { type AgentSession, type AgentSessionEvent, @@ -8,6 +8,8 @@ import { createAgentSession, DefaultResourceLoader, getAgentDir, + getBuiltinPackagePaths, + type PackageSource, SessionManager, type SessionStats, SettingsManager, @@ -30,6 +32,7 @@ import { DEFAULT_MAX_OUTPUT, type JsonSchemaObject, type MaxOutputConfig, + normalizeMaxSubagentDepth, truncateOutput, } from "../../shared/types.ts"; import { @@ -80,6 +83,12 @@ export interface ChildSpec { /** Typed identity/capability resolved by the parent before admission. */ readonly intercom?: SubagentIntercomIdentity; readonly sessionFile?: string; + /** + * Effective delegation limit for this child, already narrowed by the parent's + * limit and the child agent's own `maxSubagentDepth`. Retained on the spec so + * a cold reload reissues the same limit. + */ + readonly maxSubagentDepth?: number; readonly testSession?: boolean | TestSessionOptions; readonly structuredOutput?: { readonly schema: JsonSchemaObject; readonly outputPath: string }; readonly artifactJsonlPath?: string; @@ -245,6 +254,57 @@ function workflowMetadataFromContext( }; } +/** + * Effective delegation limit for an admitted child. A caller that already + * narrowed the parent's limit against the agent definition puts the result on + * the spec; a caller admitting a child directly still gets the agent's own + * declared limit rather than an unbounded policy. + */ +export function effectiveChildMaxSubagentDepth(spec: ChildSpec): number | undefined { + return spec.maxSubagentDepth ?? normalizeMaxSubagentDepth(spec.agent.maxSubagentDepth); +} + +/** + * In-process children must load the same bundled extensions as the host. + * Workflow extensions are disabled inside workflow-owned sessions because their + * startup lifecycle belongs to the parent workflow store. + */ +export function inProcessChildBuiltinPackagePaths( + context: CreateAgentSessionOptions["orchestrationContext"] | undefined, +): PackageSource[] { + return getBuiltinPackagePaths().map((source) => { + if (context?.kind !== "workflow-stage" || basename(source) !== "workflows") return source; + return { source, extensions: [] }; + }); +} + +/** + * Resource-loading options for a real (non-test) in-process child session. + * Omitting `builtinPackagePaths` leaves a child with no bundled extension at + * all — no `subagent`, `web_search`, `fetch_content`, or `intercom` — so the + * bundled roots belong in every child loader. + */ +export function inProcessChildResourceLoaderOptions(input: { + readonly cwd: string; + readonly agentDir: string; + readonly settingsManager: SettingsManager; + readonly agent: Pick; + readonly orchestrationContext: CreateAgentSessionOptions["orchestrationContext"] | undefined; +}): ConstructorParameters[0] { + const agentPrompt = input.agent.systemPrompt?.trim(); + return { + cwd: input.cwd, + agentDir: input.agentDir, + settingsManager: input.settingsManager, + builtinPackagePaths: inProcessChildBuiltinPackagePaths(input.orchestrationContext), + ...(agentPrompt && input.agent.systemPromptMode === "append" + ? { appendSystemPrompt: [agentPrompt] } + : agentPrompt + ? { systemPrompt: agentPrompt } + : {}), + }; +} + function createTestSession(sessionManager: SessionManager, spec: ChildSpec): AgentSession { const listeners = new Set(); const testOptions = typeof spec.testSession === "object" ? spec.testSession : {}; @@ -542,6 +602,9 @@ export class SubagentControlRuntime { intercomGroup: parent.intercomGroup, intercom: spec.intercom, depth: identity.depth, + ...(effectiveChildMaxSubagentDepth(spec) === undefined + ? {} + : { maxSubagentDepth: effectiveChildMaxSubagentDepth(spec) }), }, sessionDir, sessionFile: spec.sessionFile, @@ -637,40 +700,45 @@ export class SubagentControlRuntime { ); activeSessionManager = sessionManager; if (workflow) sessionManager.markSessionInternal(workflow); - const settingsManager = SettingsManager.create(admitted.policy.cwd, getAgentDir()); - const agentPrompt = admitted.spec.agent.systemPrompt?.trim(); - const resourceLoader = new DefaultResourceLoader({ - cwd: admitted.policy.cwd, - agentDir: getAgentDir(), - settingsManager, - ...(agentPrompt && admitted.spec.agent.systemPromptMode === "append" - ? { appendSystemPrompt: [agentPrompt] } - : agentPrompt - ? { systemPrompt: agentPrompt } - : {}), - }); - await resourceLoader.reload(); - const promptBehavior = createInProcessChildPromptBehavior(admitted.policy); - const created = admitted.spec.testSession - ? { session: createTestSession(sessionManager, admitted.spec) } - : await createAgentSession({ + let created: { session: AgentSession }; + if (admitted.spec.testSession) { + created = { session: createTestSession(sessionManager, admitted.spec) }; + } else { + const settingsManager = SettingsManager.create(admitted.policy.cwd, getAgentDir()); + const resourceLoader = new DefaultResourceLoader( + inProcessChildResourceLoaderOptions({ cwd: admitted.policy.cwd, - model: candidate.model ?? admitted.policy.model, - thinkingLevel: candidate.thinkingLevel ?? admitted.policy.thinkingLevel, - ...(admitted.spec.fallbackModels?.length - ? { fallbackModels: [...admitted.spec.fallbackModels] } - : {}), - tools: admitted.policy.tools ? [...admitted.policy.tools] : undefined, - excludedTools: admitted.policy.excludedTools ? [...admitted.policy.excludedTools] : undefined, - customTools: admitted.policy.customTools, - resourceLoader, - sessionManager, + agentDir: getAgentDir(), settingsManager, + agent: admitted.spec.agent, orchestrationContext: admitted.spec.parent?.orchestrationContext, - subagentPolicy: admitted.policy, - systemPromptTransform: promptBehavior.systemPromptTransform, - initialContextTransform: promptBehavior.initialContextTransform, - }); + }), + ); + await resourceLoader.reload(); + const promptBehavior = createInProcessChildPromptBehavior(admitted.policy); + created = { + session: ( + await createAgentSession({ + cwd: admitted.policy.cwd, + model: candidate.model ?? admitted.policy.model, + thinkingLevel: candidate.thinkingLevel ?? admitted.policy.thinkingLevel, + ...(admitted.spec.fallbackModels?.length + ? { fallbackModels: [...admitted.spec.fallbackModels] } + : {}), + tools: admitted.policy.tools ? [...admitted.policy.tools] : undefined, + excludedTools: admitted.policy.excludedTools ? [...admitted.policy.excludedTools] : undefined, + customTools: admitted.policy.customTools, + resourceLoader, + sessionManager, + settingsManager, + orchestrationContext: admitted.spec.parent?.orchestrationContext, + subagentPolicy: admitted.policy, + systemPromptTransform: promptBehavior.systemPromptTransform, + initialContextTransform: promptBehavior.initialContextTransform, + }) + ).session, + }; + } session = created.session; if (session.sessionFile) this.sessionFiles.set(admitted.identity.path, session.sessionFile); this.sessions.set(admitted.identity.path, session); @@ -1055,6 +1123,9 @@ export class SubagentControlRuntime { thinkingLevel: spec.thinkingLevel ?? (spec.agent.thinking as ChildPolicy["thinkingLevel"]), intercomGroup: spec.parent?.intercomGroup, depth, + ...(effectiveChildMaxSubagentDepth(spec) === undefined + ? {} + : { maxSubagentDepth: effectiveChildMaxSubagentDepth(spec) }), }; } diff --git a/packages/subagents/src/shared/types-async.ts b/packages/subagents/src/shared/types-async.ts index 59720f89e..5459fbac6 100644 --- a/packages/subagents/src/shared/types-async.ts +++ b/packages/subagents/src/shared/types-async.ts @@ -217,6 +217,12 @@ export interface ForegroundResumeChild { sessionFile?: string; status: SubagentResultStatus; result?: SingleResult; + /** + * Effective delegation limit this child ran under. Retained per child because + * parallel and chain branches can carry different limits, and because a later + * edit to the agent definition must not widen a resumed child's budget. + */ + maxSubagentDepth?: number; } export interface ForegroundResumeRun { diff --git a/packages/subagents/src/shared/types-config.ts b/packages/subagents/src/shared/types-config.ts index f19a7fdaa..01e75adfd 100644 --- a/packages/subagents/src/shared/types-config.ts +++ b/packages/subagents/src/shared/types-config.ts @@ -94,6 +94,8 @@ export interface RunSyncOptions { outputPath?: string; outputMode?: OutputMode; maxSubagentDepth?: number; + /** Current session depth passed to the in-process admission door. */ + parentDepth?: number; workflowStageSubagentGuard?: boolean; workflowSessionMetadata?: SessionWorkflowMetadata; nestedRoute?: NestedRouteInfo; diff --git a/packages/subagents/src/shared/types-depth.ts b/packages/subagents/src/shared/types-depth.ts index 037af2f86..db4314089 100644 --- a/packages/subagents/src/shared/types-depth.ts +++ b/packages/subagents/src/shared/types-depth.ts @@ -3,34 +3,32 @@ */ import type { ExtensionContext, SessionWorkflowMetadata } from "@bastani/atomic"; -import { - APP_NAME, - getEnvValue, - WORKFLOW_SESSION_METADATA_ENV, - WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, -} from "@bastani/atomic"; import { DEFAULT_SUBAGENT_MAX_DEPTH, MAX_SUBAGENT_NESTING_DEPTH } from "./types-runtime.ts"; -const ENV_PREFIX = APP_NAME.toUpperCase(); -const SUBAGENT_MAX_DEPTH_ENV = `${ENV_PREFIX}_SUBAGENT_MAX_DEPTH`; -const SUBAGENT_DEPTH_ENV = `${ENV_PREFIX}_SUBAGENT_DEPTH`; - -export { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV }; +// Depth is admission state carried in the typed child policy, not process environment. // ============================================================================ - export function normalizeMaxSubagentDepth(value: unknown): number | undefined { const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN; if (!Number.isInteger(parsed) || parsed < 0) return undefined; return Math.min(parsed, MAX_SUBAGENT_NESTING_DEPTH); } -export function resolveCurrentMaxSubagentDepth(configMaxDepth?: number): number { - return ( - normalizeMaxSubagentDepth(getEnvValue(SUBAGENT_MAX_DEPTH_ENV)) ?? - normalizeMaxSubagentDepth(configMaxDepth) ?? - DEFAULT_SUBAGENT_MAX_DEPTH - ); +/** + * The effective limit is the stricter of the local configuration and any limit + * inherited through the child policy, both clamped by the global ceiling. + */ +export function resolveCurrentMaxSubagentDepth(configMaxDepth?: number, inheritedMaxDepth?: number): number { + const local = normalizeMaxSubagentDepth(configMaxDepth) ?? DEFAULT_SUBAGENT_MAX_DEPTH; + const inherited = normalizeMaxSubagentDepth(inheritedMaxDepth); + return inherited === undefined ? local : Math.min(local, inherited); +} + +/** Read the delegation limit a parent admission issued to this child session. */ +export function getInheritedMaxSubagentDepth( + ctx: Partial>, +): number | undefined { + return ctx.subagentPolicy?.maxSubagentDepth; } export function resolveChildMaxSubagentDepth(parentMaxDepth: number, agentMaxDepth?: number): number { @@ -39,10 +37,6 @@ export function resolveChildMaxSubagentDepth(parentMaxDepth: number, agentMaxDep return normalizedAgent === undefined ? normalizedParent : Math.min(normalizedParent, normalizedAgent); } -export function hasWorkflowStageSubagentGuard(): boolean { - return getEnvValue(WORKFLOW_STAGE_SUBAGENT_GUARD_ENV) === "1"; -} - export function isWorkflowStageOrchestrationContext(ctx: Pick): boolean { return ctx.orchestrationContext?.kind === "workflow-stage"; } @@ -59,21 +53,11 @@ export function workflowSessionMetadataFromContext( }; } -export function workflowSessionEnv(metadata: SessionWorkflowMetadata | undefined): Record { - return metadata ? { [WORKFLOW_SESSION_METADATA_ENV]: JSON.stringify(metadata) } : {}; -} - -export function workflowSessionEnvFromContext( - ctx: Pick, -): Record { - return workflowSessionEnv(workflowSessionMetadataFromContext(ctx)); -} - export function resolveWorkflowStageMaxSubagentDepth( - ctx: Pick, + ctx: Pick & Partial>, configMaxDepth?: number, ): number { - const maxDepth = resolveCurrentMaxSubagentDepth(configMaxDepth); + const maxDepth = resolveCurrentMaxSubagentDepth(configMaxDepth, getInheritedMaxSubagentDepth(ctx)); return isWorkflowStageOrchestrationContext(ctx) ? // Workflow stages receive an explicit host constraint, clamped by the // inherited/global nesting ceiling. A 0-depth workflow constraint still @@ -88,7 +72,7 @@ export interface SubagentDepthPolicy { } export function resolveSubagentDepthPolicy( - ctx: Pick, + ctx: Pick & Partial>, configMaxDepth?: number, ): SubagentDepthPolicy { return { @@ -128,31 +112,21 @@ export interface SubagentDepthCheck { blocked: boolean; depth: number; maxDepth: number; - workflowStageGuard: boolean; } -export function checkSubagentDepth(configMaxDepth?: number): SubagentDepthCheck { - const depth = Number(getEnvValue(SUBAGENT_DEPTH_ENV) ?? "0"); - const maxDepth = resolveCurrentMaxSubagentDepth(configMaxDepth); - const blocked = Number.isFinite(depth) && depth >= maxDepth; - return { blocked, depth, maxDepth, workflowStageGuard: hasWorkflowStageSubagentGuard() }; +/** Read the admitted depth carried by an in-process child session. */ +export function getCurrentSubagentDepth(ctx: Pick): number { + const depth = ctx.subagentPolicy?.depth; + return typeof depth === "number" && Number.isInteger(depth) && depth >= 0 ? depth : 0; } -export function getSubagentDepthEnv( - maxDepth?: number, - options?: { workflowStageSubagentGuard?: boolean }, -): Record { - const parentDepth = Number(getEnvValue(SUBAGENT_DEPTH_ENV) ?? "0"); - // Preserve an inherited workflow-stage marker for descendants; callers that - // mutate process.env in tests must clear it to avoid intentional propagation. - const nextDepth = Number.isFinite(parentDepth) ? parentDepth + 1 : 1; - return { - [SUBAGENT_DEPTH_ENV]: String(nextDepth), - [SUBAGENT_MAX_DEPTH_ENV]: String(normalizeMaxSubagentDepth(maxDepth) ?? resolveCurrentMaxSubagentDepth()), - ...(options?.workflowStageSubagentGuard || hasWorkflowStageSubagentGuard() - ? { [WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]: "1" } - : {}), - }; +export function checkSubagentDepth( + ctx: Pick, + configMaxDepth?: number, +): SubagentDepthCheck { + const depth = getCurrentSubagentDepth(ctx); + const maxDepth = resolveCurrentMaxSubagentDepth(configMaxDepth, getInheritedMaxSubagentDepth(ctx)); + return { blocked: Number.isFinite(depth) && depth >= maxDepth, depth, maxDepth }; } // ============================================================================ diff --git a/packages/workflows/CHANGELOG.md b/packages/workflows/CHANGELOG.md index 1cadf8268..4d6674fb7 100644 --- a/packages/workflows/CHANGELOG.md +++ b/packages/workflows/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed - Fixed the `DISPATCHED` confirmation panel being torn apart by any workflow input containing a newline. Input values were truncated by visible width, which a control character does not have, so an embedded newline survived truncation and emitted extra physical lines that no border ever wrapped — the box was destroyed from that row down. Multi-line inputs are ordinary, so this fired for most real launches: a `prompt` or an `acceptance_criteria` block reliably reproduced it. String values and the object/array JSON projection now collapse control characters, `U+2028`, and `U+2029` to single spaces before truncation, so a run card stays one row per value. `U+2028`/`U+2029` are included because `JSON.stringify` does not escape them and some terminals still break lines on them. As a side effect, escape sequences in an input value can no longer inject ANSI styling into the chat surface. +- Fixed workflow stages being unable to use the `subagent` tool at all. The stage policy set `managementActions: "full"` and `fanoutAuthorized: false` together, which contradict: stages could neither delegate nor, because of the companion subagents defect, run read-only management such as `subagent list`. Workflow stages are top-level sessions rather than subagent children, so the policy now sets `fanoutAuthorized: true`, restoring the delegation the workflow docs already describe. Nesting remains bounded by the typed in-process depth policy and Rust admission’s five-level ceiling ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). +- Fixed nested subagents in workflow-stage sessions losing the bundled `subagent` extension. In-process child resource loading now carries the bundled package roots and disables only the workflow extension's repeated stage lifecycle, so a stage can delegate and its nested child receives the bundled tools instead of only the base built-in ones ([#2220](https://github.com/bastani-inc/atomic/pull/2220), regression from [#2205](https://github.com/bastani-inc/atomic/pull/2205)). ## [0.9.13-alpha.1] - 2026-08-05 diff --git a/packages/workflows/src/extension/atomic-stage-session.ts b/packages/workflows/src/extension/atomic-stage-session.ts index 8b832d094..39a280765 100644 --- a/packages/workflows/src/extension/atomic-stage-session.ts +++ b/packages/workflows/src/extension/atomic-stage-session.ts @@ -49,9 +49,15 @@ export interface PrepareAtomicStageSessionOptions { resourceLoaderInheritanceSnapshot?: DefaultResourceLoaderInheritanceSnapshot; onSettingsManager?: (settingsManager: PiSdkSettingsManager) => void; } +/** + * Workflow stages are top-level sessions that carry a policy object; they are + * not subagent children. They keep full management and are authorized to + * delegate, as `packages/coding-agent/docs/workflows.md` documents. Nesting + * stays bounded by the depth guard in the subagent executor. + */ const WORKFLOW_STAGE_SUBAGENT_POLICY: SubagentChildPolicy = { managementActions: "full", - fanoutAuthorized: false, + fanoutAuthorized: true, inheritProjectContext: true, inheritSkills: true, }; diff --git a/test/unit/subagents-child-policy-gate.test.ts b/test/unit/subagents-child-policy-gate.test.ts new file mode 100644 index 000000000..e2e2a3f3c --- /dev/null +++ b/test/unit/subagents-child-policy-gate.test.ts @@ -0,0 +1,462 @@ +/** + * Regression coverage for the #2205 child-policy gate. + * + * The fanout gate used to run before the management branch, so a child with + * `fanoutAuthorized: false` was refused every `subagent` action — `list` + * included — with a message about fanout. Workflow stages additionally shipped + * `fanoutAuthorized: false`, so no stage could delegate at all. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { join } from "node:path"; +import type { ExtensionAPI, SubagentChildPolicy, ToolDefinition } from "@bastani/atomic"; +import { beforeEach, describe, test, vi } from "vitest"; +import registerFanoutChildSubagentExtension from "../../packages/subagents/src/extension/fanout-child.js"; +import registerSubagentExtension from "../../packages/subagents/src/extension/index.js"; +import { createSubagentExecutor } from "../../packages/subagents/src/runs/foreground/subagent-executor.js"; +import { MAX_SUBAGENT_NESTING_DEPTH } from "../../packages/subagents/src/shared/types.js"; +import type { + PiCodingAgentSdk, + PiSdkResourceLoader, + PiSdkSettingsManager, +} from "../../packages/workflows/src/extension/atomic-stage-session.js"; +import { prepareAtomicStageSessionOptions } from "../../packages/workflows/src/extension/wiring.js"; +import type { StageSessionRuntime } from "../../packages/workflows/src/runs/foreground/stage-runner.js"; + +const FANOUT_MESSAGE = "Subagent fanout is not authorized for this child."; + +interface MinimalAgentConfig { + name: string; + description: string; + systemPromptMode: "append" | "replace"; + inheritProjectContext: boolean; + inheritSkills: boolean; + systemPrompt: string; + source: "builtin" | "user" | "project"; + filePath: string; +} + +type ExecutorForTest = ReturnType; +type ExecutorDepsForTest = Parameters[0]; +type ExecutorContextForTest = Parameters[4]; +type ExecutorResultForTest = Awaited>; + +const emptyUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; +const runSyncCalls: string[] = []; +const runSyncParentDepths: (number | undefined)[] = []; +const executorPolicies = new WeakMap(); + +const runSyncMock = vi.fn( + async ( + _cwd: string, + _agents: MinimalAgentConfig[], + agentName: string, + task: string, + options: { maxSubagentDepth?: number; parentDepth?: number }, + ) => { + runSyncCalls.push(agentName); + runSyncParentDepths.push(options.parentDepth); + return { + agent: agentName, + task, + status: "ok" as const, + messages: [], + usage: emptyUsage, + finalOutput: `${agentName} output`, + }; + }, +); + +function makeAgent(name: string): MinimalAgentConfig { + return { + name, + description: `${name} test agent`, + systemPromptMode: "replace", + inheritProjectContext: false, + inheritSkills: false, + systemPrompt: "You are a test agent.", + source: "project", + filePath: `/tmp/${name}.md`, + }; +} + +function makeState() { + return { + baseCwd: "", + currentSessionId: null, + asyncJobs: new Map(), + foregroundRuns: new Map(), + foregroundControls: new Map(), + lastForegroundControlId: null, + cleanupTimers: new Map(), + lastUiContext: null, + poller: null, + completionSeen: new Map(), + watcher: null, + watcherRestartTimer: null, + resultFileCoalescer: { schedule: () => false, clear: () => {} }, + }; +} + +function makeContext(cwd: string, subagentPolicy?: SubagentChildPolicy): ExecutorContextForTest { + return { + cwd, + mode: "tui", + hasUI: false, + ui: { custom: async () => undefined as T } as unknown as ExecutorContextForTest["ui"], + model: undefined, + scopedModels: [], + modelRegistry: { getAvailable: () => [] } as unknown as ExecutorContextForTest["modelRegistry"], + sessionManager: { + getSessionFile: () => undefined, + getSessionId: () => "parent-session", + getLeafId: () => null, + } as ExecutorContextForTest["sessionManager"], + orchestrationContext: undefined, + ...(subagentPolicy === undefined ? {} : { subagentPolicy }), + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + } satisfies ExecutorContextForTest; +} + +function makeExecutor(policy: SubagentChildPolicy): ExecutorForTest { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-child-policy-")); + const deps = { + pi: { + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "parent-session-name", + } as unknown as ExecutorDepsForTest["pi"], + state: makeState(), + config: { parallel: { concurrency: 4, maxTasks: 50 } }, + asyncByDefault: false, + tempArtifactsDir: path.join(tempRoot, "artifacts"), + getSubagentSessionRoot: () => path.join(tempRoot, "sessions"), + expandTilde: (p: string) => p, + discoverAgents: () => ({ agents: [makeAgent("alpha")] }), + childPolicy: policy, + allowMutatingManagementActions: policy.managementActions === "full", + runtime: { + runSync: runSyncMock, + isAsyncAvailable: () => false, + }, + } satisfies ExecutorDepsForTest; + const executor = createSubagentExecutor(deps); + executorPolicies.set(executor, policy); + return executor; +} + +function policyFor(overrides: Partial): SubagentChildPolicy { + return { + managementActions: "restricted", + fanoutAuthorized: false, + inheritProjectContext: false, + inheritSkills: false, + ...overrides, + }; +} + +function resultText(result: ExecutorResultForTest): string { + return result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n"); +} + +async function runAction( + executor: ExecutorForTest, + params: Parameters[1], +): Promise { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-child-policy-cwd-")); + return executor.execute( + "subagent", + params, + new AbortController().signal, + undefined, + makeContext(cwd, executorPolicies.get(executor)), + ); +} + +beforeEach(() => { + runSyncCalls.length = 0; + runSyncParentDepths.length = 0; + runSyncMock.mockClear(); +}); + +describe("subagent child policy gates fanout, not management", () => { + test("a child without fanout authorization can still run the observing management actions", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: false, managementActions: "full" })); + + for (const action of ["list", "get", "status", "doctor"] as const) { + const result = await runAction(executor, { action }); + assert.notEqual( + resultText(result), + FANOUT_MESSAGE, + `observing management action '${action}' must not be refused as fanout`, + ); + assert.equal(result.details.mode, "management"); + } + }); + + test("a child without fanout authorization is refused resume and interrupt", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: false, managementActions: "full" })); + + // Both continue agent execution: resume revives a child, interrupt is + // privileged control over a running one. Neither is observation. + for (const action of ["resume", "interrupt"] as const) { + const result = await runAction(executor, { + action, + id: "victim-run/victim_1", + message: "keep going", + }); + assert.equal(result.isError, true, `'${action}' must be refused for a fanout-denied child`); + assert.equal(resultText(result), FANOUT_MESSAGE); + assert.equal(result.details.mode, "management"); + } + + assert.deepEqual(runSyncCalls, []); + }); + + test("a fanout-authorized child still reaches resume and interrupt", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: true, managementActions: "full" })); + + for (const action of ["resume", "interrupt"] as const) { + const result = await runAction(executor, { + action, + id: "missing-run/missing_1", + message: "keep going", + }); + // No such child exists, so the handler's own "not found" answer proves the + // request passed the gate rather than being refused as fanout. + assert.notEqual(resultText(result), FANOUT_MESSAGE); + assert.match(resultText(result), /No (running )?in-process child found for 'missing-run\/missing_1'\./); + } + }); + + test("'list' succeeds for a child without fanout authorization", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: false, managementActions: "full" })); + + const result = await runAction(executor, { action: "list" }); + + assert.notEqual(result.isError, true); + assert.ok(!resultText(result).includes(FANOUT_MESSAGE)); + }); + + test("a management-restricted child is still refused create/update/delete", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: false, managementActions: "restricted" })); + + for (const action of ["create", "update", "delete"] as const) { + const result = await runAction(executor, { action, agent: "alpha" }); + assert.equal(result.isError, true); + assert.equal(resultText(result), `Action '${action}' is not available from child-safe subagent fanout mode.`); + assert.equal(result.details.mode, "management"); + } + }); + + test("a child without fanout authorization is still refused actual delegation", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: false, managementActions: "full" })); + + const single = await runAction(executor, { agent: "alpha", task: "do work" }); + assert.equal(single.isError, true); + assert.equal(resultText(single), FANOUT_MESSAGE); + assert.equal(single.details.mode, "single"); + + const parallel = await runAction(executor, { tasks: [{ agent: "alpha", task: "do work" }] }); + assert.equal(parallel.isError, true); + assert.equal(resultText(parallel), FANOUT_MESSAGE); + assert.equal(parallel.details.mode, "parallel"); + + const chain = await runAction(executor, { chain: [{ agent: "alpha", task: "do work" }] }); + assert.equal(chain.isError, true); + assert.equal(resultText(chain), FANOUT_MESSAGE); + assert.equal(chain.details.mode, "chain"); + + assert.deepEqual(runSyncCalls, []); + }); + + test("a fanout-authorized child reaches the delegation path", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: true, managementActions: "full" })); + + const result = await runAction(executor, { agent: "alpha", task: "do work" }); + + assert.equal(result.isError, undefined); + assert.deepEqual(runSyncCalls, ["alpha"]); + }); + + test("the live child-policy depth blocks delegation at the documented limit", async () => { + assert.equal(MAX_SUBAGENT_NESTING_DEPTH, 5); + const executor = makeExecutor( + policyFor({ fanoutAuthorized: true, managementActions: "full", depth: MAX_SUBAGENT_NESTING_DEPTH }), + ); + + const result = await runAction(executor, { agent: "alpha", task: "do work" }); + + assert.equal(result.isError, true); + assert.ok( + resultText(result).startsWith( + `Nested subagent call blocked (depth=${MAX_SUBAGENT_NESTING_DEPTH}, max=${MAX_SUBAGENT_NESTING_DEPTH})`, + ), + `unexpected depth message: ${resultText(result)}`, + ); + assert.deepEqual(runSyncCalls, []); + }); + + test("one admitted level below the limit is still allowed", async () => { + const executor = makeExecutor( + policyFor({ fanoutAuthorized: true, managementActions: "full", depth: MAX_SUBAGENT_NESTING_DEPTH - 1 }), + ); + + const result = await runAction(executor, { agent: "alpha", task: "do work" }); + + assert.equal(result.isError, undefined); + assert.deepEqual(runSyncCalls, ["alpha"]); + }); + + test("the admitted depth travels into the run options that admission reads", async () => { + const executor = makeExecutor( + policyFor({ fanoutAuthorized: true, managementActions: "full", depth: MAX_SUBAGENT_NESTING_DEPTH - 2 }), + ); + + const result = await runAction(executor, { agent: "alpha", task: "do work" }); + + assert.equal(result.isError, undefined); + assert.deepEqual(runSyncParentDepths, [MAX_SUBAGENT_NESTING_DEPTH - 2]); + }); + + test("a top-level session delegates from depth zero", async () => { + const executor = makeExecutor(policyFor({ fanoutAuthorized: true, managementActions: "full" })); + + const result = await runAction(executor, { agent: "alpha", task: "do work" }); + + assert.equal(result.isError, undefined); + assert.deepEqual(runSyncParentDepths, [0]); + }); +}); + +function makeFakeAtomicSdk(defaultAgentDir: string): PiCodingAgentSdk { + class FakeResourceLoader implements PiSdkResourceLoader { + async reload(): Promise {} + } + + return { + getAgentDir: () => defaultAgentDir, + getBuiltinPackagePaths: () => [], + SettingsManager: { + create(): PiSdkSettingsManager { + return { getCodexFastModeSettings: () => ({ chat: false, workflow: false }) }; + }, + }, + DefaultResourceLoader: FakeResourceLoader, + async createAgentSession(): Promise<{ session: StageSessionRuntime }> { + throw new Error("not used"); + }, + }; +} + +describe("workflow stage subagent policy", () => { + test("a prepared stage session resolves a fanout-authorized policy with full management", async () => { + const sdk = makeFakeAtomicSdk(join("/home", "user", ".atomic", "agent")); + + const options = await prepareAtomicStageSessionOptions({ cwd: join("/tmp", "project") }, sdk); + + assert.equal(options?.subagentPolicy?.fanoutAuthorized, true); + assert.equal(options?.subagentPolicy?.managementActions, "full"); + assert.equal(options?.subagentPolicy?.inheritProjectContext, true); + assert.equal(options?.subagentPolicy?.inheritSkills, true); + }); + + test("an executor built from the stage policy reaches the delegation path", async () => { + const sdk = makeFakeAtomicSdk(join("/home", "user", ".atomic", "agent")); + const options = await prepareAtomicStageSessionOptions({ cwd: join("/tmp", "project") }, sdk); + const policy = options?.subagentPolicy; + assert.ok(policy, "stage options must carry a subagent policy"); + + const executor = makeExecutor(policy); + const listed = await runAction(executor, { action: "list" }); + assert.ok(!resultText(listed).includes(FANOUT_MESSAGE)); + + const delegated = await runAction(executor, { agent: "alpha", task: "do work" }); + assert.equal(delegated.isError, undefined); + assert.deepEqual(runSyncCalls, ["alpha"]); + }); + + test("the registered subagent tool answers 'list' for a stage-policy session", async () => { + // End-to-end through the real registration door a stage session uses, so the + // policy -> registered-tool wiring is covered rather than only the executor. + const sdk = makeFakeAtomicSdk(join("/home", "user", ".atomic", "agent")); + const options = await prepareAtomicStageSessionOptions({ cwd: join("/tmp", "project") }, sdk); + const policy = options?.subagentPolicy; + assert.ok(policy, "stage options must carry a subagent policy"); + + let registered: ToolDefinition | undefined; + const pi = { + registerTool: (tool: ToolDefinition) => { + registered = tool; + }, + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "workflow-stage-session", + } as unknown as ExtensionAPI; + registerFanoutChildSubagentExtension(pi, policy); + assert.ok(registered, "the subagent tool must be registered"); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-stage-tool-")); + const result = (await registered.execute( + "stage-list", + { action: "list" }, + new AbortController().signal, + undefined, + makeContext(cwd), + )) as ExecutorResultForTest; + + assert.notEqual(result.isError, true); + assert.ok( + !resultText(result).includes(FANOUT_MESSAGE), + `stage 'subagent list' must not be refused as fanout, got: ${resultText(result)}`, + ); + }); + + test("the parent-registered subagent tool answers 'list' for a stage-policy context", async () => { + // The production door a workflow stage actually goes through: the full + // subagents extension resolves an executor from `ctx.subagentPolicy`. + const sdk = makeFakeAtomicSdk(join("/home", "user", ".atomic", "agent")); + const options = await prepareAtomicStageSessionOptions({ cwd: join("/tmp", "project") }, sdk); + const policy = options?.subagentPolicy; + assert.ok(policy, "stage options must carry a subagent policy"); + + let registered: ToolDefinition | undefined; + const pi = { + registerTool: (tool: ToolDefinition) => { + registered = tool; + }, + registerCommand: () => {}, + registerMessageRenderer: () => {}, + sendMessage: () => {}, + on: () => {}, + events: { on: () => () => {}, emit: () => {} }, + getSessionName: () => "workflow-stage-session", + } as unknown as ExtensionAPI; + registerSubagentExtension(pi); + assert.ok(registered, "the subagent tool must be registered"); + + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-stage-parent-tool-")); + const result = (await registered.execute( + "stage-parent-list", + { action: "list" }, + new AbortController().signal, + undefined, + makeContext(cwd, policy), + )) as ExecutorResultForTest; + + assert.notEqual(result.isError, true); + assert.ok( + !resultText(result).includes(FANOUT_MESSAGE), + `stage 'subagent list' must not be refused as fanout, got: ${resultText(result)}`, + ); + }); +}); diff --git a/test/unit/subagents-depth-guard.test.ts b/test/unit/subagents-depth-guard.test.ts index f9393d0ac..8da878a7e 100644 --- a/test/unit/subagents-depth-guard.test.ts +++ b/test/unit/subagents-depth-guard.test.ts @@ -1,34 +1,31 @@ import assert from "node:assert/strict"; -import { afterEach, describe, test } from "vitest"; +import { describe, test } from "vitest"; import { checkSubagentDepth, - getSubagentDepthEnv, MAX_SUBAGENT_NESTING_DEPTH, resolveWorkflowStageMaxSubagentDepth, subagentDepthBlockedMessage, - WORKFLOW_STAGE_SUBAGENT_GUARD_ENV, } from "../../packages/subagents/src/shared/types.js"; -const DEPTH_ENV = "ATOMIC_SUBAGENT_DEPTH"; -const MAX_DEPTH_ENV = "ATOMIC_SUBAGENT_MAX_DEPTH"; +const childPolicy = { + managementActions: "full" as const, + fanoutAuthorized: true, + inheritProjectContext: false, + inheritSkills: false, +}; -const savedEnv = new Map(); -for (const key of [DEPTH_ENV, MAX_DEPTH_ENV, WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]) { - savedEnv.set(key, process.env[key]); +function contextAtDepth(depth: number, maxSubagentDepth?: number) { + return { + subagentPolicy: { + ...childPolicy, + depth, + ...(maxSubagentDepth === undefined ? {} : { maxSubagentDepth }), + }, + }; } -afterEach(() => { - for (const [key, value] of savedEnv) { - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } -}); - describe("subagent workflow-stage depth guard", () => { test("workflow-stage context preserves stricter limits and defaults to main-chat depth", () => { - delete process.env[DEPTH_ENV]; - delete process.env[MAX_DEPTH_ENV]; - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; const workflowCtx = { orchestrationContext: { kind: "workflow-stage" as const, @@ -54,57 +51,56 @@ describe("subagent workflow-stage depth guard", () => { assert.equal(resolveWorkflowStageMaxSubagentDepth({}, undefined), MAX_SUBAGENT_NESTING_DEPTH); }); - test("subagent nesting defaults to and is capped at five levels", () => { - delete process.env[DEPTH_ENV]; - delete process.env[MAX_DEPTH_ENV]; - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + test("the live admitted policy depth blocks at the documented five-level limit", () => { + const topLevel = checkSubagentDepth({}); + assert.equal(topLevel.blocked, false); + assert.equal(topLevel.depth, 0); + assert.equal(topLevel.maxDepth, MAX_SUBAGENT_NESTING_DEPTH); - const result = checkSubagentDepth(); - assert.equal(result.blocked, false); - assert.equal(result.depth, 0); - assert.equal(result.maxDepth, MAX_SUBAGENT_NESTING_DEPTH); + const oneBelow = checkSubagentDepth(contextAtDepth(MAX_SUBAGENT_NESTING_DEPTH - 1)); + assert.equal(oneBelow.blocked, false); + assert.equal(oneBelow.depth, MAX_SUBAGENT_NESTING_DEPTH - 1); - process.env[MAX_DEPTH_ENV] = String(MAX_SUBAGENT_NESTING_DEPTH + 10); - assert.equal(checkSubagentDepth().maxDepth, MAX_SUBAGENT_NESTING_DEPTH); + const atLimit = checkSubagentDepth(contextAtDepth(MAX_SUBAGENT_NESTING_DEPTH)); + assert.equal(atLimit.blocked, true); + assert.equal(atLimit.depth, MAX_SUBAGENT_NESTING_DEPTH); + assert.equal(atLimit.maxDepth, MAX_SUBAGENT_NESTING_DEPTH); + }); - const firstChildEnv = getSubagentDepthEnv(MAX_SUBAGENT_NESTING_DEPTH + 10, { workflowStageSubagentGuard: true }); - assert.equal(firstChildEnv[DEPTH_ENV], "1"); - assert.equal(firstChildEnv[MAX_DEPTH_ENV], String(MAX_SUBAGENT_NESTING_DEPTH)); - assert.equal(firstChildEnv[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV], "1"); + test("a configured lower limit still applies to admitted depth", () => { + const result = checkSubagentDepth(contextAtDepth(2), 2); - process.env[DEPTH_ENV] = firstChildEnv[DEPTH_ENV]; - process.env[MAX_DEPTH_ENV] = firstChildEnv[MAX_DEPTH_ENV]; - process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = firstChildEnv[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - const firstChildResult = checkSubagentDepth(); - assert.equal(firstChildResult.blocked, false); - assert.equal(firstChildResult.depth, 1); - assert.equal(firstChildResult.maxDepth, MAX_SUBAGENT_NESTING_DEPTH); + assert.equal(result.blocked, true); + assert.equal(result.depth, 2); + assert.equal(result.maxDepth, 2); + }); - const secondChildEnv = getSubagentDepthEnv(MAX_SUBAGENT_NESTING_DEPTH, { workflowStageSubagentGuard: true }); - assert.equal(secondChildEnv[DEPTH_ENV], "2"); - assert.equal(secondChildEnv[MAX_DEPTH_ENV], String(MAX_SUBAGENT_NESTING_DEPTH)); + test("the nesting ceiling clamps a configured limit that exceeds it", () => { + assert.equal(checkSubagentDepth({}, MAX_SUBAGENT_NESTING_DEPTH + 10).maxDepth, MAX_SUBAGENT_NESTING_DEPTH); + assert.equal( + checkSubagentDepth(contextAtDepth(0, MAX_SUBAGENT_NESTING_DEPTH + 10)).maxDepth, + MAX_SUBAGENT_NESTING_DEPTH, + ); }); - test("workflow-stage child env marker produces nested workflow-stage rejection message", () => { - delete process.env[DEPTH_ENV]; - delete process.env[MAX_DEPTH_ENV]; - delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; + test("an inherited policy maximum tightens the limit and blocks below the global ceiling", () => { + const result = checkSubagentDepth(contextAtDepth(1, 1)); + + assert.equal(result.maxDepth, 1); + assert.equal(result.depth, 1); + assert.equal(result.blocked, true); + }); + + test("the stricter of the local and inherited maximums wins in either direction", () => { + assert.equal(checkSubagentDepth(contextAtDepth(0, 3), 2).maxDepth, 2); + assert.equal(checkSubagentDepth(contextAtDepth(0, 2), 3).maxDepth, 2); + assert.equal(checkSubagentDepth(contextAtDepth(0), 3).maxDepth, 3); + }); - const firstChildEnv = getSubagentDepthEnv(2, { workflowStageSubagentGuard: true }); - process.env[DEPTH_ENV] = firstChildEnv[DEPTH_ENV]; - process.env[MAX_DEPTH_ENV] = firstChildEnv[MAX_DEPTH_ENV]; - process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = firstChildEnv[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - const secondChildEnv = getSubagentDepthEnv(2, { workflowStageSubagentGuard: true }); - assert.equal(secondChildEnv[DEPTH_ENV], "2"); - assert.equal(secondChildEnv[MAX_DEPTH_ENV], "2"); - assert.equal(secondChildEnv[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV], "1"); + test("workflow-stage rejection uses the workflow-specific message", () => { + const result = checkSubagentDepth(contextAtDepth(2), 2); - process.env[DEPTH_ENV] = secondChildEnv[DEPTH_ENV]; - process.env[MAX_DEPTH_ENV] = secondChildEnv[MAX_DEPTH_ENV]; - process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV] = secondChildEnv[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; - const result = checkSubagentDepth(); assert.equal(result.blocked, true); - assert.equal(result.workflowStageGuard, true); assert.match( subagentDepthBlockedMessage(result.depth, result.maxDepth, { workflowStageGuard: true }), /Sub-agents inside workflow stages are running at the maximum nesting depth/, diff --git a/test/unit/subagents-foreground-guard-propagation.test.ts b/test/unit/subagents-foreground-guard-propagation.test.ts index b0db38029..82d548b64 100644 --- a/test/unit/subagents-foreground-guard-propagation.test.ts +++ b/test/unit/subagents-foreground-guard-propagation.test.ts @@ -2,9 +2,9 @@ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "@bastani/atomic"; import { afterAll, beforeEach, describe, test, vi } from "vitest"; import { createSubagentExecutor } from "../../packages/subagents/src/runs/foreground/subagent-executor.js"; -import { WORKFLOW_STAGE_SUBAGENT_GUARD_ENV } from "../../packages/subagents/src/shared/types.js"; interface MinimalRunSyncOptions { maxSubagentDepth?: number; @@ -172,8 +172,6 @@ function makeExecutor(agents: MinimalAgentConfig[]) { } function clearSubagentGuardEnv(): void { - delete process.env.ATOMIC_SUBAGENT_DEPTH; - delete process.env.ATOMIC_SUBAGENT_MAX_DEPTH; delete process.env[WORKFLOW_STAGE_SUBAGENT_GUARD_ENV]; } @@ -289,3 +287,215 @@ describe("foreground workflow-stage subagent guard propagation", () => { assert.equal(asyncSingleCalls[0]!.params.workflowStageSubagentGuard, true); }); }); + +function cappedRunSyncDepths(): Record { + return Object.fromEntries(runSyncCalls.map((call) => [call.agentName, call.options.maxSubagentDepth])); +} + +describe("per-agent maximum narrows every delegation mode", () => { + // The stage constraint is 2; `capped` declares 1 in its own definition. Each + // mode must hand the child the stricter of the two, not the stage limit. + const cappedAgents = () => [makeAgent("capped", 1), makeAgent("uncapped")]; + + test("a foreground single child receives its agent's tightened maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-single-agent-max-")); + const executor = makeExecutor(cappedAgents()); + + const result = await executor.execute( + "subagent", + { agent: "capped", task: "single task" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(result); + assert.deepEqual(cappedRunSyncDepths(), { capped: 1 }); + }); + + test("foreground parallel children each receive their own agent's maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-parallel-agent-max-")); + const executor = makeExecutor(cappedAgents()); + + const result = await executor.execute( + "subagent", + { + tasks: [ + { agent: "capped", task: "first" }, + { agent: "uncapped", task: "second" }, + ], + }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(result); + assert.deepEqual(cappedRunSyncDepths(), { capped: 1, uncapped: 2 }); + }); + + test("sequential and parallel chain steps each receive their own agent's maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-chain-agent-max-")); + const executor = makeExecutor([...cappedAgents(), makeAgent("alsoCapped", 1)]); + + const result = await executor.execute( + "subagent", + { + chain: [ + { agent: "capped", task: "first" }, + { + parallel: [ + { agent: "alsoCapped", task: "second" }, + { agent: "uncapped", task: "third" }, + ], + }, + ], + }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(result); + assert.deepEqual(cappedRunSyncDepths(), { capped: 1, alsoCapped: 1, uncapped: 2 }); + }); + + test("an async single child receives its agent's tightened maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-single-async-agent-max-")); + const executor = makeExecutor(cappedAgents()); + + const result = await executor.execute( + "subagent", + { agent: "capped", task: "single task", async: true }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(result); + assert.equal(asyncSingleCalls.length, 1); + assert.equal(asyncSingleCalls[0]!.params.maxSubagentDepth, 1); + }); + + test("async parallel children each receive their own agent's maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-parallel-async-agent-max-")); + const executor = makeExecutor(cappedAgents()); + + const result = await executor.execute( + "subagent", + { + tasks: [ + { agent: "capped", task: "first" }, + { agent: "uncapped", task: "second" }, + ], + async: true, + }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + await new Promise((resolve) => setImmediate(resolve)); + + assertNoErrorFlag(result); + assert.deepEqual(cappedRunSyncDepths(), { capped: 1, uncapped: 2 }); + }); +}); + +describe("retained foreground resume keeps the child's effective maximum", () => { + test("a resumed child keeps the maximum its agent definition narrowed", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-retained-resume-max-")); + // Config maximum 2, agent maximum 1: the resume must carry 1, not 2. + const executor = makeExecutor([makeAgent("capped", 1)]); + + const initial = await executor.execute( + "subagent", + { agent: "capped", task: "initial task" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + assertNoErrorFlag(initial); + assert.equal(runSyncCalls[0]?.options.maxSubagentDepth, 1); + const runId = initial.details.runId; + assert.ok(runId, "the initial delegation must retain a run id"); + + // No live in-process control exists for this run, so resume falls back to the + // retained foreground record, which is the path under test. + const resumed = await executor.execute( + "subagent", + { action: "resume", id: runId, message: "keep going" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(resumed); + assert.equal(asyncSingleCalls.length, 1); + assert.equal(asyncSingleCalls[0]!.params.maxSubagentDepth, 1); + }); + + test("a resumed child without its own agent cap keeps the stage maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-retained-resume-uncapped-")); + const executor = makeExecutor([makeAgent("uncapped")]); + + const initial = await executor.execute( + "subagent", + { agent: "uncapped", task: "initial task" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + assertNoErrorFlag(initial); + const runId = initial.details.runId; + assert.ok(runId); + + const resumed = await executor.execute( + "subagent", + { action: "resume", id: runId, message: "keep going" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(resumed); + assert.equal(asyncSingleCalls.length, 1); + assert.equal(asyncSingleCalls[0]!.params.maxSubagentDepth, 2); + }); + + test("a widened agent definition cannot raise an already-retained child's maximum", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "atomic-retained-resume-widened-")); + // The executor reads this array on every call, so editing it after the + // initial run models an agent file edited between the run and the resume. + const agents = [makeAgent("capped", 1)]; + const executor = makeExecutor(agents); + + const initial = await executor.execute( + "subagent", + { agent: "capped", task: "initial task" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + assertNoErrorFlag(initial); + const runId = initial.details.runId; + assert.ok(runId); + + agents[0] = makeAgent("capped"); + + const resumed = await executor.execute( + "subagent", + { action: "resume", id: runId, message: "keep going" }, + new AbortController().signal, + undefined, + makeWorkflowStageContext(cwd), + ); + + assertNoErrorFlag(resumed); + assert.equal(asyncSingleCalls.length, 1); + assert.equal( + asyncSingleCalls[0]!.params.maxSubagentDepth, + 1, + "the resume must use the limit recorded with the run, not the edited definition", + ); + }); +}); diff --git a/test/unit/subagents-inprocess-child-resources.test.ts b/test/unit/subagents-inprocess-child-resources.test.ts new file mode 100644 index 000000000..74ec570fd --- /dev/null +++ b/test/unit/subagents-inprocess-child-resources.test.ts @@ -0,0 +1,256 @@ +/// + +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getModel } from "@earendil-works/pi-ai/compat"; +import { afterEach, describe, test } from "vitest"; +import { DefaultResourceLoader } from "../../packages/coding-agent/src/core/resource-loader.js"; +import { createAgentSession } from "../../packages/coding-agent/src/core/sdk.js"; +import { SessionManager } from "../../packages/coding-agent/src/core/session-manager.js"; +import { SettingsManager } from "../../packages/coding-agent/src/core/settings-manager.js"; +import type { SubagentChildPolicy } from "../../packages/coding-agent/src/index.js"; +import type { AgentConfig } from "../../packages/subagents/src/agents/agent-types.js"; +import { inProcessChildResourceLoaderOptions } from "../../packages/subagents/src/runs/inprocess/runner.js"; +import { MAX_SUBAGENT_NESTING_DEPTH } from "../../packages/subagents/src/shared/types.js"; + +const tempDirs: string[] = []; + +/** + * Structural cost, not a slow test: every case performs a full builtin-package + * loader reload and creates a real agent session from the result. Do not reuse + * this budget for a test that merely inspects data. + */ +const CHILD_SESSION_RELOAD_TIMEOUT_MS = 120_000; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + if (existsSync(dir)) rmSync(dir, { recursive: true, force: true }); + } +}); + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function childAgent(): AgentConfig { + return { + name: "worker", + description: "worker agent", + systemPrompt: "", + systemPromptMode: "replace", + inheritProjectContext: false, + inheritSkills: false, + source: "user", + filePath: "/tmp/worker.md", + }; +} + +const stageContext = { + kind: "workflow-stage", + workflowRunId: "run-test", + workflowStageId: "stage-test", + workflowStageName: "Stage Test", + constraints: { disableWorkflowTool: true, maxSubagentDepth: 5 }, +} as const; + +/** Create a child session the way the in-process runner does. */ +async function createChildSession(options: { + readonly cwd: string; + readonly agentDir: string; + readonly orchestrationContext?: typeof stageContext; + /** Drop the bundled package roots, reproducing the pre-fix child loader. */ + readonly withoutBundledPackages?: boolean; + readonly policy?: Partial; +}) { + const model = getModel("anthropic", "claude-sonnet-4-5"); + assert.notEqual(model, undefined); + const settingsManager = SettingsManager.create(options.cwd, options.agentDir); + const loaderOptions = inProcessChildResourceLoaderOptions({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + agent: childAgent(), + orchestrationContext: options.orchestrationContext, + }); + const resourceLoader = new DefaultResourceLoader( + options.withoutBundledPackages ? { ...loaderOptions, builtinPackagePaths: [] } : loaderOptions, + ); + await resourceLoader.reload(); + return createAgentSession({ + cwd: options.cwd, + agentDir: options.agentDir, + settingsManager, + resourceLoader, + sessionManager: SessionManager.inMemory(options.cwd), + model: model!, + ...(options.orchestrationContext ? { orchestrationContext: options.orchestrationContext } : {}), + subagentPolicy: { + managementActions: "full", + fanoutAuthorized: true, + inheritProjectContext: false, + inheritSkills: false, + depth: 1, + ...options.policy, + }, + }); +} + +function sessionCwd(prefix: string): { cwd: string; agentDir: string } { + const cwd = tempDir(prefix); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); + return { cwd, agentDir }; +} + +const REQUIRED_BUNDLED_TOOLS = ["subagent", "web_search", "fetch_content", "intercom"] as const; + +describe("in-process child session resources", () => { + test( + "a child session registers every required bundled tool", + async () => { + const { cwd, agentDir } = sessionCwd("atomic-inprocess-child-tools-cwd-"); + const { session } = await createChildSession({ cwd, agentDir }); + try { + const toolNames = session.getAllTools().map((tool) => tool.name); + for (const bundled of REQUIRED_BUNDLED_TOOLS) { + assert.ok( + toolNames.includes(bundled), + `expected the bundled '${bundled}' tool, got: ${toolNames.join(", ")}`, + ); + } + } finally { + session.dispose(); + } + }, + CHILD_SESSION_RELOAD_TIMEOUT_MS, + ); + + test( + "a non-fanout child still registers the subagent tool but is refused delegation by policy", + async () => { + const { cwd, agentDir } = sessionCwd("atomic-inprocess-child-nonfanout-cwd-"); + // resolveChildModePolicy grants fanout only when the admitted tool + // allowlist names `subagent`; an omitted allowlist still registers the + // bundled tool through normal discovery. Registration is not authority. + const { session } = await createChildSession({ + cwd, + agentDir, + policy: { managementActions: "restricted", fanoutAuthorized: false }, + }); + try { + const tool = session.getToolDefinition("subagent"); + assert.ok(tool, "a non-fanout child still receives the registered subagent tool"); + + const delegated = await tool.execute( + "non-fanout-delegation", + { agent: "worker", task: "delegate one level further", context: "fresh" } as never, + new AbortController().signal, + undefined, + session.extensionRunner.createContext(), + ); + assert.equal( + delegated.content.map((part) => (part.type === "text" ? part.text : "")).join("\n"), + "Subagent fanout is not authorized for this child.", + ); + + const listed = await tool.execute( + "non-fanout-list", + { action: "list" } as never, + new AbortController().signal, + undefined, + session.extensionRunner.createContext(), + ); + assert.ok( + !listed.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n") + .includes("Subagent fanout is not authorized"), + "the observing `list` action stays available to a non-fanout child", + ); + } finally { + session.dispose(); + } + }, + CHILD_SESSION_RELOAD_TIMEOUT_MS, + ); + + test( + "a child whose agent tightened the maximum is refused delegation by its own subagent tool", + async () => { + assert.equal(MAX_SUBAGENT_NESTING_DEPTH, 5); + const { cwd, agentDir } = sessionCwd("atomic-inprocess-child-inherited-max-cwd-"); + const { session } = await createChildSession({ + cwd, + agentDir, + policy: { depth: 1, maxSubagentDepth: 1 }, + }); + try { + const tool = session.getToolDefinition("subagent"); + assert.ok(tool, "the child must register the bundled subagent tool"); + const result = await tool.execute( + "inherited-max", + { agent: "worker", task: "delegate one level further", context: "fresh" } as never, + new AbortController().signal, + undefined, + session.extensionRunner.createContext(), + ); + const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n"); + + assert.ok(text.startsWith("Nested subagent call blocked (depth=1, max=1)"), `unexpected message: ${text}`); + } finally { + session.dispose(); + } + }, + CHILD_SESSION_RELOAD_TIMEOUT_MS, + ); + + test( + "a child of a workflow stage registers every required bundled tool without the workflow tool", + async () => { + const { cwd, agentDir } = sessionCwd("atomic-inprocess-child-stage-tools-cwd-"); + const { session } = await createChildSession({ cwd, agentDir, orchestrationContext: stageContext }); + try { + const toolNames = session.getAllTools().map((tool) => tool.name); + for (const bundled of REQUIRED_BUNDLED_TOOLS) { + assert.ok( + toolNames.includes(bundled), + `expected the bundled '${bundled}' tool, got: ${toolNames.join(", ")}`, + ); + } + assert.equal( + toolNames.includes("workflow"), + false, + "a stage-owned child must not re-enter the workflow tool", + ); + } finally { + session.dispose(); + } + }, + CHILD_SESSION_RELOAD_TIMEOUT_MS, + ); + + test( + "dropping the bundled package roots leaves a child with no bundled tool at all", + async () => { + const { cwd, agentDir } = sessionCwd("atomic-inprocess-child-nobundled-cwd-"); + const { session } = await createChildSession({ cwd, agentDir, withoutBundledPackages: true }); + try { + const toolNames = session.getAllTools().map((tool) => tool.name); + for (const bundled of ["subagent", "web_search", "fetch_content", "intercom"]) { + assert.equal( + toolNames.includes(bundled), + false, + `expected the pre-fix loader to lose '${bundled}', got: ${toolNames.join(", ")}`, + ); + } + } finally { + session.dispose(); + } + }, + CHILD_SESSION_RELOAD_TIMEOUT_MS, + ); +}); diff --git a/test/unit/subagents-inprocess-runner.test.ts b/test/unit/subagents-inprocess-runner.test.ts index 7c00df88d..04229587f 100644 --- a/test/unit/subagents-inprocess-runner.test.ts +++ b/test/unit/subagents-inprocess-runner.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { test } from "vitest"; import type { AgentConfig } from "../../packages/subagents/src/agents/agent-types.ts"; import { runSingleInProcess } from "../../packages/subagents/src/runs/foreground/inprocess-run-sync.ts"; @@ -17,9 +17,11 @@ import { type AttemptOutcome, type ChildSpec, continue_in_background, + inProcessChildBuiltinPackagePaths, type RunningAttempt, SubagentControlRuntime, } from "../../packages/subagents/src/runs/inprocess/runner.ts"; +import { MAX_SUBAGENT_NESTING_DEPTH } from "../../packages/subagents/src/shared/types.ts"; import { resultStatusLine } from "../../packages/subagents/src/tui/render-status-progress.js"; import { sleep } from "../helpers/runtime.ts"; @@ -40,6 +42,63 @@ function sampleSpec(cwd: string): ChildSpec { return { taskName: "analysis", task: "inspect the fixture", agent: sampleAgent(), cwd }; } +test("a run's parent depth reaches admission and the door refuses the level past the maximum", async () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-depth-")); + clearSubagentControls(); + try { + const deepestAllowed = await runSingleInProcess(root, sampleAgent(), "inspect fixture", { + cwd: root, + runId: "depth-parent-below-limit", + sessionDir: join(root, "sessions", "below-limit"), + parentDepth: MAX_SUBAGENT_NESTING_DEPTH - 1, + testSession: { output: "deep result" }, + }); + + assert.equal(deepestAllowed.status, "ok"); + const deepestControl = findSubagentControl("depth-parent-below-limit"); + assert.equal( + deepestControl?.native.listChildren()[0]?.depth, + MAX_SUBAGENT_NESTING_DEPTH, + "a child of the deepest allowed parent is admitted at the maximum depth", + ); + + const beyondLimit = await runSingleInProcess(root, sampleAgent(), "inspect fixture", { + cwd: root, + runId: "depth-parent-at-limit", + sessionDir: join(root, "sessions", "at-limit"), + parentDepth: MAX_SUBAGENT_NESTING_DEPTH, + testSession: { output: "deep result" }, + }); + + assert.equal(beyondLimit.status, "error"); + assert.equal(beyondLimit.error, `child depth exceeds maximum ${MAX_SUBAGENT_NESTING_DEPTH}`); + } finally { + clearSubagentControls(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("in-process child loading includes bundled subagent resources", () => { + const packagePath = (source: string | { source: string }): string => + typeof source === "string" ? source : source.source; + const builtinPaths = inProcessChildBuiltinPackagePaths(undefined); + const subagentsPath = builtinPaths.find((source) => basename(packagePath(source)) === "subagents"); + const workflowsPath = builtinPaths.find((source) => basename(packagePath(source)) === "workflows"); + + assert.ok(subagentsPath, "in-process children must load the bundled subagents package"); + assert.ok(workflowsPath, "source checkout must expose the bundled workflows package"); + + const stagePaths = inProcessChildBuiltinPackagePaths({ + kind: "workflow-stage", + workflowRunId: "run", + workflowStageId: "stage", + workflowStageName: "Stage", + constraints: { disableWorkflowTool: true, maxSubagentDepth: 5 }, + }); + const stageWorkflowsPath = stagePaths.find((source) => basename(packagePath(source)) === "workflows"); + assert.deepEqual(stageWorkflowsPath, { source: packagePath(workflowsPath), extensions: [] }); +}); + test("admission resolves restricted child management and explicit fanout policy", () => { const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-policy-")); try { @@ -51,6 +110,7 @@ test("admission resolves restricted child management and explicit fanout policy" ); assert.ok(result.admitted); assert.equal(result.admitted.policy.managementActions, "restricted"); + assert.equal(result.admitted.policy.depth, 1); assert.equal(result.admitted.policy.fanoutAuthorized, true); const noFanout = control.admitChildSession(sampleSpec(root), { path: "parent", depth: 0 }); assert.ok(noFanout.admitted); @@ -61,6 +121,122 @@ test("admission resolves restricted child management and explicit fanout policy" } }); +test("admission carries the effective per-agent maximum into the child policy", () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-max-depth-")); + try { + const control = new SubagentControlRuntime({ path: "parent", depth: 0 }, root); + control.registerAgents([sampleAgent()]); + + const capped = control.admitChildSession( + { ...sampleSpec(root), maxSubagentDepth: 1 }, + { path: "parent", depth: 0 }, + ); + assert.ok(capped.admitted); + assert.equal(capped.admitted.policy.depth, 1); + assert.equal(capped.admitted.policy.maxSubagentDepth, 1); + + const uncapped = control.admitChildSession(sampleSpec(root), { path: "parent", depth: 0 }); + assert.ok(uncapped.admitted); + assert.equal(uncapped.admitted.policy.maxSubagentDepth, undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("direct admission falls back to the agent's own declared maximum", () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-agent-max-depth-")); + try { + const cappedAgent: AgentConfig = { ...sampleAgent(), maxSubagentDepth: 1 }; + const control = new SubagentControlRuntime({ path: "agent-max-parent", depth: 0 }, root); + control.registerAgents([cappedAgent]); + + // The spec omits maxSubagentDepth, as a caller admitting a child directly does. + const admitted = control.admitChildSession( + { taskName: cappedAgent.name, task: "inspect the fixture", agent: cappedAgent, cwd: root }, + { path: "agent-max-parent", depth: 0 }, + ); + + assert.ok(admitted.admitted); + assert.equal(admitted.admitted.policy.depth, 1); + assert.equal(admitted.admitted.policy.maxSubagentDepth, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("an explicitly narrowed spec maximum wins over the agent's declared one", () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-narrowed-max-depth-")); + try { + const cappedAgent: AgentConfig = { ...sampleAgent(), maxSubagentDepth: 3 }; + const control = new SubagentControlRuntime({ path: "narrowed-parent", depth: 0 }, root); + control.registerAgents([cappedAgent]); + + const admitted = control.admitChildSession( + { + taskName: cappedAgent.name, + task: "inspect the fixture", + agent: cappedAgent, + cwd: root, + maxSubagentDepth: 1, + }, + { path: "narrowed-parent", depth: 0 }, + ); + + assert.ok(admitted.admitted); + assert.equal(admitted.admitted.policy.maxSubagentDepth, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a retained spec keeps the effective maximum across a cold reload", () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-max-depth-reload-")); + try { + const control = new SubagentControlRuntime({ path: "reload-parent", depth: 0 }, root); + control.registerAgents([sampleAgent()]); + const admitted = control.admitChildSession( + { ...sampleSpec(root), maxSubagentDepth: 1 }, + { path: "reload-parent", depth: 0 }, + ); + assert.ok(admitted.admitted); + mkdirSync(admitted.admitted.sessionDir, { recursive: true }); + writeFileSync(join(admitted.admitted.sessionDir, "session.jsonl"), "", "utf8"); + + const reloaded = control.reloadColdChild(admitted.admitted.identity.path, "follow up"); + + assert.ok(reloaded.admitted, reloaded.refusal?.reason); + assert.equal(reloaded.admitted.policy.maxSubagentDepth, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a run's effective maximum reaches the admitted child policy", async () => { + const root = mkdtempSync(join(tmpdir(), "atomic-inprocess-max-depth-run-")); + clearSubagentControls(); + try { + const result = await runSingleInProcess(root, sampleAgent(), "inspect fixture", { + cwd: root, + runId: "max-depth-parent", + sessionDir: join(root, "sessions"), + maxSubagentDepth: 1, + testSession: { output: "capped result" }, + }); + assert.equal(result.status, "ok"); + + const control = findSubagentControl("max-depth-parent"); + assert.ok(control); + const childPath = result.path ?? ""; + const reloaded = control.reloadColdChild(childPath, "follow up"); + + assert.ok(reloaded.admitted, reloaded.refusal?.reason); + assert.equal(reloaded.admitted.policy.maxSubagentDepth, 1); + } finally { + clearSubagentControls(); + rmSync(root, { recursive: true, force: true }); + } +}); + function emptyOutcome(): AttemptOutcome { return { status: "error", diff --git a/test/unit/wiring-adapters-01.test.ts b/test/unit/wiring-adapters-01.test.ts index ce6924ed2..cdbb5368e 100644 --- a/test/unit/wiring-adapters-01.test.ts +++ b/test/unit/wiring-adapters-01.test.ts @@ -360,7 +360,7 @@ describe("prepareAtomicStageSessionOptions", () => { assert.deepEqual(firstOptions?.subagentPolicy, { managementActions: "full", - fanoutAuthorized: false, + fanoutAuthorized: true, inheritProjectContext: true, inheritSkills: true, }); diff --git a/test/unit/workflow-stage-bundled-resources.test.ts b/test/unit/workflow-stage-bundled-resources.test.ts index 0b32eaadb..4bb71f3b5 100644 --- a/test/unit/workflow-stage-bundled-resources.test.ts +++ b/test/unit/workflow-stage-bundled-resources.test.ts @@ -198,6 +198,35 @@ describe("workflow stage bundled resources", () => { } }); + test("delegates through the registered subagent tool from a workflow stage", async () => { + const snapshot = snapshotEnv(); + const cwd = tempDir("atomic-workflow-stage-delegation-cwd-"); + const agentDir = join(cwd, "agent"); + mkdirSync(agentDir, { recursive: true }); + try { + const { session } = await createWorkflowStageSession({ cwd, agentDir }); + try { + const tool = session.getToolDefinition("subagent"); + assert.ok(tool, "workflow stages must register the subagent tool"); + const result = await tool.execute( + "stage-delegation", + { agent: "worker", task: "complete this test task", context: "fresh" } as never, + undefined, + undefined, + session.extensionRunner.createContext(), + ); + assert.ok( + result.content.some((part) => part.type === "text" && part.text.includes("done")), + "the stage tool must return the in-process child result", + ); + } finally { + session.dispose(); + } + } finally { + restoreEnv(snapshot); + } + }); + test("keeps explicit workflow stage tool allowlists authoritative", async () => { const cwd = tempDir("atomic-workflow-stage-explicit-tools-cwd-"); const agentDir = join(cwd, "agent");