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 packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Fixed

- Fixed Windows native filesystem watchers to canonicalize watched paths before calling `fs.watch`, reject unresolved 8.3 short-name paths, and fall back to polling for unsafe watcher paths. This prevents libuv fs-event assertion crashes when temp, session, footer, or theme paths contain short-name components such as `USERNA~1`.
- Fixed responses truncated at the output-token cap (`stopReason: "length"`) dead-ending the model's work with a "maximum output token limit" error when the context was still below the auto-compaction threshold. Previously auto-continuation only happened as a side effect of threshold compaction, so a length truncation with input room to spare left the task half-finished. Length-truncated turns now continue directly without compacting: the incomplete assistant is removed from retry context and the generation resumes automatically so the model finishes where it left off, bounded by a small consecutive-continuation cap so a turn that keeps exceeding the per-turn output cap still terminates. Compaction-driven continuation and fresh user prompts are unaffected.
- Fixed bundled workflow durable resume for reusable `git_worktree_dir` worktrees so resumed runs reuse the original invocation repository/cwd and report slow Git subprocess timeouts as Git timeouts instead of repository-detection failures.

## [0.9.5-alpha.8] - 2026-07-08
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ If auto-compaction runs while a turn still has queued work (for example a failed

For provider-overflow recovery that succeeds with `willRetry: true`, the public `AgentSession.prompt()` promise remains pending until the post-compaction retry continuation has run through the normal continuation lifecycle. If that continuation exhausts the one compact-and-retry attempt and emits `compaction_end` with `unresolvedOverflow: true`, workflow callers can observe the signal before deciding whether the prompt succeeded or should advance model fallback.

When an assistant response is truncated at the provider's per-turn output-token cap (`stopReason: "length"`) with real output produced, Atomic treats it as work cut off mid-flight and continues it automatically instead of leaving the turn dead-ended on the "maximum output token limit" error. If the context is at or above the compaction threshold, the truncation is recovered through the normal compact-and-continue path (the incomplete assistant is dropped from retry context, then generation resumes). If the context is still below the threshold — genuine long output with input room to spare — compaction would free no room, so Atomic continues the generation directly without compacting. Consecutive direct continuations are bounded by a small cap, so a turn that keeps exceeding the per-turn output cap still terminates rather than looping. This resume applies only to the live turn-completion path; a fresh user prompt never resumes a previously truncated turn.

### Image Context and Compaction

Image content blocks (screenshots, pasted images, image-bearing tool results) are expensive: providers fold image tokens into their reported prompt/input usage, so image-heavy conversations reach the compaction threshold sooner. Atomic accounts for this in two complementary ways:
Expand Down
42 changes: 42 additions & 0 deletions packages/coding-agent/src/core/agent-session-auto-compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ import { calculateContextTokens, estimateContextTokens, shouldCompact } from "./
import { getLatestCompactionBoundaryEntry } from "./session-manager.ts";
import type { AgentSessionInternalSurface as AgentSession } from "./agent-session-methods.ts";

/**
* Upper bound on consecutive automatic continuations of a response that was
* truncated at the output-token cap ("length") while the context is still
* below the compaction budget. Each continuation regenerates the cut-off turn,
* so a model that insists on emitting more than its per-turn output cap can
* still terminate instead of looping forever.
*/
export const MAX_LENGTH_CONTINUATION_ATTEMPTS = 3;

export async function _checkCompaction(this: AgentSession, assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
// The agent_end path passes skipAbortedCheck=true; the pre-prompt path passes
// false. Only the live turn-completion path may auto-continue a truncated
// response — before a fresh user prompt we must not resume the old turn.
const isLiveTurnCompletion = skipAbortedCheck;
const settings = this.settingsManager.getCompactionSettings();
if (!settings.enabled) return;

Expand Down Expand Up @@ -101,6 +114,16 @@ export async function _checkCompaction(this: AgentSession, assistantMessage: Ass
const compactionBudget = this.model ? getEffectiveInputBudget(this.model) : contextWindow;
if (shouldCompact(contextTokens, compactionBudget, settings)) {
await this._runAutoCompaction("threshold", shouldRetryAfterThresholdCompaction(assistantMessage));
return;
}

// A response truncated at the output-token cap ("length") with the context
// still below the compaction budget is genuine work cut off mid-flight, not a
// context overflow. Compaction would not free any room, so continue the
// generation directly instead of dead-ending on the truncation and leaving
// the task half-finished.
if (isLiveTurnCompletion && shouldRetryAfterThresholdCompaction(assistantMessage)) {
this._resumeAfterLengthTruncation();
}
}

Expand Down Expand Up @@ -206,6 +229,24 @@ export async function _resumeAfterAutoCompaction(this: AgentSession): Promise<vo
}
}

/**
* Internal: resume a response that was truncated at the output-token cap
* ("length") when the context does not warrant compaction. The generation is
* continued directly so the model finishes the work it was cut off from, rather
* than dead-ending on the truncation. Bounded by MAX_LENGTH_CONTINUATION_ATTEMPTS
* so a turn that keeps exceeding the per-turn output cap can still terminate.
*/

export function _resumeAfterLengthTruncation(this: AgentSession): void {
if (this._lengthContinuationAttempts >= MAX_LENGTH_CONTINUATION_ATTEMPTS) return;
this._lengthContinuationAttempts += 1;
// agent.continue() rejects an assistant tail; drop the incomplete
// length-stopped message so the preceding user/tool-result anchors the
// continuation. It remains persisted in session history.
this._dropTrailingAutoCompactionRetryAssistantIfPresent();
this._schedulePostAutoCompactionContinuationProbe("threshold", true);
}


function overflowUnresolved(reason: "overflow" | "threshold", aborted = false): boolean | undefined {
return reason === "overflow" && !aborted ? true : undefined;
Expand Down Expand Up @@ -297,5 +338,6 @@ export const agentSessionAutoCompactionMethods = {
_dropTrailingAutoCompactionRetryAssistantIfPresent,
_schedulePostAutoCompactionContinuationProbe,
_resumeAfterAutoCompaction,
_resumeAfterLengthTruncation,
_runAutoCompaction,
};
7 changes: 7 additions & 0 deletions packages/coding-agent/src/core/agent-session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ export async function _processAgentEvent(this: AgentSession, event: AgentEvent):
this._overflowRecoveryAttempted = false;
}

// A non-truncated assistant response means the length-continuation loop
// made progress (or the turn completed cleanly), so reset the bounded
// output-cap continuation counter.
if (assistantMsg.stopReason !== "length") {
this._lengthContinuationAttempts = 0;
}

// Reset retry counter immediately on successful assistant response
// This prevents accumulation across multiple LLM calls within a turn
if (!assistantFailed && this._retryAttempt > 0) {
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/agent-session-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export interface AgentSessionMethodSurface {
_schedulePostAutoCompactionContinuationProbe(reason: "overflow" | "threshold", willRetry: boolean): void;
_awaitPendingOverflowPostCompactionContinuation(): Promise<void>;
_resumeAfterAutoCompaction(): Promise<void>;
_resumeAfterLengthTruncation(): void;
_runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void>;
setAutoCompactionEnabled(enabled: boolean): void;

Expand Down Expand Up @@ -342,6 +343,7 @@ export interface AgentSessionInternalSurface extends AgentSessionMethodSurface,
_interruptDeliveryQueue: Promise<void>;
_pendingOverflowPostCompactionContinuation: Promise<void> | undefined;
_overflowPostCompactionContinuationToken: number;
_lengthContinuationAttempts: number;
_pendingInterruptDeliveries: number;
_activeInterruptQueueHold: InterruptQueueHold | undefined;
_activeInterruptAbortMessage: string | undefined;
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class AgentSession {
protected _overflowRecoveryAttempted = false;
protected _pendingOverflowPostCompactionContinuation: Promise<void> | undefined = undefined;
protected _overflowPostCompactionContinuationToken = 0;
protected _lengthContinuationAttempts = 0;
protected _branchSummaryAbortController: AbortController | undefined = undefined;
protected _retryAbortController: AbortController | undefined = undefined;
protected _retryAttempt = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Agent, type AgentMessage } from "@earendil-works/pi-agent-core";
import { type AssistantMessage, getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.ts";
import { MAX_LENGTH_CONTINUATION_ATTEMPTS } from "../src/core/agent-session-auto-compaction.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { ModelRegistry } from "../src/core/model-registry.ts";
import { SessionManager } from "../src/core/session-manager.ts";
Expand Down Expand Up @@ -100,6 +101,19 @@ describe("AgentSession auto-compaction length-stop resume", () => {
};
}

function belowThresholdLengthStoppedAssistant(): AssistantMessage {
const assistant = lengthStoppedAssistant();
// Keep the context well below the compaction budget so compaction is a
// no-op and only the direct length continuation can fire.
assistant.usage = {
...assistant.usage,
input: 40_000,
output: 10_000,
totalTokens: 50_000,
};
return assistant;
}

it("compacts and retries threshold-sized length-stopped responses", async () => {
const assistant = lengthStoppedAssistant();
session.agent.state.messages = [
Expand Down Expand Up @@ -162,4 +176,87 @@ describe("AgentSession auto-compaction length-stop resume", () => {
expect(waitSpy).toHaveBeenCalledTimes(1);
expect(drainSpy).toHaveBeenCalledTimes(1);
});

it("continues a below-threshold length-stopped response without compacting", async () => {
const userMessage: AgentMessage = {
role: "user",
content: [{ type: "text", text: "write a long answer" }],
timestamp: Date.now() - 1000,
};
const assistant = belowThresholdLengthStoppedAssistant();
sessionManager.appendMessage(userMessage);
sessionManager.appendMessage(assistant);
session.agent.state.messages = [userMessage, assistant];
const runAutoCompactionSpy = vi
.spyOn(session as unknown as { _runAutoCompaction: (reason: "overflow" | "threshold", willRetry: boolean) => Promise<void> }, "_runAutoCompaction")
.mockResolvedValue();
const continueSpy = vi.spyOn(session.agent, "continue").mockImplementation(async () => {
// The incomplete length-stopped assistant is dropped so the anchor is a user message.
expect(session.agent.state.messages.at(-1)?.role).toBe("user");
});
vi.spyOn(session, "waitForRetry").mockResolvedValue();
vi.spyOn(session as unknown as { _continueQueuedAgentMessages: () => Promise<void> }, "_continueQueuedAgentMessages").mockResolvedValue();
const checkCompaction = (session as unknown as { _checkCompaction: (message: AssistantMessage) => Promise<void> })._checkCompaction.bind(session);

await checkCompaction(assistant);
await vi.advanceTimersByTimeAsync(100);

expect(runAutoCompactionSpy).not.toHaveBeenCalled();
expect(continueSpy).toHaveBeenCalledTimes(1);
});

it("does not continue a below-threshold zero-output length stop", async () => {
const assistant = belowThresholdLengthStoppedAssistant();
assistant.usage = { ...assistant.usage, output: 0, totalTokens: 40_000 };
session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
assistant,
];
const resumeSpy = vi.spyOn(
session as unknown as { _resumeAfterLengthTruncation: () => void },
"_resumeAfterLengthTruncation",
);
const checkCompaction = (session as unknown as { _checkCompaction: (message: AssistantMessage) => Promise<void> })._checkCompaction.bind(session);

await checkCompaction(assistant);

expect(resumeSpy).not.toHaveBeenCalled();
});

it("does not resume a length truncation before a fresh user prompt (non-live path)", async () => {
const assistant = belowThresholdLengthStoppedAssistant();
session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
assistant,
];
const resumeSpy = vi.spyOn(
session as unknown as { _resumeAfterLengthTruncation: () => void },
"_resumeAfterLengthTruncation",
);
const checkCompaction = (
session as unknown as { _checkCompaction: (message: AssistantMessage, skipAbortedCheck: boolean) => Promise<void> }
)._checkCompaction.bind(session);

// skipAbortedCheck=false marks the pre-prompt path; a new user turn must not resume the old one.
await checkCompaction(assistant, false);

expect(resumeSpy).not.toHaveBeenCalled();
});

it("stops continuing after MAX_LENGTH_CONTINUATION_ATTEMPTS", async () => {
const assistant = belowThresholdLengthStoppedAssistant();
session.agent.state.messages = [
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() - 1000 },
assistant,
];
(session as unknown as { _lengthContinuationAttempts: number })._lengthContinuationAttempts = MAX_LENGTH_CONTINUATION_ATTEMPTS;
const continueSpy = vi.spyOn(session.agent, "continue").mockResolvedValue();
vi.spyOn(session, "waitForRetry").mockResolvedValue();
const checkCompaction = (session as unknown as { _checkCompaction: (message: AssistantMessage) => Promise<void> })._checkCompaction.bind(session);

await checkCompaction(assistant);
await vi.advanceTimersByTimeAsync(100);

expect(continueSpy).not.toHaveBeenCalled();
});
});
Loading