diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 886914019..b7bb67c8c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index a8a7f0168..927040e2f 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -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: diff --git a/packages/coding-agent/src/core/agent-session-auto-compaction.ts b/packages/coding-agent/src/core/agent-session-auto-compaction.ts index 913846372..9998e4447 100644 --- a/packages/coding-agent/src/core/agent-session-auto-compaction.ts +++ b/packages/coding-agent/src/core/agent-session-auto-compaction.ts @@ -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 { + // 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; @@ -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(); } } @@ -206,6 +229,24 @@ export async function _resumeAfterAutoCompaction(this: AgentSession): Promise= 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; @@ -297,5 +338,6 @@ export const agentSessionAutoCompactionMethods = { _dropTrailingAutoCompactionRetryAssistantIfPresent, _schedulePostAutoCompactionContinuationProbe, _resumeAfterAutoCompaction, + _resumeAfterLengthTruncation, _runAutoCompaction, }; diff --git a/packages/coding-agent/src/core/agent-session-events.ts b/packages/coding-agent/src/core/agent-session-events.ts index 51cec383d..8ffc7f27d 100644 --- a/packages/coding-agent/src/core/agent-session-events.ts +++ b/packages/coding-agent/src/core/agent-session-events.ts @@ -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) { diff --git a/packages/coding-agent/src/core/agent-session-methods.ts b/packages/coding-agent/src/core/agent-session-methods.ts index e57959c5b..013d7337d 100644 --- a/packages/coding-agent/src/core/agent-session-methods.ts +++ b/packages/coding-agent/src/core/agent-session-methods.ts @@ -203,6 +203,7 @@ export interface AgentSessionMethodSurface { _schedulePostAutoCompactionContinuationProbe(reason: "overflow" | "threshold", willRetry: boolean): void; _awaitPendingOverflowPostCompactionContinuation(): Promise; _resumeAfterAutoCompaction(): Promise; + _resumeAfterLengthTruncation(): void; _runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise; setAutoCompactionEnabled(enabled: boolean): void; @@ -342,6 +343,7 @@ export interface AgentSessionInternalSurface extends AgentSessionMethodSurface, _interruptDeliveryQueue: Promise; _pendingOverflowPostCompactionContinuation: Promise | undefined; _overflowPostCompactionContinuationToken: number; + _lengthContinuationAttempts: number; _pendingInterruptDeliveries: number; _activeInterruptQueueHold: InterruptQueueHold | undefined; _activeInterruptAbortMessage: string | undefined; diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index fd2cf6944..0a127805a 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -88,6 +88,7 @@ export class AgentSession { protected _overflowRecoveryAttempted = false; protected _pendingOverflowPostCompactionContinuation: Promise | undefined = undefined; protected _overflowPostCompactionContinuationToken = 0; + protected _lengthContinuationAttempts = 0; protected _branchSummaryAbortController: AbortController | undefined = undefined; protected _retryAbortController: AbortController | undefined = undefined; protected _retryAttempt = 0; diff --git a/packages/coding-agent/test/agent-session-auto-compaction-queue-03.suite.ts b/packages/coding-agent/test/agent-session-auto-compaction-queue-03.suite.ts index 0190a5c0a..761f979ce 100644 --- a/packages/coding-agent/test/agent-session-auto-compaction-queue-03.suite.ts +++ b/packages/coding-agent/test/agent-session-auto-compaction-queue-03.suite.ts @@ -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"; @@ -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 = [ @@ -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 }, "_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 }, "_continueQueuedAgentMessages").mockResolvedValue(); + const checkCompaction = (session as unknown as { _checkCompaction: (message: AssistantMessage) => Promise })._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 })._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 } + )._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 })._checkCompaction.bind(session); + + await checkCompaction(assistant); + await vi.advanceTimersByTimeAsync(100); + + expect(continueSpy).not.toHaveBeenCalled(); + }); });