Skip to content
Closed

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,155 @@
/**
* 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
* -> toJsonEvent -> the same JSONL a host redirects from `--mode json`.
*
* 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>
*/
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 { 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 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, or it
// will try to resolve `tsx` from the child's own cwd.
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") }) },
],
});

// Mirror `--mode json`: every session event is one JSONL line on stdout.
const out = createWriteStream(logPath);
const stats = {
byType: new Map<string, number>(),
bytesByType: new Map<string, number>(),
updates: 0,
updatePayloadBytes: 0,
updateMessagesBytes: 0,
largestUpdate: 0,
snapshotHashes: new Map<string, number>(),
snapshotEmissions: 0,
finalResultBytes: 0,
finalResultMessages: 0,
};
let writeChain: Promise<void> = Promise.resolve();
let peakRss = 0;
const sampler = setInterval(() => {
peakRss = Math.max(peakRss, process.memoryUsage().rss);
}, 10);

harness.session.subscribe((event) => {
const line = JSON.stringify(toJsonEvent(event));
const bytes = Buffer.byteLength(line, "utf8");
stats.byType.set(event.type, (stats.byType.get(event.type) ?? 0) + 1);
stats.bytesByType.set(event.type, (stats.bytesByType.get(event.type) ?? 0) + bytes);
writeChain = writeChain.then(
() => new Promise<void>((resolve) => (out.write(`${line}\n`) ? resolve() : out.once("drain", () => resolve()))),
);
if (event.type === "tool_execution_end") {
stats.finalResultBytes = Buffer.byteLength(JSON.stringify(event.result), "utf8");
const records = event.result?.details?.results;
stats.finalResultMessages = Array.isArray(records)
? records.reduce<number>(
(sum, record) => sum + (Array.isArray(record?.messages) ? record.messages.length : 0),
0,
)
: 0;
return;
}
if (event.type !== "tool_execution_update") return;
stats.updates += 1;
stats.updatePayloadBytes += bytes;
stats.largestUpdate = Math.max(stats.largestUpdate, bytes);
const results = event.partialResult?.details?.results;
if (!Array.isArray(results)) return;
for (const record of results) {
const messages = record?.messages;
if (!Array.isArray(messages)) continue;
stats.snapshotEmissions += 1;
stats.updateMessagesBytes += Buffer.byteLength(JSON.stringify(messages), "utf8");
const hash = createHash("sha256").update(JSON.stringify(messages)).digest("hex");
stats.snapshotHashes.set(hash, (stats.snapshotHashes.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(2);
const distinct = stats.snapshotHashes.size;
const redundant = stats.snapshotEmissions - distinct;

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),
},
logMiB: mib(size),
toolExecutionUpdateEvents: stats.updates,
updatePayloadMiB: mib(stats.updatePayloadBytes),
payloadShareOfLogPct: +((stats.updatePayloadBytes / Math.max(1, size)) * 100).toFixed(1),
bytesCarriedByMessagesMiB: mib(stats.updateMessagesBytes),
largestUpdateMiB: mib(stats.largestUpdate),
finalResultMiB: mib(stats.finalResultBytes),
finalResultMessages: stats.finalResultMessages,
distinctMessageSnapshots: distinct,
snapshotEmissions: stats.snapshotEmissions,
redundantSnapshotEmissions: redundant,
peakRssMiB: mib(peakRss),
elapsedMs: Date.now() - startedAt,
eventsByType: Object.fromEntries([...stats.byType.entries()].sort((a, b) => b[1] - a[1])),
mibByType: Object.fromEntries(
[...stats.bytesByType.entries()]
.sort((a, b) => b[1] - a[1])
.map(([type, bytes]) => [type, mib(bytes)]),
),
},
null,
2,
),
);

harness.cleanup();
rmSync(workDir, { recursive: true, force: true });
18 changes: 17 additions & 1 deletion packages/coding-agent/docs/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,26 @@ type AgentEvent =
| { type: "message_end"; message: AgentMessage }
// Tool execution
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
// `args`/`details` may be omitted and the text reduced to a tail when one
// update would exceed the wire budget; see "Live tool updates" below.
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args?: any; partialResult: any }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };
```

## Live tool updates

`tool_execution_update` is progress, and a `json` consumer typically redirects it
to a log, so one update is capped at 256 KiB of serialized bytes. An oversized
update is replaced by the tail of its text plus a `[live output truncated]`
marker, and its `args`/`details` are omitted — both are already on the matching
`tool_execution_start` and `tool_execution_end`. The cap applies only to this
stdout projection: extension handlers see the original event, and terminal
results are never bounded here. Treat an update as a display snapshot that may
be replaced wholesale, not as a complete event history.

The producer rate is also bounded: the subagent tool publishes at most one
update per 100 ms, and its live roster carries no child transcript.

## Message Types

Base messages from [`packages/providers/src/types.ts`](https://github.com/stepfun-ai/step-harness/blob/main/packages/providers/src/types.ts#L134):
Expand Down
9 changes: 8 additions & 1 deletion packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,14 @@ When complete:
}
```

Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_update` contains the accumulated output so far (not just the delta), allowing clients to simply replace their display on each update.
Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_update` normally contains the accumulated output so far (not just the delta), allowing clients to simply replace their display on each update — except when the update is oversized, where only the tail is kept (next paragraph).

One update is capped at 256 KiB of serialized bytes. An oversized update keeps
only the tail of its text, adds a `[live output truncated]` marker, and omits
`args`/`details`; read those from `tool_execution_start`/`tool_execution_end`
instead, which are never bounded. The cap applies to the stdout projection only,
not to in-process extension handlers. The subagent tool additionally coalesces
updates to at most one per 100 ms and omits a child's transcript from them.

### queue_update

Expand Down
42 changes: 42 additions & 0 deletions packages/coding-agent/docs/tool-output-retention.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,45 @@ termination policy remains in force. Small results create no artifact and are un

This bounds final model-facing text, not streaming progress updates or arbitrary
typed `details` payloads. No new permission bypass or external upload is added.

## Live tool updates

A tool's `onUpdate` callbacks are progress, not results. They run while the call
is in flight and each one becomes a `tool_execution_update` session event, which
the `json` and `rpc` modes write as one JSONL line — so a host that redirects
that stream to a file turns progress into log volume. The bound is
`O(elapsed time x bounded payload)`, not a constant log size. Both factors are
bounded:

- A producer sends at most one update per 100 ms (the same cadence the bash tool
uses). Status transitions are coalesced into the same window too: publishing
each one immediately makes a parallel batch quadratic (N transitions times a
roster of N), which is the amplification this bound exists to prevent. The
terminal state is always flushed before the call returns.
- A live update is an explicit projection, never the accumulated call state. The
subagent orchestrator drops `messages`, keeps a short tail of the streamed
text / tool output / stderr, clips every identifier and a task prompt at both
ends, and stops adding records once the roster reaches 48 KiB — reporting the
rest as `omittedResults`. A chain splices the previous step's whole answer
into the next step's task, so a spread would repeat it on every tick.
- The final records are also an explicit projection sharing one 256 KiB budget
for heavy fields: the newest messages that fit (64 KiB per record), an
`activeText` capped at 50,000 characters (the renderer's own truncation
point), and a bounded `task`/`stderr`/`errorMessage`. A field that does not
fit is omitted, never kept at full size. Small per-record metadata (agent,
status, usage, ids) is bounded separately, so the total is
`256 KiB + O(tasks)` and never grows with the child's output.
`messagesTruncated` says when a transcript tail was cut, and the full
transcript stays in the child session (`sessionId` / `sessionFile`). The
model-facing `content` is unaffected and still passes through this document's
50 KiB retention.

`toJsonEvent` additionally bounds a single `tool_execution_update` at 256 KiB of
*serialized* bytes. That backstop exists for third-party tools that re-send
accumulated state; it never applies to `tool_execution_end`, which carries the
real result. The budget is measured after JSON escaping on purpose: a control
character or a lone surrogate becomes a six-byte escape, so counting raw
characters under-counts the bytes the host writes.

These bounds limit payload size. They do not add backpressure: a slow stdout
reader can still queue updates, just far smaller ones.
14 changes: 14 additions & 0 deletions packages/coding-agent/src/features/step-subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ export interface StepSubagentResultRecord extends StepSubagentRunResult {
step?: number;
worktreePath?: string;
worktreeBranch?: string;
/**
* True when `messages` holds only the newest part of the child transcript.
* The record is a display projection, not the child's authoritative history:
* the full transcript stays in the child session (`sessionId`/`sessionFile`).
*/
messagesTruncated?: boolean;
}

export interface StepSubagentDetails {
Expand All @@ -225,6 +231,12 @@ export interface StepSubagentDetails {
status?: "running" | "completed" | "failed" | "aborted";
startedAt?: number;
updatedAt?: number;
/**
* Records left out of this live roster to keep one update bounded. Only set
* while running with a large parallel batch; the final result reports every
* task. Renderers may show it as "+N more".
*/
omittedResults?: number;
}

export interface StepSubagentExtensionOptions {
Expand Down Expand Up @@ -752,10 +764,12 @@ export function createStepSubagentExtension(options: StepSubagentExtensionOption
lane.ctx.ui.setWidget(lanesWidgetKey, undefined);
return;
}
const omitted = batch.reduce((sum, entry) => sum + (entry.details.omittedResults ?? 0), 0);
const details: StepSubagentDetails = {
...lane.details,
mode: "parallel",
results: batch.flatMap((entry) => entry.details.results),
...(omitted > 0 ? { omittedResults: omitted } : {}),
};
lanesWidget?.setDetails(details);
const signature = subagentListSignature(details);
Expand Down
Loading
Loading