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 apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ function readCredentialUid(credential: unknown): string | undefined {
async function dispatchStepAppMode(prep: Extract<MainPreparation, { kind: "dispatch" }>): Promise<void> {
switch (prep.appMode) {
case "rpc": {
await runRpcMode(prep.runtimeHost);
await runRpcMode(prep.runtimeHost, { includeLiveEvents: prep.parsed.includeLiveEvents });
break;
}
case "interactive": {
Expand Down Expand Up @@ -917,6 +917,7 @@ async function dispatchStepAppMode(prep: Extract<MainPreparation, { kind: "dispa
completionCheck: prep.parsed.completionCheck,
completionCheckAttempts: prep.parsed.completionCheckAttempts,
completionReview: prep.parsed.completionReview,
includeLiveEvents: prep.parsed.includeLiveEvents,
messages: prep.parsed.messages,
initialMessage: prep.initialMessage,
initialImages: prep.initialImages,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Fake `step --mode rpc` child used by the local amplification repro.
// It speaks the real rpc framing (one JSON object per LF-framed line) and
// replays what a child with a long streaming turn looks like: many text deltas,
// then a persisted assistant message, then a tool result.
const TURNS = Number(process.env.REPRO_TURNS ?? 20); // assistant turns in the child
const DELTAS = Number(process.env.REPRO_DELTAS ?? 60); // text deltas per turn
const TOOL_BYTES = Number(process.env.REPRO_TOOL_BYTES ?? 20_000); // child tool result size

let buffer = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) handle(line.trim());
});

function write(value) {
process.stdout.write(`${JSON.stringify(value)}\n`);
}

function assistant(content, stopReason = "stop") {
return {
role: "assistant",
content,
api: "anthropic-messages",
provider: "step",
model: "step-3.8-code-alpha",
usage: { input: 1000, output: 200, cacheRead: 0, cacheWrite: 0, totalTokens: 1200, cost: {} },
stopReason,
timestamp: Date.now(),
};
}

async function handle(line) {
if (!line) return;
let command;
try {
command = JSON.parse(line);
} catch {
return;
}
if (command.type === "get_state") {
write({ type: "response", id: command.id, command: "get_state", success: true, data: { sessionFile: "/tmp/child.jsonl" } });
return;
}
if (command.type === "abort") {
write({ type: "agent_settled" });
return;
}
if (command.type !== "prompt") return;

write({ type: "response", id: command.id, command: "prompt", success: true });
write({ type: "agent_start" });

const messages = [];
for (let turn = 0; turn < TURNS; turn++) {
write({ type: "turn_start" });
write({ type: "message_start", message: assistant([]) });

let text = "";
for (let index = 0; index < DELTAS; index++) {
const delta = `token-${turn}-${index} `;
text += delta;
write({
type: "message_update",
assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta },
});
}

// The persisted assistant message, exactly as the real child sends it.
const message = assistant([{ type: "text", text }], "toolUse");
messages.push(message);
write({ type: "message_end", message });

// A tool call whose result the child accumulates into its transcript.
const toolCallId = `call_${turn}`;
write({
type: "tool_execution_start",
toolCallId,
toolName: "run_command",
args: { command: `echo ${turn}` },
});
const output = "x".repeat(TOOL_BYTES);
write({ type: "tool_execution_update", toolCallId, toolName: "run_command", partialResult: output });
const result = {
role: "toolResult",
toolCallId,
toolName: "run_command",
content: [{ type: "text", text: output }],
isError: false,
timestamp: Date.now(),
};
messages.push(result);
write({ type: "tool_execution_end", toolCallId, toolName: "run_command", result: { content: [{ type: "text", text: output }] }, isError: false });
write({ type: "message_end", message: result });
write({ type: "turn_end" });
}

write({ type: "message_end", message: assistant([{ type: "text", text: "child done" }]) });
write({ type: "agent_end" });
write({ type: "agent_settled" });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Local, offline reproduction of the subagent event-log amplification.
*
* Everything below is production code except the two test doubles the repo's
* testing policy already sanctions:
* - the parent model (faux provider, no network),
* - the child (a fake `step --mode rpc` process that only streams tokens).
*
* The measured path is real end to end:
* child stdout -> routeSubagentRpcLine -> parseJsonEvent -> onUpdate
* -> execute.emit -> agent-loop tool_execution_update -> AgentSession event
* -> sessionEventDurability -> toJsonEvent -> the JSONL a host redirects
* from `--mode json`.
*
* The point of the run is the `--mode json` default: a durable-only stream is
* what a harness writes to disk, so the measurement applies the same filter the
* transport does.
*
* Run from packages/coding-agent:
* node --import tsx ../../docs/research/2026-10-27-subagent-live-update-amplification/repro.mts
* REPRO_TURNS=40 REPRO_DELTAS=150 REPRO_TOOL_BYTES=40000 node --import tsx <same>
* REPRO_LIVE=1 node --import tsx <same> # simulate --include-live-events
*/
import { createHash } from "node:crypto";
import { createWriteStream, mkdtempSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers";
import { isEphemeralSessionEvent } from "../../../packages/coding-agent/src/core/session-event-durability.ts";
import { createStepSubagentExtension } from "../../../packages/coding-agent/src/features/step-subagent.ts";
import { toJsonEvent } from "../../../packages/coding-agent/src/modes/json-event.ts";
import { createHarness } from "../../../packages/coding-agent/test/suite/harness.ts";

const childPath = fileURLToPath(new URL("./child.mjs", import.meta.url));
const includeLive = process.env.REPRO_LIVE === "1";
const workDir = mkdtempSync(join(tmpdir(), "step-amp-"));
const logPath = join(workDir, "stepcode.jsonl");

// The default runner spawns `process.argv[1]`. Point it at the fake rpc child so
// this repro works on any branch, without the host-only `cliPath` option.
process.argv[1] = childPath;
// The child is plain ESM: do not forward the parent's tsx loader into it.
process.execArgv = process.execArgv.filter((arg) => !/^(?:--import|--loader|--require|-r)$/u.test(arg));

const harness = await createHarness({
extensionFactories: [
{ name: "step-subagent", factory: createStepSubagentExtension({ agentDir: join(workDir, "agent") }) },
],
});

const out = createWriteStream(logPath);
const stats = {
byType: new Map<string, number>(),
durableBytes: 0,
liveBytes: 0,
liveFrames: 0,
largestLiveFrame: 0,
largestDurable: 0,
messageHashes: new Map<string, number>(),
};
let writeChain: Promise<void> = Promise.resolve();
let peakRss = 0;
const sampler = setInterval(() => {
peakRss = Math.max(peakRss, process.memoryUsage().rss);
}, 10);

harness.session.subscribe((event) => {
// The projection decides what the record contains; the flag is the live
// consumer's opt-in. A frame is measured on its own line either way.
if (isEphemeralSessionEvent(event)) {
const frameLine = JSON.stringify(toJsonEvent(event, { includeLiveEvents: true }));
const frameBytes = Buffer.byteLength(frameLine, "utf8");
stats.liveFrames += 1;
stats.liveBytes += frameBytes;
stats.largestLiveFrame = Math.max(stats.largestLiveFrame, frameBytes);
}
const projected = toJsonEvent(event, { includeLiveEvents: includeLive });
if (!projected) return;
const line = JSON.stringify(projected);
const bytes = Buffer.byteLength(line, "utf8");
if (!isEphemeralSessionEvent(event)) {
stats.durableBytes += bytes;
stats.largestDurable = Math.max(stats.largestDurable, bytes);
}
stats.byType.set(event.type, (stats.byType.get(event.type) ?? 0) + 1);
writeChain = writeChain.then(
() => new Promise<void>((resolve) => (out.write(`${line}\n`) ? resolve() : out.once("drain", () => resolve()))),
);
if (event.type !== "tool_execution_update") return;
const results = event.partialResult?.details?.results;
if (!Array.isArray(results)) return;
for (const record of results) {
const messages = record?.messages;
if (!Array.isArray(messages) || messages.length === 0) continue;
const hash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
stats.messageHashes.set(hash, (stats.messageHashes.get(hash) ?? 0) + 1);
}
});

harness.setResponses([
fauxAssistantMessage(fauxToolCall("subagent", { agent: "general", task: "inspect the repository" }), {
stopReason: "toolUse",
}),
fauxAssistantMessage("parent done"),
]);

const startedAt = Date.now();
await harness.session.prompt("delegate the inspection");
await writeChain;
await new Promise<void>((resolve) => out.end(resolve));
clearInterval(sampler);
peakRss = Math.max(peakRss, process.memoryUsage().rss);

const size = statSync(logPath).size;
const mib = (n: number): number => +(n / 1024 / 1024).toFixed(3);
const pct = (part: number, whole: number): number => +((part / Math.max(1, whole)) * 100).toFixed(1);

console.log(
JSON.stringify(
{
config: {
childTurns: Number(process.env.REPRO_TURNS ?? 20),
deltasPerTurn: Number(process.env.REPRO_DELTAS ?? 60),
childToolBytes: Number(process.env.REPRO_TOOL_BYTES ?? 20_000),
includeLive,
},
logMiB: mib(size),
durableMiB: mib(stats.durableBytes),
liveFrames: stats.liveFrames,
liveFramesWritten: includeLive,
liveMiB: mib(stats.liveBytes),
liveShareOfTotalPct: pct(stats.liveBytes, stats.durableBytes + stats.liveBytes),
largestDurableMiB: mib(stats.largestDurable),
largestLiveFrameMiB: mib(stats.largestLiveFrame),
liveFramesCarryingTranscript: [...stats.messageHashes.values()].reduce((a, b) => a + b, 0),
peakRssMiB: mib(peakRss),
elapsedMs: Date.now() - startedAt,
eventsByType: Object.fromEntries([...stats.byType.entries()].sort((a, b) => b[1] - a[1])),
},
null,
2,
),
);

harness.cleanup();
rmSync(workDir, { recursive: true, force: true });
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../lark-adapter && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build",
"build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../providers && npm run build:offline && cd ../agent-core && npm run build && cd ../config && npm run build && cd ../lark-adapter && npm run build && cd ../coding-agent && npm run build && cd ../../apps/cli && npm run build",
"build:binaries": "bash scripts/build-binaries.sh --product step --offline-model-data",
"check": "biome check --write --error-on-warnings . && pnpm run check:pinned-deps && pnpm run check:ts-imports && pnpm run check:layer-direction && pnpm run check:ui-layer && pnpm run check:workspace-registry && pnpm run check:tui-no-ai && pnpm run check:coding-agent-entry-freeze && pnpm run check:contracts-deps-empty && pnpm run check:derived-compat-only && pnpm run check:no-provider-dispatch && pnpm run check:metadata-not-in-dispatch && pnpm run check:no-secret-leak && pnpm run check:legacy-scope-prefix && pnpm run check:no-observability && pnpm run check:public-boundary && tsgo --noEmit && pnpm run check:browser-smoke",
"check": "biome check --write --error-on-warnings . && pnpm run check:pinned-deps && pnpm run check:ts-imports && pnpm run check:layer-direction && pnpm run check:ui-layer && pnpm run check:workspace-registry && pnpm run check:tui-no-ai && pnpm run check:coding-agent-entry-freeze && pnpm run check:contracts-deps-empty && pnpm run check:derived-compat-only && pnpm run check:no-provider-dispatch && pnpm run check:session-event-transports && pnpm run check:metadata-not-in-dispatch && pnpm run check:no-secret-leak && pnpm run check:legacy-scope-prefix && pnpm run check:no-observability && pnpm run check:public-boundary && tsgo --noEmit && pnpm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",
"check:ts-imports": "node scripts/check-ts-relative-imports.mjs",
Expand All @@ -28,6 +28,7 @@
"check:contracts-deps-empty": "node scripts/check-contracts-deps-empty.mjs",
"check:derived-compat-only": "node scripts/check-derived-compat-only.mjs",
"check:no-provider-dispatch": "node scripts/check-no-provider-dispatch.mjs",
"check:session-event-transports": "node scripts/check-session-event-transports.mjs",
"check:metadata-not-in-dispatch": "node scripts/check-metadata-not-in-dispatch.mjs",
"check:no-secret-leak": "node scripts/check-no-secret-leak.mjs",
"check:legacy-scope-prefix": "node scripts/check-legacy-scope-prefix.mjs",
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Inherits the [root rules](../../AGENTS.md). This package is `@step-harness/agent
- Do not introduce vendor SDKs, Step defaults, CLI argument parsing, or UI objects. Products compose capabilities through stream, tool, environment, and resource interfaces.
- All product modes share this loop. New entry points must not copy the tool-call loop or message state machine.
- Preserve tool-call/result pairing, event ordering, cancellation propagation, steer / follow-up timing, and the distinction between errors and normal completion.
- Tool updates are replacement snapshots and may coalesce. Observe background delivery failures and report them through the awaited tool-result path, without leaving a run unsettled.
- Generic session context and persistence contracts belong in the harness. Product session adapters may compose them; client caches must not become authoritative state.
- Pass telemetry contexts explicitly. Disabling telemetry must not change business return values, errors, or execution order.

Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ The last message in context must be `user` or `toolResult` (not `assistant`).
| `tool_execution_update` | Tool streams progress |
| `tool_execution_end` | Tool completes |

Tool updates are replacement snapshots: intermediate frames may be coalesced
while delivery is in flight. A tool must put anything it needs preserved in its
returned result. Delivery failures are observed by the awaited tool-result path;
they do not leave background promise rejections or prevent the run from settling.

`Agent.subscribe()` listeners are awaited in registration order. `agent_end` means no more loop events will be emitted, but `await agent.waitForIdle()` and `await agent.prompt(...)` only settle after awaited `agent_end` listeners finish.

## Agent Options
Expand Down
Loading
Loading