Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changed

- Parallel subagent batches now run at a fixed default of 8 children instead of a CPU-derived ceiling (`max(1, min(16, cores - 2))`), which squeezed small machines down to one or two children even though a child waits on a remote model, not on a local core. `[subagent] max_concurrency` (StepCode JSON: `subagent.maxConcurrency`) sets it per batch and may be higher or lower than the default; an invalid value is a settings warning and is ignored. `STEP_WORKFLOW_CONCURRENCY_LIMIT` no longer bounds subagent batches — it caps workflow `agent()` fan-out only — so a backend that limits simultaneous requests needs `[subagent] max_concurrency` as well. Batches still take slots from the process-wide admission window shared with workflow nodes, whose capacity is the smallest ceiling among the runs attached, so a concurrent workflow with a lower ceiling also lowers a batch and a 429 seen by either lowers both. See [Step configuration](docs/step-configuration.md#subagents).
- `AgentLoopConfig.toolCallLeakRetries` is removed. The low-level loop no longer guesses a tool intent from markup in text; it records the structural classification (`classifyIncompleteAssistantTurn()`, now in `@step-harness/providers`) on every finished assistant message as `incomplete`, and hosts decide what to do about it.
- Plan mode is now entered only by the user, with `/plan` or `--plan`; the `enter_plan_mode` tool is removed. The default system prompt no longer carries a `# Planning` section. Entering plan mode appends one hidden `<collaboration_mode>` block with the plan method and plan path, and leaving it appends a short Default block, so the system prompt and tool block stay byte-identical across a plan cycle. Sessions recorded with an agent-initiated plan still restore. See [plan approval](docs/plan-approval.md#mode-announcements).
- Subagents are no longer an automatic escalation. The `subagent` tool description now carries an explicit-request gate and a delegation method (critical path first, self-contained briefs, disjoint write sets, no redoing delegated work), replacing the system prompt's `# Delegation` section; `# Coordination primitives` only routes between authorized primitives. Session-standing `/ultracode on` also authorizes proactive delegation. See [orchestration lifecycle](docs/orchestration-lifecycle.md#subagent-delegation-authorization).
Expand All @@ -25,7 +26,7 @@
- Stop appending Ultracode capability tutorials on every turn. Announce session-mode transitions after user input, reuse unchanged state, and restore necessary reminders when context is compacted or replaced.
- Recognize `/exit` as an alias for `/quit`, including while the agent is running or compacting.
- Recognize `search_files`, `find_files`, and `list_directory` when selecting default system-prompt file-exploration guidance.
- Report callback errors (including nested workflow errors raised before any agent starts), and queue ordinary subagent batches beyond their concurrency window in the same process-wide admission window as workflow nodes. Subagent batches also honor `STEP_WORKFLOW_CONCURRENCY_LIMIT` and hold each slot until the child process exits.
- Report callback errors (including nested workflow errors raised before any agent starts), and queue ordinary subagent batches beyond their own concurrency window in the same process-wide admission window as workflow nodes. A batch holds each slot until the child process exits.
- Let a tool return `isError: true` while keeping its partial output and details, so failed subagent and workflow results reach the model as errors. Finalize a partial assistant message when a provider stream throws, ends without a terminal event, or fails during construction, instead of leaving the turn unfinished.
- Classify provider failures structurally (HTTP status, provider error code, transport error) as rate limit, quota, auth, transient, or invalid request. Session retries, the separate 429 retry budget, and workflow congestion control use this classification instead of matching error text, so a 400 that mentions "429" is no longer retried and an exhausted quota returned as 429 is not treated as throttling.
- Resume workflows by matching each call's prompt and options instead of the journal's sequence prefix, so a failed or reordered node no longer forces every later call to re-run. Once a live call writes files, runs a shell command, or uses an unrecognized tool, cached calls that read the environment re-run as well; cached results are re-validated against the call's schema, and calls with a runtime (safeParse) schema always run live.
4 changes: 4 additions & 0 deletions apps/cli/src/bootstrap/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ export function createStepExtensionFactories(deps: StepExtensionFactoryDeps): In
}
: undefined;
},
maxConcurrencySettings: () => {
const settings = deps.stepSettings?.()?.getStepSettings();
return settings ? { maxConcurrency: settings.subagentMaxConcurrency } : undefined;
},
},
}),
createStepCronExtension({ telemetry: deps.telemetry }),
Expand Down
25 changes: 20 additions & 5 deletions docs/step-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,37 @@ Step screens pass the Step agent directory; generic TUI callers default to the
system temporary directory. Rendering equivalence tests select the uncached
renderer through a test-process argument.

### Background subagents
### Subagents

The `[subagent]` table controls background subagent lanes:
The `[subagent]` table controls subagent concurrency and background lanes:

```toml
[subagent]
background = true # false runs run_in_background calls in the foreground
background_timeout_seconds = 3600 # default per-turn wall-clock limit of a lane
max_concurrency = 8 # children running at once across parallel batches
```

A trusted project's `config.toml` overrides the global values.
`background_timeout_seconds` must be an integer from 60 to 7,200; any other
value is reported as a settings warning and ignored, so the 3,600-second default
applies. A call's own `timeout_seconds` takes precedence. When
`STEPCODE_CONFIG_PATH` selects a StepCode JSON config, the same settings live
under `subagent.background` and `subagent.backgroundTimeoutSeconds`.
applies. A call's own `timeout_seconds` takes precedence.

`max_concurrency` sets how many child processes run at once across parallel
subagent batches. It applies to foreground and print batches too, not only to
background lanes, and is a different limit from the at most eight running lanes
per session described in the [orchestration
lifecycle](orchestration-lifecycle.md#background-subagent-lanes). It defaults to
8 and must be a positive integer; there is no separate upper bound, so a
configured value may raise the limit above the default, and the batch itself is
still capped by `maxParallelTasks` (1,000 by default). Batches take slots from
the same process-wide admission window as workflow nodes, whose capacity is the
smallest ceiling among the runs currently attached: a concurrent workflow with a
lower ceiling also lowers a batch, and a 429 seen by either lowers both. The
value is read on every call, so a config reload takes effect without a restart.
When `STEPCODE_CONFIG_PATH` selects a StepCode JSON config, the same settings
live under `subagent.background` and `subagent.backgroundTimeoutSeconds`;
`max_concurrency` lives under `subagent.maxConcurrency`.
`STEP_DISABLE_SUBAGENT_BACKGROUND=1` disables background lanes regardless of the
file. Print and JSON runs never start lanes. See the [orchestration
lifecycle](orchestration-lifecycle.md#background-subagent-lanes) for lane
Expand Down
4 changes: 2 additions & 2 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ Temporary 429/concurrency errors retry the current model request, preserving com

### Workflow concurrency and failures

The harness does not pattern-match requests such as `开10个子agent` / `launch 10 subagents`; the model decides whether they call for the `subagent` tool's parallel mode or a workflow, and whether the message is a request at all. The `subagent` tool accepts batches larger than its concurrent window (up to 1000 by default) and queues the excess. Its children and workflow nodes take slots from the same process-wide admission window, so running both at once cannot exceed the host ceiling, and a 429 seen by either lowers it for both.
The harness does not pattern-match requests such as `开10个子agent` / `launch 10 subagents`; the model decides whether they call for the `subagent` tool's parallel mode or a workflow, and whether the message is a request at all. The `subagent` tool accepts batches larger than its own window (`[subagent] max_concurrency`, default 8, at most `maxParallelTasks` = 1,000 by default) and queues the excess. Its children and workflow nodes take slots from the same process-wide admission window, whose capacity is the smallest ceiling among the runs currently attached: a batch and a workflow running together are bound by the more conservative of the two, and a 429 seen by either lowers it for both.

The workflow ceiling is `max(1, min(16, CPU count - 2))`. Model-authored `maxConcurrency` can only lower this ceiling. Set `STEP_WORKFLOW_CONCURRENCY_LIMIT=8` to impose a lower trusted provider limit (for example, a backend allowing eight simultaneous requests). The effective ceiling is the minimum of the machine and configured limits; the final result reports requested, effective, and current concurrency.
The workflow ceiling is `max(1, min(16, CPU count - 2))`. Model-authored `maxConcurrency` can only lower this ceiling. Set `STEP_WORKFLOW_CONCURRENCY_LIMIT=8` to impose a lower trusted provider limit on workflow `agent()` calls (for example, a backend allowing eight simultaneous requests); it does not bound subagent batches, which use `[subagent] max_concurrency`. The effective workflow ceiling is the minimum of the machine and configured limits; the final result reports requested, effective, and current concurrency.

Rate limits are recognized from the provider's structured classification (HTTP 429 or a rate-limit error code), never from text: error messages, agent output, and node labels that mention "429" or "rate limit" are not treated as congestion, and a 429 whose error code reports exhausted quota or billing (`insufficient_quota`, `GoUsageLimitError`, HTTP 402) is terminal. Temporary 429s reduce the admission cap by 25% (minimum one), once per congestion epoch. New nodes wait during backoff; running nodes keep their slots and retry their current request. Four successful responses allow a one-slot recovery when work is queued. Concurrent workflows share a process-wide host window, in addition to each run's lower limit. This conservatively covers all model overrides; it is not a cross-process/account-wide quota lock.

Expand Down
48 changes: 42 additions & 6 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ import type { NewSessionOptions } from "../core/session-manager.ts";
import { resolveStepAgentDir, resolveStepConfigDir } from "../step/environment.ts";
import {
isValidSubagentBackgroundTimeout,
isValidSubagentMaxConcurrency,
SUBAGENT_BACKGROUND_TIMEOUT_DEFAULT_SECONDS,
SUBAGENT_BACKGROUND_TIMEOUT_MAX_SECONDS,
SUBAGENT_BACKGROUND_TIMEOUT_MIN_SECONDS,
SUBAGENT_MAX_CONCURRENCY_DEFAULT,
} from "../step/settings-manager.ts";
import type { StepTelemetryReporter } from "../step/telemetry.ts";
import type { StepAgentConfig, StepAgentScope } from "./step-subagent-agents.ts";
Expand All @@ -49,7 +51,6 @@ import {
} from "./subagent/rendering.ts";
import { createSubagentRpcSession } from "./subagent/rpc-adapter.ts";
import { buildSubagentToolDescription } from "./subagent/tool-description.ts";
import { clampWorkflowConcurrency } from "./workflow/budget.ts";

// Lane-notification primitives moved to ./subagent/lane-events.ts; re-exported
// here to keep this module's public surface stable.
Expand Down Expand Up @@ -243,7 +244,11 @@ export interface StepSubagentExtensionOptions {
includeBuiltinAgents?: boolean;
/** Maximum batch size accepted in one parallel call; excess tasks queue instead of running. */
maxParallelTasks?: number;
/** Number of child processes allowed to run at once; tasks beyond this stay queued. */
/**
* Fallback number of child processes allowed to run at once, used only when
* `[subagent] max_concurrency` is unset; a configured value overrides it.
* Must be a positive integer.
*/
maxConcurrency?: number;
/** Optional isolated git worktree implementation. */
worktreeManager?: StepWorktreeManager;
Expand All @@ -256,6 +261,13 @@ export interface StepSubagentExtensionOptions {
* `config.toml`), read on every call so a reload takes effect.
*/
backgroundSettings?: () => StepSubagentBackgroundSettings | undefined;
/**
* Host-supplied `[subagent] max_concurrency` (config.toml), read on every
* call so a reload takes effect. The settings boundary validates the value,
* so it is trusted here and overrides both the `maxConcurrency` option and
* the built-in default; it may exceed the default.
*/
maxConcurrencySettings?: () => StepSubagentConcurrencySettings | undefined;
}

export interface StepSubagentBackgroundSettings {
Expand All @@ -265,6 +277,25 @@ export interface StepSubagentBackgroundSettings {
timeoutSeconds?: number;
}

export interface StepSubagentConcurrencySettings {
/** Default number of child processes a parallel batch runs at once. */
maxConcurrency?: number;
}

/**
* Resolve the parallel-batch ceiling: a validated configured value, then a
* validated host option, then the default. There is deliberately no CPU- or
* provider-derived ceiling — a child waits on a remote model, not on a local
* core — but an invalid value must never reach the shared admission window,
* where a non-positive or non-finite capacity wedges the process-wide pool
* instead of failing one batch.
*/
export function resolveSubagentConcurrency(configured?: number, option?: number): number {
if (isValidSubagentMaxConcurrency(configured)) return configured;
if (isValidSubagentMaxConcurrency(option)) return option;
return SUBAGENT_MAX_CONCURRENCY_DEFAULT;
}

// Keep the model-facing contract identical to Pi's subagent example. Step
// adapts discovery, process invocation, storage, telemetry, and theme only.
export const StepTaskItem = Type.Object(
Expand Down Expand Up @@ -737,20 +768,25 @@ async function withSubagentListWidget(

/** Construct the hidden inline extension used by the Step launcher. */
export function createStepSubagentExtension(options: StepSubagentExtensionOptions = {}): ExtensionFactory {
// The configured setting is validated where it is read; a host option is a
// programming error, so reject it here while the caller can still see it.
if (options.maxConcurrency !== undefined && !isValidSubagentMaxConcurrency(options.maxConcurrency)) {
throw new Error(
`Invalid Step subagent maxConcurrency option: ${String(options.maxConcurrency)} (expected a positive integer)`,
);
}
const resolved = {
agentDir: path.resolve(options.agentDir ?? resolveStepAgentDir()),
configDirName: options.configDirName?.trim() || resolveStepConfigDir() || CONFIG_DIR_NAME,
includeBuiltinAgents: options.includeBuiltinAgents !== false,
maxParallelTasks: Math.max(1, Math.min(MAX_PARALLEL_TASKS, options.maxParallelTasks ?? MAX_PARALLEL_TASKS)),
maxConcurrency: Math.max(
1,
Math.min(clampWorkflowConcurrency(options.maxConcurrency), options.maxParallelTasks ?? MAX_PARALLEL_TASKS),
),
maxConcurrency: resolveSubagentConcurrency(undefined, options.maxConcurrency),
telemetry: options.telemetry,
worktreeManager: options.worktreeManager ?? {
allocate: allocateStepWorktree,
},
runner: options.runner ?? runStepSubagentProcess,
maxConcurrencySettings: options.maxConcurrencySettings,
};
return (pi: ExtensionAPI): void => {
// The child process inherits this marker. It still gets Step's provider and
Expand Down
34 changes: 20 additions & 14 deletions packages/coding-agent/src/features/subagent/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
finalOutput,
isFailed,
makeToolResult,
resolveSubagentConcurrency,
resultText,
type StepChainItem,
type StepSubagentDetails,
Expand All @@ -41,11 +42,7 @@ import {
type StepAgentScope,
} from "../step-subagent-agents.ts";
import { acquireWorkflowAdmission } from "../workflow/admission.ts";
import {
clampWorkflowConcurrency,
configuredWorkflowConcurrencyLimit,
type WorkflowSemaphore,
} from "../workflow/budget.ts";
import type { WorkflowSemaphore } from "../workflow/budget.ts";
import { truncateText } from "./lane-events.ts";
import type { StepSubagentLaneRuntime } from "./lane-lifecycle.ts";
import { finalRoster, liveRoster } from "./projection.ts";
Expand Down Expand Up @@ -264,11 +261,12 @@ export type SubagentExecutionOptions = Required<
StepSubagentExtensionOptions,
"agentDir" | "configDirName" | "includeBuiltinAgents" | "maxParallelTasks" | "maxConcurrency"
>
> & {
worktreeManager: StepWorktreeManager;
runner: StepSubagentRunner;
telemetry?: StepTelemetryReporter;
};
> &
Pick<StepSubagentExtensionOptions, "maxConcurrencySettings"> & {
worktreeManager: StepWorktreeManager;
runner: StepSubagentRunner;
telemetry?: StepTelemetryReporter;
};

/** A validated, approved call: discovery ran, exactly one mode was selected, and project agents were confirmed. */
export interface PreparedSubagentRun {
Expand Down Expand Up @@ -471,8 +469,10 @@ export async function runPreparedSubagent(
return failed;
}
reportCreated(agent, index);
// A parallel batch shares the process-wide window with workflow nodes, so the
// two together never exceed the host ceiling. The task stays queued until admitted.
// A parallel batch shares the process-wide window with workflow nodes. The
// window's capacity is the smallest ceiling among the runs attached, so a batch
// and a workflow running together are bound by the more conservative of the two.
// The task stays queued until admitted.
let releaseSlot: (() => void) | undefined;
if (admission) {
try {
Expand Down Expand Up @@ -623,8 +623,14 @@ export async function runPreparedSubagent(
// at 0s, including ones still queued behind maxConcurrency.
emit(mode);
if (parallel) {
// The configured provider limit caps subagent batches the same way it caps workflows.
const ceiling = Math.min(options.maxConcurrency, clampWorkflowConcurrency(configuredWorkflowConcurrencyLimit()));
// Plain default plus an optional configured value; no CPU- or provider-derived
// ceiling. Both inputs are validated here so an invalid host option cannot reach
// the shared window as a non-positive capacity.
const configured = options.maxConcurrencySettings?.()?.maxConcurrency;
const ceiling = Math.min(
resolveSubagentConcurrency(configured, options.maxConcurrency),
options.maxParallelTasks,
);
const lease = acquireWorkflowAdmission(ceiling);
try {
await mapWithConcurrency(parallel, ceiling, (task, index) => runOne(task, index, lease.semaphore));
Expand Down
11 changes: 8 additions & 3 deletions packages/coding-agent/src/features/workflow/budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,15 @@ export class WorkflowSemaphore {
}

setCeiling(ceiling: number): void {
if (ceiling === this.ceiling) return;
// The pool's capacity is the smallest ceiling among attached members, so one
// caller can hand over a non-positive or non-finite value. That capacity admits
// nothing and would wedge the process-wide window instead of failing one run, so
// clamp it here as the last line of defense.
const next = Number.isFinite(ceiling) ? Math.max(1, Math.floor(ceiling)) : 1;
if (next === this.ceiling) return;
const wasAtCeiling = this.limit === this.ceiling;
this.ceiling = ceiling;
this.limit = wasAtCeiling ? ceiling : Math.min(this.limit, ceiling);
this.ceiling = next;
this.limit = wasAtCeiling ? next : Math.min(this.limit, next);
this.generation++;
this.successes = 0;
this.drain();
Expand Down
Loading
Loading