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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changed

- The Step system prompt no longer carries the git branch or the uncommitted-file count in its `<env>` block. Any later prompt rebuild (an MCP catalog refresh, a tool registration) re-read git and rewrote the system prompt after a checkout, and because the provider caches the system prompt right after the tool block, one rebuild re-billed the whole conversation as cache writes; between rebuilds the model saw a stale branch. The branch now reaches the model as a hidden runtime-context snapshot appended beside the user's prompt only when it changed, so the system prompt and earlier history stay byte-identical. Within a run, a step that ran tools, or a run started without a user prompt (goal continuation, cron, subagent wake-ups, an approved plan), re-reads the branch and shows a change at the request tail. The uncommitted-file count is dropped rather than moved: it changes with almost every edit the agent itself makes, and the prompt already tells the model to inspect git state before changing files. SDK embedders that use `buildStepSystemPromptAppendix` without `createStepCapabilitiesExtensionInline` can add `createStepRuntimeContextExtension` to keep the branch visible.
- 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).
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/src/features/step-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
/** Step's built-in clarification, plan, task-tracking, and delegated-agent extensions. */
/** Step's built-in clarification, plan, task-tracking, runtime-context, and delegated-agent extensions. */

import type { ExtensionAPI, ExtensionFactory, InlineExtension } from "../core/extensions/types.ts";
import type { StepTelemetryReporter } from "../step/telemetry.ts";
import { createStepPlanExtension } from "./step-plan.ts";
import { createStepPsExtension } from "./step-ps.ts";
import { registerStepClarifyUserExtension } from "./step-questionnaire.ts";
import { createStepRuntimeContextExtension } from "./step-runtime-context.ts";
import { createStepSubagentExtension, type StepSubagentExtensionOptions } from "./step-subagent.ts";
import { createStepSubagentsCommandExtension } from "./step-subagents-command.ts";
import { createStepTasksExtension } from "./step-tasks.ts";
Expand All @@ -26,6 +27,7 @@ export function createStepCapabilitiesExtension(options: StepCapabilitiesExtensi
const ps = createStepPsExtension();
const subagentsCommand = createStepSubagentsCommandExtension();
const tasks = createStepTasksExtension();
const runtimeContext = createStepRuntimeContextExtension();
const subagent = createStepSubagentExtension({
...options.subagent,
telemetry: options.telemetry,
Expand All @@ -48,6 +50,7 @@ export function createStepCapabilitiesExtension(options: StepCapabilitiesExtensi
ps(pi);
subagentsCommand(pi);
tasks(pi);
runtimeContext(pi);
subagent(pi);
workflow(pi);
ultraloopOptIn(pi);
Expand Down
181 changes: 181 additions & 0 deletions packages/coding-agent/src/features/step-runtime-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Runtime context that changes during a session, kept out of the system prompt.
*
* The provider caches the request prefix: tools, then the system prompt, then
* history. The git branch used to live in the system prompt's <env> block, so
* any prompt rebuild after a checkout rewrote the system prompt and re-billed
* the whole conversation as cache writes. Between rebuilds the model saw a
* stale branch.
*
* Instead, the branch rides a runtime-context snapshot appended to history: a
* hidden custom message added beside the user's prompt only when the branch
* differs from the last snapshot in the active context. Each snapshot states it
* supersedes earlier ones, so history stays append-only and the cached prefix
* survives. Within a run, a step that ran tools or a run started without a user
* prompt re-reads the branch and shows a changed one at the request tail.
* Working-tree changes are not announced: they are mostly the agent's own
* edits, and the system prompt already tells it to inspect git state.
*/

import { execFile } from "node:child_process";
import type { AgentMessage } from "@step-harness/agent-core";
import type { ExtensionAPI, ExtensionFactory } from "../core/extensions/types.ts";
import { encodeEnvironmentValue } from "../step/system-prompt.ts";

export const STEP_RUNTIME_CONTEXT_TYPE = "step-runtime-context";

/**
* The checked-out branch, `DETACHED_HEAD` for a detached HEAD, or null outside
* a git repository. Git refuses a branch named "HEAD", so the sentinel cannot
* collide with a real branch.
*/
export type GitBranch = string | null;
export const DETACHED_HEAD = "HEAD";

interface RuntimeContextDetails {
version: 1;
branch: GitBranch;
}

export interface StepRuntimeContextExtensionOptions {
/** Reads the branch for a working directory; undefined when git cannot tell. Injectable for tests. */
readBranch?: (cwd: string) => Promise<GitBranch | undefined>;
}

const GIT_TIMEOUT_MS = 1500;

/**
* One `git symbolic-ref` call distinguishes all three states: exit 0 prints the
* branch (also before the first commit), exit 1 means a detached HEAD, and exit
* 128 means no repository. Any other failure (no git binary, timeout) is
* undefined so the caller announces nothing rather than a wrong fact.
*/
export function readGitBranch(cwd: string): Promise<GitBranch | undefined> {
return new Promise((resolve) => {
try {
execFile(
"git",
["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"],
{ cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, windowsHide: true },
(error, stdout) => {
if (!error) return resolve(stdout.trim() || undefined);
if (error.code === 1) return resolve(DETACHED_HEAD);
if (error.code === 128) return resolve(null);
resolve(undefined);
},
);
} catch {
resolve(undefined);
}
});
}

function describeBranch(branch: GitBranch): string {
if (branch === null) return "Git: the working directory is not inside a git repository.";
if (branch === DETACHED_HEAD) return "Git branch: none (detached HEAD)";
return `Git branch: ${encodeEnvironmentValue(branch)}`;
}

export function buildRuntimeContextText(branch: GitBranch): string {
return [
"<system-reminder>",
"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.",
describeBranch(branch),
"</system-reminder>",
].join("\n");
}

interface SnapshotRecord {
customType?: string;
details?: unknown;
}

/** The branch of the newest snapshot in the given context, or undefined when none is visible. */
function lastSnapshot(records: readonly SnapshotRecord[]): GitBranch | undefined {
for (let index = records.length - 1; index >= 0; index--) {
const record = records[index]!;
if (record.customType !== STEP_RUNTIME_CONTEXT_TYPE) continue;
const details = record.details as Partial<RuntimeContextDetails> | undefined;
if (details?.version === 1 && details.branch !== undefined) return details.branch;
}
return undefined;
}

function snapshotMessage(branch: GitBranch) {
return {
customType: STEP_RUNTIME_CONTEXT_TYPE,
content: buildRuntimeContextText(branch),
display: false,
details: { version: 1, branch } satisfies RuntimeContextDetails,
};
}

export function createStepRuntimeContextExtension(options: StepRuntimeContextExtensionOptions = {}): ExtensionFactory {
const readBranch = options.readBranch ?? readGitBranch;
return (pi: ExtensionAPI): void => {
// The branch the model should currently see; undefined while there is nothing to state.
let current: GitBranch | undefined;
// The branch may have moved since the last read: a step ran tools (the agent
// may have checked out), or a run started without a user prompt.
let stale = false;
// before_agent_start already read git for the run that is about to start.
let readForPrompt = false;

pi.on("session_start", () => {
current = undefined;
stale = false;
readForPrompt = false;
});

// Read once per user prompt and persist a snapshot beside it only when the
// branch changed since the last one the model can see.
pi.on("before_agent_start", async (_event, ctx) => {
const branch = await readBranch(ctx.cwd);
readForPrompt = true;
stale = false;
if (branch === undefined) return;
const previous = lastSnapshot(
ctx.sessionManager.buildContextEntries().filter((entry) => entry.type === "custom_message"),
);
// Outside a repository with nothing announced yet, there is nothing to correct.
current = previous === undefined && branch === null ? undefined : branch;
if (current === undefined || previous === current) return;
return { message: snapshotMessage(current) };
});

// Goal continuation, cron, background subagent wake-ups, approved plans and
// retries start runs without before_agent_start, so they never read git above.
pi.on("agent_start", () => {
if (!readForPrompt) stale = true;
readForPrompt = false;
});

pi.on("turn_end", (event) => {
if (event.toolResults.length > 0) stale = true;
});

// Each request re-reads git only when it may have moved, then restores the
// current snapshot at the tail when the request does not already show it:
// after a mid-run checkout, after compaction summarized the persisted one
// away, or during runs that persist nothing. The addition is request-local,
// so the cached prefix is untouched; the next user prompt persists a fresh one.
pi.on("context", async (event, ctx) => {
if (stale) {
stale = false;
const branch = await readBranch(ctx.cwd);
if (branch !== undefined && !(current === undefined && branch === null)) current = branch;
}
if (current === undefined) return;
const visible = lastSnapshot(
event.messages.filter((message): message is AgentMessage & SnapshotRecord => message.role === "custom"),
);
if (visible === current) return;
return {
messages: [
...event.messages,
{ role: "custom" as const, ...snapshotMessage(current), timestamp: Date.now() },
],
};
});
};
}
4 changes: 4 additions & 0 deletions packages/coding-agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,10 @@ export {
stepCronExtensionInline,
} from "./features/step-cron.ts";
export { createStepProviderConfig, STEP_PROVIDER_ID } from "./features/step-provider/index.ts";
export {
createStepRuntimeContextExtension,
type StepRuntimeContextExtensionOptions,
} from "./features/step-runtime-context.ts";
export {
CreateGoalParams,
continuationPrompt,
Expand Down
79 changes: 3 additions & 76 deletions packages/coding-agent/src/step/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
* structured calls.
*/

import { spawnSync } from "node:child_process";

export interface StepSystemPromptContext {
/** Initial working directory rendered by the Pi prompt builder. */
cwd?: string;
Expand Down Expand Up @@ -63,7 +61,8 @@ function hasAnyTool(active: ReadonlySet<string>, names: readonly string[]): bool
return names.some((name) => active.has(name));
}

function encodeEnvironmentValue(value: string): string {
/** Escape a runtime fact so it stays one inert line inside a prompt or reminder block. */
export function encodeEnvironmentValue(value: string): string {
return Array.from(value, (character) => {
switch (character) {
case "&":
Expand Down Expand Up @@ -98,86 +97,14 @@ function buildEnvironmentSection(context: StepSystemPromptContext, operatingMode
`Working directory: ${encodeEnvironmentValue(context.cwd?.trim() || "(see Current working directory below)")}`,
`Platform: ${encodeEnvironmentValue(context.platform?.trim() || "unknown")}`,
`Today's date: ${encodeEnvironmentValue(context.date?.trim() || "unknown")}`,
];
const gitEnvironment = collectGitEnvironment(context.cwd);
if (gitEnvironment.branch !== undefined) {
lines.push(`Git branch: ${encodeEnvironmentValue(gitEnvironment.branch)}`);
}
if (gitEnvironment.uncommittedCount !== undefined) {
lines.push(`Uncommitted changes: ${gitEnvironment.uncommittedCount}`);
}
lines.push(
`Operating mode: ${operatingMode}`,
"</env>",
"The initial working directory is the base for relative paths. The working directory above is also the default base for relative paths; absolute paths, parent-directory paths, and ~/ home paths are valid where the selected tool and permissions allow.",
"Git state is not assumed to be clean; inspect it before changing repository files and preserve unrelated user changes.",
);
];
return lines.join("\n");
}

interface GitEnvironment {
branch?: string;
uncommittedCount?: number;
}

/**
* Git state changes far more slowly than the prompt is rebuilt. A single MCP
* server registering N tools rebuilds the prompt N times within a few hundred
* milliseconds, and each rebuild used to pay two synchronous git spawns
* (~29 ms). Cache per working directory so a registration burst pays once.
*/
const GIT_ENVIRONMENT_TTL_MS = 5_000;
const gitEnvironmentCache = new Map<string, { readonly value: GitEnvironment; readonly expiresAt: number }>();

/** Drop cached git facts so the next prompt build re-reads the repository. */
export function invalidateGitEnvironmentCache(cwd?: string): void {
if (cwd === undefined) gitEnvironmentCache.clear();
else gitEnvironmentCache.delete(cwd.trim());
}

/**
* Best-effort git facts for the environment block. Every failure path —
* missing git binary, not a repository, nonexistent cwd, timeout — returns
* an empty object so prompt construction can never crash on git state.
*/
function collectGitEnvironment(cwd: string | undefined): GitEnvironment {
const workingDirectory = cwd?.trim();
if (!workingDirectory) return {};
const now = Date.now();
const cached = gitEnvironmentCache.get(workingDirectory);
if (cached && cached.expiresAt > now) return cached.value;
const value = readGitEnvironment(workingDirectory);
gitEnvironmentCache.set(workingDirectory, { value, expiresAt: now + GIT_ENVIRONMENT_TTL_MS });
return value;
}

function readGitEnvironment(workingDirectory: string): GitEnvironment {
const runGitCommand = (gitArguments: string[]): string | undefined => {
try {
const spawnResult = spawnSync("git", gitArguments, {
cwd: workingDirectory,
encoding: "utf8",
timeout: 1500,
windowsHide: true,
});
if (spawnResult.error || spawnResult.status !== 0 || typeof spawnResult.stdout !== "string") return undefined;
return spawnResult.stdout;
} catch {
return undefined;
}
};
// symbolic-ref resolves the branch even before the first commit; fall back
// to rev-parse for detached HEAD (which reports the literal "HEAD").
const branch = (
runGitCommand(["symbolic-ref", "--short", "HEAD"]) ?? runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"])
)?.trim();
if (!branch) return {};
const statusOutput = runGitCommand(["status", "--porcelain"]);
if (statusOutput === undefined) return { branch };
const uncommittedCount = statusOutput.split("\n").filter((line) => line.trim().length > 0).length;
return { branch, uncommittedCount };
}

/** Build the Step product fragment for the tools active in this session. */
export function buildStepSystemPromptAppendix(
activeToolNames: readonly string[],
Expand Down
Loading
Loading