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
10 changes: 10 additions & 0 deletions packages/coding-agent/docs/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ The native pipeline has two summarization mechanisms:

Both use the same structured summary format and track file operations cumulatively. Compaction and branch-summary requests use fresh routing session IDs and, where supported by the provider, disable prompt-cache writes because these one-off prompts are unlikely to be reused.

## Failure diagnostics

Native compaction failures include a safe configuration snapshot in the error shown by TUI, RPC, and SDK. The snapshot reports the runtime model/provider/API, context window, model output limit, selected summary output budget, observed payload output budget when available, approximate input size, reasoning option, request attempts and elapsed time, stop reason and error kind. It also includes compaction reserve/retention and retry settings.

`selectedMaxTokens` is the caller's budget; `observedPayloadMaxTokens` is the numeric output cap observed through the provider payload callback after context/adapter adjustments. Custom streams that do not invoke this callback report `unknown`. Input estimates use serialized characters/4, not the provider tokenizer. Configuration provenance is currently `unknown`; values describe effective runtime settings, not a guessed disk configuration.

Non-cancelled failures append a versioned `step-compaction-failure` custom entry to the session JSONL (when persistence is enabled). This entry is not added to model context and contains no failed summary, prompt, headers, URL, or environment dump. Credential-shaped error text is redacted. Failure to persist diagnostics does not replace the original error. Cancelled requests do not create failure entries.

A `length` response remains an incomplete summary and is never saved as a successful checkpoint. Check model output limits and input headroom rather than assuming increasing `reserveTokens` increases the output cap. Transient transport failures use the existing bounded retry policy; deterministic malformed responses and cancellations do not become network retries.

## Compaction

### When It Triggers
Expand Down
40 changes: 36 additions & 4 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
streamSimple,
} from "@step-harness/providers/compat";
import { getAgentDir } from "../config.ts";
import { redactSecretString } from "../step/secret-redaction.ts";
import { getThemeByName, theme } from "../theme/theme.ts";
import { stripFrontmatter } from "../utils/frontmatter.ts";
import { sleep } from "../utils/sleep.ts";
Expand Down Expand Up @@ -79,6 +80,7 @@ import {
type LiveContextStatus,
} from "./compaction/live-context/manager.ts";
import { StableContextNotices } from "./compaction/live-context/stable-notices.ts";
import { formatRetryPolicy, formatSummaryDiagnostics, SummaryFailure } from "./compaction/summary-diagnostics.ts";
import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
Expand Down Expand Up @@ -1080,6 +1082,33 @@ export class AgentSession {
});
}

private _recordCompactionFailure(error: unknown, reason: "manual" | "threshold" | "overflow"): string {
const settings = this.settingsManager.getCompactionSettings();
const retry = this.settingsManager.getRetrySettings();
const message = redactSecretString(error instanceof Error ? error.message : String(error));
const diagnostics = error instanceof SummaryFailure ? error.diagnostics : undefined;
const model = this.model;
const configuration = `Compaction settings: reserveTokens=${settings.reserveTokens}, keepRecentTokens=${settings.keepRecentTokens}; ${formatRetryPolicy(retry)}; configurationSource=unknown.`;
const request = diagnostics
? formatSummaryDiagnostics(diagnostics)
: `Selected model (request not observed): provider=${model?.provider ?? "unknown"}, model=${model?.id ?? "unknown"}, contextWindow=${model?.contextWindow ?? "unknown"}, model.maxTokens=${model?.maxTokens ?? "unknown"}.`;
let result = redactSecretString(`${message}\n${request}\n${configuration}`);
try {
this.sessionManager.appendCustomEntry("step-compaction-failure", {
version: 1,
reason,
errorMessage: message,
diagnostics,
settings: { reserveTokens: settings.reserveTokens, keepRecentTokens: settings.keepRecentTokens },
retry: { enabled: retry.enabled, maxRetries: retry.maxRetries, baseDelayMs: retry.baseDelayMs },
configurationSource: "unknown",
});
} catch {
result += "\nCould not persist compaction diagnostics; original failure retained.";
}
return result;
}

private async _emitSessionCompactFailed(event: Omit<SessionCompactFailedEvent, "type">): Promise<void> {
if (this._extensionRunner.hasHandlers("session_compact_failed")) {
await this._extensionRunner.emit({ type: "session_compact_failed", ...event });
Expand Down Expand Up @@ -2709,7 +2738,9 @@ export class AgentSession {
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const aborted = message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError");
const errorMessage = aborted ? undefined : `Compaction failed: ${message}`;
const errorMessage = aborted
? undefined
: `Compaction failed: ${this._recordCompactionFailure(error, "manual")}`;
this._compactionAbortController = undefined;
this._emit({
type: "compaction_end",
Expand All @@ -2726,6 +2757,7 @@ export class AgentSession {
willRetry: false,
fromExtension,
});
if (!aborted && errorMessage) throw new Error(errorMessage, { cause: error });
throw error;
} finally {
this._compactionAbortController = undefined;
Expand Down Expand Up @@ -3077,14 +3109,14 @@ export class AgentSession {
// Continue once so queued messages are delivered.
return this.agent.hasQueuedMessages();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "compaction failed";
if (started) {
const aborted = this._autoCompactionAbortController?.signal.aborted ?? false;
const failureDetails = aborted ? undefined : this._recordCompactionFailure(error, reason);
const formattedErrorMessage = aborted
? undefined
: reason === "overflow"
? `Context overflow recovery failed: ${errorMessage}`
: `Auto-compaction failed: ${errorMessage}`;
? `Context overflow recovery failed: ${failureDetails}`
: `Auto-compaction failed: ${failureDetails}`;
this._emit({
type: "compaction_end",
reason,
Expand Down
88 changes: 75 additions & 13 deletions packages/coding-agent/src/core/compaction/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ import {
type SkillInstructionContext,
stripSkillInstructions,
} from "./skill-instructions.ts";
import {
createSummaryDiagnostics,
getSummaryDiagnostics,
observeSummaryPayload,
SummaryFailure,
saveSummaryDiagnostics,
summaryFailure,
} from "./summary-diagnostics.ts";
import { createSummarySourceReducer } from "./summary-overflow.ts";
import {
computeFileLists,
Expand Down Expand Up @@ -585,8 +593,8 @@ ${SUMMARY_DETAIL_RULES}`;
/**
* Returns an error message when a summarization response cannot safely be persisted.
* A length stop contains partial text and must not become a session checkpoint.
* When the model reported its output cap, it is surfaced in the message so
* operators can see which limit was hit and tune `reserveTokens` accordingly.
* The displayed budget is selected by the caller, not read back from the HTTP payload.
* Effective request limits are recorded separately in the failure diagnostics.
*/
export function getSummarizationFailure(
response: AssistantMessage,
Expand All @@ -600,7 +608,9 @@ export function getSummarizationFailure(
return `${label} failed: ${response.errorMessage || "Unknown error"}`;
}
if (response.stopReason === "length") {
const capNote = maxTokens ? ` (${maxTokens}-token output cap; raise reserveTokens if this recurs)` : "";
const capNote = maxTokens
? ` (${maxTokens}-token selected output cap; check model output limits and request context headroom)`
: "";
return `${label} failed: generation hit the token cap and the summary is incomplete${capNote}`;
}
return undefined;
Expand Down Expand Up @@ -640,16 +650,40 @@ export async function completeSummarization(
): Promise<AssistantMessage> {
// Avoid cache writes for one-off summaries. Reuse caller-supplied routing when available;
// callers without a session ID, including branch summaries, receive a fresh routing ID.
const diagnostics = createSummaryDiagnostics(model, context, options);
const startedAt = Date.now();
const requestOptions: SimpleStreamOptions = {
...options,
cacheRetention: "none",
sessionId: options.sessionId ?? uuidv7(),
onPayload: async (payload, requestModel) => {
const replacement = await options.onPayload?.(payload, requestModel);
diagnostics.observedMaxTokens = observeSummaryPayload(replacement ?? payload);
return replacement;
},
};
const produce = async (): Promise<AssistantMessage> =>
streamFn
const produce = async (): Promise<AssistantMessage> => {
diagnostics.attempts++;
diagnostics.observedMaxTokens = undefined;
return streamFn
? (await streamFn(model, context, requestOptions)).result()
: completeSimple(model, context, requestOptions);
return retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
};
let response: AssistantMessage;
try {
response = await retryAssistantCall(produce, retry, requestOptions.signal, callbacks);
} catch (error) {
if (requestOptions.signal?.aborted) throw new DOMException("Summarization aborted", "AbortError");
diagnostics.durationMs = Date.now() - startedAt;
throw new SummaryFailure(error instanceof Error ? error.message : String(error), diagnostics);
}
diagnostics.durationMs = Date.now() - startedAt;
diagnostics.stopReason = response.stopReason;
diagnostics.errorKind = response.errorKind;
diagnostics.rawStopReason = response.rawStopReason;
diagnostics.usage = response.usage;
saveSummaryDiagnostics(response, diagnostics);
return response;
}

/**
Expand Down Expand Up @@ -735,6 +769,9 @@ async function completeSummaryWithOverflowRecovery(
let reductions = 0;
let reduceSource: ReturnType<typeof createSummarySourceReducer>;
let usage: Usage | undefined;
let lastResponse: AssistantMessage | undefined;
let attempts = 0;
const startedAt = Date.now();
const requestOptions = { ...options, sessionId: options.sessionId ?? uuidv7() };

// A local recovery/cancellation failure can end produce() while a transient retry is
Expand All @@ -756,6 +793,10 @@ async function completeSummaryWithOverflowRecovery(
for (;;) {
if (options.signal?.aborted) throw new Error("Compaction cancelled");
const response = await completeSummarization(model, context, requestOptions, streamFn);
const diagnostics = getSummaryDiagnostics(response);
if (diagnostics) diagnostics.stage = label;
lastResponse = response;
attempts++;
usage = usage ? combineUsage(usage, response.usage) : response.usage;
if (options.signal?.aborted) throw new Error("Compaction cancelled");
// Do not infer overflow from successful usage or empty/length-limited output.
Expand Down Expand Up @@ -784,11 +825,29 @@ async function completeSummaryWithOverflowRecovery(

try {
const response = await retryAssistantCall(produce, retry, options.signal, retryCallbacks);
return { ...response, usage: usage ?? response.usage };
const result = { ...response, usage: usage ?? response.usage };
const diagnostics = getSummaryDiagnostics(response);
if (diagnostics) {
saveSummaryDiagnostics(result, { ...diagnostics, attempts, durationMs: Date.now() - startedAt });
}
return result;
} catch (error) {
if (activeRetry !== undefined) {
await callbacks?.onRetryFinished?.(false, activeRetry, error instanceof Error ? error.message : String(error));
}
if (error instanceof SummaryFailure) {
error.diagnostics.stage = label;
error.diagnostics.attempts += attempts;
error.diagnostics.durationMs = Date.now() - startedAt;
throw error;
}
if (lastResponse && !options.signal?.aborted) {
const diagnostics = getSummaryDiagnostics(lastResponse);
if (diagnostics) {
saveSummaryDiagnostics(lastResponse, { ...diagnostics, attempts, durationMs: Date.now() - startedAt });
throw summaryFailure(error instanceof Error ? error.message : String(error), lastResponse);
}
}
throw error;
}
}
Expand Down Expand Up @@ -854,16 +913,16 @@ export async function generateSummaryWithUsage(
}
const failure = getSummarizationFailure(response, "Summarization", maxTokens);
if (failure) {
throw new Error(failure);
throw summaryFailure(failure, response);
}
if (response.content.some((block) => block.type === "toolCall")) {
throw new Error("Summarization attempted to call a tool");
throw summaryFailure("Summarization attempted to call a tool", response);
}

const textContent = contentText(response.content);
// Validate model text before split-turn scaffolding or file metadata can make it look nonempty.
if (textContent.trim().length === 0) {
throw new Error("Summarization failed: empty summary (response contained no summary text)");
throw summaryFailure("Summarization failed: empty summary (response contained no summary text)", response);
}

return { text: textContent, usage: response.usage };
Expand Down Expand Up @@ -1157,15 +1216,18 @@ async function generateTurnPrefixSummary(
}
const failure = getSummarizationFailure(response, "Turn prefix summarization", maxTokens);
if (failure) {
throw new Error(failure);
throw summaryFailure(failure, response);
}
if (response.content.some((block) => block.type === "toolCall")) {
throw new Error("Turn prefix summarization attempted to call a tool");
throw summaryFailure("Turn prefix summarization attempted to call a tool", response);
}
const textContent = contentText(response.content);
// A valid history summary cannot substitute for a missing turn-prefix summary.
if (textContent.trim().length === 0) {
throw new Error("Turn prefix summarization failed: empty summary (response contained no summary text)");
throw summaryFailure(
"Turn prefix summarization failed: empty summary (response contained no summary text)",
response,
);
}

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { AssistantMessage, Context, Model, RetryPolicy, SimpleStreamOptions } from "@step-harness/providers";
import { redactSecretString } from "../../step/secret-redaction.ts";

export interface SummaryRequestDiagnostics {
stage?: string;
provider: string;
model: string;
api: string;
contextWindow: number;
modelMaxTokens: number;
selectedMaxTokens?: number;
observedMaxTokens?: number;
inputTokensEstimated: number;
reasoning: string;
attempts: number;
durationMs: number;
stopReason?: string;
errorKind?: string;
rawStopReason?: string;
usage?: AssistantMessage["usage"];
}

const responses = new WeakMap<AssistantMessage, SummaryRequestDiagnostics>();

/** Capture only numeric budget fields, never the prompt, headers, or credentials. */
export function observeSummaryPayload(payload: unknown): number | undefined {
if (!payload || typeof payload !== "object") return undefined;
const record = payload as Record<string, unknown>;
for (const key of ["max_output_tokens", "max_tokens", "max_completion_tokens"]) {
const value = record[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
}
return undefined;
}

export function createSummaryDiagnostics(
model: Model<any>,
context: Context,
options: SimpleStreamOptions,
): SummaryRequestDiagnostics {
return {
provider: redactSecretString(model.provider),
model: redactSecretString(model.id),
api: model.api,
contextWindow: model.contextWindow,
modelMaxTokens: model.maxTokens,
selectedMaxTokens: options.maxTokens,
inputTokensEstimated: Math.ceil(JSON.stringify(context).length / 4),
reasoning: options.reasoning ?? "off",
attempts: 0,
durationMs: 0,
};
}

export function saveSummaryDiagnostics(response: AssistantMessage, diagnostics: SummaryRequestDiagnostics): void {
responses.set(response, diagnostics);
}

export function getSummaryDiagnostics(response: AssistantMessage): SummaryRequestDiagnostics | undefined {
return responses.get(response);
}

export class SummaryFailure extends Error {
readonly diagnostics: SummaryRequestDiagnostics;

constructor(message: string, diagnostics: SummaryRequestDiagnostics) {
super(redactSecretString(message));
this.name = "SummaryFailure";
this.diagnostics = diagnostics;
}
}

export function summaryFailure(message: string, response: AssistantMessage): Error {
const diagnostics = responses.get(response);
return diagnostics ? new SummaryFailure(message, diagnostics) : new Error(redactSecretString(message));
}

export function formatSummaryDiagnostics(d: SummaryRequestDiagnostics): string {
return `Summary request: stage=${d.stage ?? "unknown"}, provider=${d.provider}, model=${d.model}, api=${d.api}; contextWindow=${d.contextWindow}, model.maxTokens=${d.modelMaxTokens}, selectedMaxTokens=${d.selectedMaxTokens ?? "unknown"}, observedPayloadMaxTokens=${d.observedMaxTokens ?? "unknown"}, inputTokensEstimated=${d.inputTokensEstimated}, reasoning=${d.reasoning}; attempts=${d.attempts}, durationMs=${d.durationMs}, stopReason=${d.stopReason ?? "unknown"}, errorKind=${d.errorKind ?? "unknown"}.`;
}

export function formatRetryPolicy(retry: RetryPolicy): string {
return `retry.enabled=${retry.enabled}, retry.maxRetries=${retry.maxRetries}, retry.baseDelayMs=${retry.baseDelayMs}`;
}
5 changes: 3 additions & 2 deletions packages/coding-agent/test/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,9 @@ describe("getSummarizationFailure length-stop diagnostics", () => {
it("surfaces the token cap in the length-stop message when provided", () => {
const message = getSummarizationFailure(lengthResponse, "Summarization", 19660);
expect(message).toContain("hit the token cap");
expect(message).toContain("19660-token output cap");
expect(message).toContain("raise reserveTokens");
expect(message).toContain("19660-token selected output cap");
expect(message).toContain("check model output limits");
expect(message).not.toContain("raise reserveTokens");
});

it("omits the cap note when maxTokens is not provided", () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/test/footer-data-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ function createReftableWorktree(tempDir: string): WorktreeFixture {
return { worktreeDir, reftableDir };
}

// These checks exercise real fs.watch delivery, not only the mocked git callback.
// A full-suite wait timed out but focused and later full reruns passed; event loss
// versus scheduling delay is unconfirmed. Capture watcher/debounce timing on recurrence
// rather than increasing this deadline without evidence.
async function waitFor(condition: () => boolean, timeoutMs = 3000): Promise<void> {
const startedAt = Date.now();
while (!condition()) {
Expand Down
4 changes: 4 additions & 0 deletions packages/coding-agent/test/subagent-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ test("a source-launched subagent starts the real CLI from a different working di
for (const name of ["PATH", "SystemRoot", "SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT"]) {
if (process.env[name] !== undefined) env[name] = process.env[name];
}
// macOS Unix socket paths are bounded: tsx adds an IPC suffix below TMPDIR.
// Run the isolated suite with TMPDIR=/tmp if the default temp root causes EINVAL.
// A subsequent 20s child timeout was intermittent; focused and full reruns passed.
// Keep the deadline unchanged until startup-stage evidence identifies the cause.
const options = { cwd, env, encoding: "utf8" as const, timeout: 25_000 };
const launcher = [fileURLToPath(import.meta.resolve("tsx/cli")), "--tsconfig", tsconfig];
// Positive control: the source CLI itself works without built workspace packages.
Expand Down
Loading
Loading