From 8e6f54f917ba1dbf0316759151504612c102d783 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:53:45 +0800 Subject: [PATCH 1/5] feat(agent-core): add beforeModelRequest loop hook Add an optional beforeModelRequest hook to AgentLoopConfig, AgentOptions, and Agent. The loop calls it once before every provider request: phase "initial" for the first request of a prompt or continue(), "follow-up" for each later request. It runs after injected steering, so the context it receives is exactly what will be sent, and it may replace the context, model, or thinking level. Tool-call markup resampling does not call it again. On follow-up requests that have not received steering yet, the loop polls steering once more after the hook (unless aborted), so input queued during long request preparation such as compaction reaches the same request while one-at-a-time delivery stays one message per turn. prepareNextTurn keeps its existing timing and semantics. --- packages/agent-core/README.md | 2 + packages/agent-core/docs/harness.md | 2 + packages/agent-core/src/agent-loop.ts | 52 +++++-- packages/agent-core/src/agent.ts | 14 ++ packages/agent-core/src/types.ts | 27 ++++ packages/agent-core/test/agent-loop.test.ts | 163 ++++++++++++++++++++ 6 files changed, 248 insertions(+), 12 deletions(-) diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index e24f816b4..6fa8b66cc 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -159,6 +159,8 @@ const stream = agentLoop( `shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason. The `AgentOptions` callback also receives the active run's `AbortSignal` as its second argument. +To change the context right before a provider request instead of stopping, set `beforeModelRequest` (on `AgentLoopConfig`, `AgentOptions`, or the `Agent` instance). It runs before every provider request: `phase: "initial"` for the first request of a prompt or `continue()`, and `phase: "follow-up"` for each later request. The context it receives already contains the prompts, completed tool results, and steering injected for that request. Return `{ context, model, thinkingLevel }` replacements, or `undefined` to send the context unchanged. Tool-call markup resampling of the same request does not call it again. On follow-up requests that have not received steering yet, the loop polls steering once more after the hook, so input queued during a long hook such as compaction reaches the same request. `prepareNextTurn` keeps its existing timing: after `turn_end`, before steering is polled. + When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call. ### continue() Event Sequence diff --git a/packages/agent-core/docs/harness.md b/packages/agent-core/docs/harness.md index 9e30c3cd1..483076758 100644 --- a/packages/agent-core/docs/harness.md +++ b/packages/agent-core/docs/harness.md @@ -2678,6 +2678,8 @@ For each live tool batch, the harness resolves `toolContext` exactly once, cache `executeToolBatch` (the exported successor of the source's private `executeToolCalls`) preserves the existing sequential/parallel behavior: source-ordered preparation and dispatch, concurrent effects in parallel mode, source-ordered finalization/results, no effect for blocked/invalid/genuine-length calls, and `terminate: true` only when every finalized outcome terminates. Compatibility wrappers keep existing public loop signatures and events. +The loop's optional `beforeModelRequest` hook runs before every provider request (the first request of a prompt or continuation, and every follow-up), after injected steering, and may replace the request context, model, or thinking level; see the [package README](../README.md). It must not throw. Products use it for request-time maintenance such as compaction, so any phase refactor must keep calling it once per provider request and keep the post-hook steering poll for follow-up requests. + ## 5.8 Telemetry Use the existing callback-based `TelemetryContext`, no-op/reference implementations, typed schema machinery, and agent-owned schemas. Do not invent a second contract. Context is passed explicitly; no core `AsyncLocalStorage` or global active span. diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index a16ae90f1..4549ed625 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -17,6 +17,7 @@ import type { AgentContext, AgentEvent, AgentLoopConfig, + AgentLoopTurnUpdate, AgentMessage, AgentTool, AgentToolCall, @@ -173,6 +174,20 @@ function createAgentStream(): AgentLoopStream { ); } +/** Apply a model/thinking replacement returned by a turn or request preparation hook. */ +function applyTurnUpdate(config: AgentLoopConfig, update: AgentLoopTurnUpdate): AgentLoopConfig { + return { + ...config, + model: update.model ?? config.model, + reasoning: + update.thinkingLevel === undefined + ? config.reasoning + : update.thinkingLevel === "off" + ? undefined + : update.thinkingLevel, + }; +} + /** * Main loop logic shared by agentLoop and agentLoopContinue. */ @@ -200,16 +215,7 @@ async function runLoop( const nextTurnSnapshot = await config.prepareNextTurn?.(lastCompletedTurn); if (nextTurnSnapshot) { currentContext = nextTurnSnapshot.context ?? currentContext; - config = { - ...config, - model: nextTurnSnapshot.model ?? config.model, - reasoning: - nextTurnSnapshot.thinkingLevel === undefined - ? config.reasoning - : nextTurnSnapshot.thinkingLevel === "off" - ? undefined - : nextTurnSnapshot.thinkingLevel, - }; + config = applyTurnUpdate(config, nextTurnSnapshot); } // Preparation can be long-running (for example, compaction). Pick up steering // queued while it ran. Only poll again if the earlier poll returned nothing; @@ -221,16 +227,38 @@ async function runLoop( } // Process pending messages (inject before next assistant response) - if (pendingMessages.length > 0) { - for (const message of pendingMessages) { + const injectPending = async (messages: AgentMessage[]) => { + for (const message of messages) { await emit({ type: "message_start", message }); await emit({ type: "message_end", message }); currentContext.messages.push(message); newMessages.push(message); } + }; + const injectedSteering = pendingMessages.length > 0; + if (injectedSteering) { + await injectPending(pendingMessages); pendingMessages = []; } + if (config.beforeModelRequest) { + const requestSnapshot = await config.beforeModelRequest({ + phase: lastCompletedTurn ? "follow-up" : "initial", + context: currentContext, + newMessages, + }); + if (requestSnapshot) { + currentContext = requestSnapshot.context ?? currentContext; + config = applyTurnUpdate(config, requestSnapshot); + } + // The request hook can be long-running (for example, compaction). As after + // prepareNextTurn, pick up steering queued while it ran, but only when this + // turn has not delivered any yet so one-at-a-time mode stays one per turn. + if (lastCompletedTurn && !injectedSteering && !signal?.aborted) { + await injectPending((await config.getSteeringMessages?.()) || []); + } + } + // Stream assistant response let message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction); diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 4d1b0bab7..556385f04 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -19,6 +19,7 @@ import type { AgentMessage, AgentState, AgentTool, + BeforeModelRequestContext, BeforeToolCallContext, BeforeToolCallResult, PrepareNextTurnContext, @@ -114,6 +115,10 @@ export interface AgentOptions { context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; + beforeModelRequest?: ( + context: BeforeModelRequestContext, + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; steeringMode?: QueueMode; followUpMode?: QueueMode; sessionId?: string; @@ -203,6 +208,11 @@ export class Agent { context: PrepareNextTurnContext, signal?: AbortSignal, ) => Promise | AgentLoopTurnUpdate | undefined; + /** Called before every provider request; see {@link AgentLoopConfig.beforeModelRequest}. */ + public beforeModelRequest?: ( + context: BeforeModelRequestContext, + signal?: AbortSignal, + ) => Promise | AgentLoopTurnUpdate | undefined; private activeRun?: ActiveRun; /** Session identifier forwarded to providers for cache-aware backends. */ public sessionId?: string; @@ -231,6 +241,7 @@ export class Agent { this.shouldStopAfterTurn = runtimeOptions.shouldStopAfterTurn; this.prepareNextTurn = runtimeOptions.prepareNextTurn; this.prepareNextTurnWithContext = runtimeOptions.prepareNextTurnWithContext; + this.beforeModelRequest = runtimeOptions.beforeModelRequest; this.steeringQueue = new PendingMessageQueue(runtimeOptions.steeringMode ?? "one-at-a-time"); this.followUpQueue = new PendingMessageQueue(runtimeOptions.followUpMode ?? "one-at-a-time"); this.sessionId = runtimeOptions.sessionId; @@ -473,6 +484,9 @@ export class Agent { return await this.prepareNextTurn?.(this.signal); } : undefined, + beforeModelRequest: this.beforeModelRequest + ? async (context) => await this.beforeModelRequest?.(context, this.signal) + : undefined, convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index f2112afe2..8e2b0fc1c 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -146,6 +146,19 @@ export interface AgentLoopTurnUpdate { export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {} +/** Context passed to `beforeModelRequest`. */ +export interface BeforeModelRequestContext { + /** + * `initial` for the first provider request of a loop invocation (prompt or continue); + * `follow-up` for every later request in the same invocation. + */ + phase: "initial" | "follow-up"; + /** Context that the next provider request will send, including prompts, tool results, and injected steering. */ + context: AgentContext; + /** Messages that this loop invocation will return if it exits at this point. */ + newMessages: AgentMessage[]; +} + export interface AgentLoopConfig extends SimpleStreamOptions { model: Model; @@ -243,6 +256,20 @@ export interface AgentLoopConfig extends SimpleStreamOptions { context: PrepareNextTurnContext, ) => AgentLoopTurnUpdate | undefined | Promise; + /** + * Called immediately before every provider request, including the first request of a prompt or + * continuation, after pending steering messages have been appended to the context. Tool-call + * markup resampling of the same request does not call it again. On follow-up requests that have + * not received steering yet, the loop polls steering once more after this hook returns, so input + * queued during a long-running hook (for example, compaction) reaches the same request. + * Return replacement context/model/thinking state to affect that request, or undefined to keep it. + * + * Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence. + */ + beforeModelRequest?: ( + context: BeforeModelRequestContext, + ) => AgentLoopTurnUpdate | undefined | Promise; + /** * Returns steering messages to inject into the conversation mid-run. * diff --git a/packages/agent-core/test/agent-loop.test.ts b/packages/agent-core/test/agent-loop.test.ts index 39630f656..3eeda4dbb 100644 --- a/packages/agent-core/test/agent-loop.test.ts +++ b/packages/agent-core/test/agent-loop.test.ts @@ -1297,6 +1297,169 @@ describe("agentLoop with AgentMessage", () => { expect(convertedSecondTurnSystemPrompt).toBe("second prompt"); }); + it("calls beforeModelRequest before every provider request with the full outgoing context", async () => { + const toolSchema = Type.Object({ value: Type.String() }); + const tool: AgentTool = { + name: "echo", + label: "Echo", + description: "Echo tool", + parameters: toolSchema, + async execute(_toolCallId, params) { + return { content: [{ type: "text", text: `echoed: ${params.value}` }], details: { value: params.value } }; + }, + }; + const steering = createUserMessage("steer"); + let steeringDelivered = false; + const seen: Array<{ phase: string; roles: string[] }> = []; + const requestPrompts: string[] = []; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + getSteeringMessages: async () => { + if (seen.length === 1 && !steeringDelivered) { + steeringDelivered = true; + return [steering]; + } + return []; + }, + beforeModelRequest: async ({ phase, context: requestContext }) => { + seen.push({ phase, roles: requestContext.messages.map((message) => message.role) }); + return { context: { ...requestContext, systemPrompt: `request ${seen.length}` } }; + }, + }; + + let llmCalls = 0; + const stream = agentLoop( + [createUserMessage("echo something")], + { systemPrompt: "base", messages: [], tools: [tool] }, + config, + undefined, + (_model, ctx) => { + llmCalls++; + requestPrompts.push(ctx.systemPrompt ?? ""); + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage( + [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }], + "toolUse", + ) + : createAssistantMessage([{ type: "text", text: "done" }]); + mockStream.push({ + type: "done", + reason: message.stopReason === "toolUse" ? "toolUse" : "stop", + message, + }); + }); + return mockStream; + }, + ); + for await (const _event of stream) { + // consume + } + + expect(llmCalls).toBe(2); + expect(seen).toEqual([ + { phase: "initial", roles: ["user"] }, + { phase: "follow-up", roles: ["user", "assistant", "toolResult", "user"] }, + ]); + expect(requestPrompts).toEqual(["request 1", "request 2"]); + }); + + it("delivers steering queued during a follow-up beforeModelRequest to the same request, once per turn", async () => { + const toolSchema = Type.Object({}); + const tool: AgentTool> = { + name: "noop", + label: "Noop", + description: "Noop", + parameters: toolSchema, + async execute() { + return { content: [{ type: "text", text: "ok" }], details: {} }; + }, + }; + const queue: AgentMessage[] = []; + const requests: string[][] = []; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + // one-at-a-time delivery + getSteeringMessages: async () => (queue.length > 0 ? [queue.shift()!] : []), + beforeModelRequest: async ({ phase }) => { + // Simulate input typed while a long hook (compaction) runs. + if (phase === "follow-up" && requests.length === 1) { + queue.push(createUserMessage("during hook A"), createUserMessage("during hook B")); + } + return undefined; + }, + }; + let llmCalls = 0; + const stream = agentLoop( + [createUserMessage("start")], + { systemPrompt: "", messages: [], tools: [tool] }, + config, + undefined, + (_model, ctx) => { + llmCalls++; + requests.push( + ctx.messages + .filter((message) => message.role === "user") + .map((message) => (typeof message.content === "string" ? message.content : "")), + ); + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + const message = + llmCalls === 1 + ? createAssistantMessage([{ type: "toolCall", id: "t1", name: "noop", arguments: {} }], "toolUse") + : createAssistantMessage([{ type: "text", text: "done" }]); + mockStream.push({ + type: "done", + reason: message.stopReason === "toolUse" ? "toolUse" : "stop", + message, + }); + }); + return mockStream; + }, + ); + for await (const _event of stream) { + // consume + } + // Request 2 receives only the first queued message (one-at-a-time); request 3 the second. + expect(requests).toEqual([["start"], ["start", "during hook A"], ["start", "during hook A", "during hook B"]]); + }); + + it("calls beforeModelRequest for the first request of a continuation", async () => { + const phases: string[] = []; + const config: AgentLoopConfig = { + model: createModel(), + convertToLlm: identityConverter, + beforeModelRequest: ({ phase }) => { + phases.push(phase); + return undefined; + }, + }; + const stream = agentLoopContinue( + { systemPrompt: "", messages: [createUserMessage("resume")], tools: [] }, + config, + undefined, + () => { + const mockStream = new MockAssistantStream(); + queueMicrotask(() => { + mockStream.push({ + type: "done", + reason: "stop", + message: createAssistantMessage([{ type: "text", text: "done" }]), + }); + }); + return mockStream; + }, + ); + for await (const _event of stream) { + // consume + } + expect(phases).toEqual(["initial"]); + }); + it("should stop after the current turn when shouldStopAfterTurn returns true", async () => { const toolSchema = Type.Object({ value: Type.String() }); const executed: string[] = []; From a8026cc2c65266ec0af60f3061ad14c60d3c3c14 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:54:18 +0800 Subject: [PATCH 2/5] feat(coding-agent): trigger auto-compaction at a configurable context ratio Automatic compaction previously fired at contextWindow - reserveTokens (16384 by default), which triggers at ~74% of a 64K window but ~98% of a 1M window. It was checked at four places with three token measurements, missed the first request of a run and continue() outside CLM mode, and compacted right after a final answer the user might never follow up. Trigger policy (v2, default): - Threshold is floor(contextWindow * triggerRatio), default 0.9 for every model. compaction.triggerRatio (0.5-0.95) and compaction.modelTriggerRatios["provider/id"] configure it; tokenLimit can only trigger earlier. An explicit reserveTokens smaller than the window still caps it at contextWindow - reserveTokens. Invalid values fall back to defaults and are reported as settings diagnostics. - One checkpoint before every model request through the new agent-core beforeModelRequest hook (CLM maintenance first, native fallback). The pre-run check still measures the incoming prompt; the hook re-measures the first request when it no longer matches what was checked (steering queued meanwhile, agent_start listeners, model object replaced). - A final answer no longer compacts on its own; only overflow and truncated-length recovery run after a response. - Usage reported before the latest compaction is ignored; without trustworthy usage the estimate includes the system prompt and tools. - Three consecutive automatic failures pause automatic compaction (and stop measuring) for the session. Cancellations and errors raised after a compaction was saved do not count. A successful compaction, accepted CLM edit, model switch, or context-window change resumes it. - Unexpected errors while measuring or preparing maintenance fail open: they are recorded as step-compaction-failure entries, counted, and the request is sent unchanged. compaction.triggerPolicy = "legacy" restores the previous threshold, checkpoints, and measurement. The interactive UI restores the working indicator when a compaction ends inside an active agent run. --- apps/cli/src/ui/runtime/session-events.ts | 13 + .../session-events-working-tracker.test.ts | 39 ++ docs/compaction-integrity.md | 4 +- packages/coding-agent/docs/compaction.md | 25 +- .../coding-agent/docs/context-management.md | 7 +- packages/coding-agent/docs/settings.md | 18 +- .../coding-agent/src/core/agent-session.ts | 252 +++++++++++-- .../coding-agent/src/core/compaction/index.ts | 1 + .../src/core/compaction/trigger-policy.ts | 136 +++++++ .../coding-agent/src/core/settings-manager.ts | 40 +- .../test/compaction-trigger-policy.test.ts | 94 +++++ .../test/suite/agent-session-auto-clm.test.ts | 32 +- .../agent-session-compaction-trigger.test.ts | 329 ++++++++++++++++ .../compaction-trigger-simulation.test.ts | 351 ++++++++++++++++++ 14 files changed, 1292 insertions(+), 49 deletions(-) create mode 100644 packages/coding-agent/src/core/compaction/trigger-policy.ts create mode 100644 packages/coding-agent/test/compaction-trigger-policy.test.ts create mode 100644 packages/coding-agent/test/suite/agent-session-compaction-trigger.test.ts create mode 100644 packages/coding-agent/test/suite/compaction-trigger-simulation.test.ts diff --git a/apps/cli/src/ui/runtime/session-events.ts b/apps/cli/src/ui/runtime/session-events.ts index ed00ff160..009fc658c 100644 --- a/apps/cli/src/ui/runtime/session-events.ts +++ b/apps/cli/src/ui/runtime/session-events.ts @@ -43,6 +43,17 @@ import { import type { RuntimeContext } from "./context.ts"; import { keepLatestTaskUpdateResult, registerTaskUpdateCall } from "./task-update-transcript.ts"; +/** + * Automatic compaction can run before a provider request inside an active agent loop, after + * `turn_start` already showed the working indicator. Clearing the compaction indicator would + * otherwise leave the streaming response without one until the next `turn_start`. + */ +function restoreWorkingIndicatorAfterCompaction(ctx: RuntimeContext): void { + if (ctx.session?.agent?.state.isStreaming && ctx.workingVisible && ctx.activeStatusIndicator?.kind !== "working") { + ctx.showWorkingStatusIndicator(); + } +} + /** Subscribe to the session's agent events. Returns/records the unsubscribe handle (owned by the composition root). */ export function subscribeToAgent(ctx: RuntimeContext): void { ctx.unsubscribe = ctx.session.subscribe(async (event) => { @@ -368,6 +379,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio ctx.autoCompactionEscapeHandler = undefined; } ctx.clearStatusIndicator("compaction"); + restoreWorkingIndicatorAfterCompaction(ctx); void ctx.flushCompactionQueue({ willRetry: true }); ctx.redraw.requestRender(); break; @@ -380,6 +392,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio ctx.autoCompactionEscapeHandler = undefined; } ctx.clearStatusIndicator("compaction"); + restoreWorkingIndicatorAfterCompaction(ctx); if (event.aborted) { if (event.reason === "manual") { ctx.showError("Compaction cancelled"); diff --git a/apps/cli/test/session-events-working-tracker.test.ts b/apps/cli/test/session-events-working-tracker.test.ts index 84bd0f5bd..f8f2287e9 100644 --- a/apps/cli/test/session-events-working-tracker.test.ts +++ b/apps/cli/test/session-events-working-tracker.test.ts @@ -32,6 +32,45 @@ test.each(["agent_end", "auto_clm_end", "compaction_end"] as const)( }, ); +test("restores the working indicator when compaction ends inside an active agent run", async () => { + const showWorkingStatusIndicator = vi.fn(); + const createContext = (isStreaming: boolean) => + ({ + isInitialized: true, + footer: { invalidate: vi.fn() }, + settingsManager: { getShowTerminalProgress: () => false }, + ui: { terminal: { setProgress: vi.fn() } }, + session: { agent: { state: { isStreaming } } }, + workingVisible: true, + activeStatusIndicator: undefined, + showWorkingStatusIndicator, + clearStatusIndicator: vi.fn(), + redraw: { requestRender: vi.fn() }, + flushCompactionQueue: vi.fn(), + }) as unknown as RuntimeContext; + await handleSessionEvent(createContext(true), { + type: "compaction_end", + reason: "threshold", + result: undefined, + aborted: false, + willRetry: false, + }); + await handleSessionEvent(createContext(true), { + type: "auto_clm_end", + result: { attempted: true, accepted: false, fallback: false, reason: "no-edit", requests: 1 }, + }); + expect(showWorkingStatusIndicator).toHaveBeenCalledTimes(2); + // An idle session (pre-prompt compaction or /compact) must not show a working indicator. + await handleSessionEvent(createContext(false), { + type: "compaction_end", + reason: "manual", + result: undefined, + aborted: false, + willRetry: false, + }); + expect(showWorkingStatusIndicator).toHaveBeenCalledTimes(2); +}); + test("turn tips follow the current goal branch and respect the disabled setting", async () => { const now = new Date().toISOString(); const goal = { id: "goal-1", sessionId: "session-1", objective: "Ship it", status: "active", createdAt: now, updatedAt: now }; diff --git a/docs/compaction-integrity.md b/docs/compaction-integrity.md index 872ef4657..20b435ace 100644 --- a/docs/compaction-integrity.md +++ b/docs/compaction-integrity.md @@ -59,8 +59,8 @@ compaction results. ## Bound generated summary output -`reserveTokens` also sets the automatic trigger: -`contextTokens > contextWindow - reserveTokens`. Raising it must not raise a +An explicitly configured `reserveTokens` smaller than the context window also caps the automatic trigger at +`contextWindow - reserveTokens` (the default trigger is `contextWindow * triggerRatio`). Raising it must not raise a summary request above the model's output limit or the existing 32000-token summary ceiling. Both compaction implementations clamp the final candidate budget after considering the reserve fraction and model budget. History and diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index d3b23a361..e65459c88 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -51,12 +51,18 @@ A `length` response remains an incomplete summary and is never saved as a succes Auto-compaction triggers when: ``` -contextTokens > contextWindow - reserveTokens +contextTokens > floor(contextWindow * triggerRatio) ``` -By default, `reserveTokens` is 16384 tokens (configurable in `~/.stepcode/config.toml` or trusted `/.stepcode/config.toml`). This leaves room for the LLM's response. +`triggerRatio` defaults to `0.9` for every model (configurable globally or per model in `~/.stepcode/config.toml` or trusted `/.stepcode/config.toml`). No fixed output reserve is subtracted: providers clamp each request's `max_tokens` to the remaining context, and a response truncated by that clamp goes through the overflow/length recovery below. `tokenLimit` can only make compaction trigger earlier. An explicitly configured `reserveTokens` keeps its old effect as an additional ceiling, `contextWindow - reserveTokens`. -During a multi-turn agent run, Step checks this threshold after tools finish and their results are appended, before starting the next assistant response. If the threshold is crossed, Step compacts inside the same agent run and resumes with the summary and retained messages. It skips this between-turn check when the completed tool batch terminates the run and no queued message requires another response. Step also checks the threshold before a new user prompt and after a low-level agent run ends. +Step checks the threshold once immediately before every model request: before the first request of a prompt (counting the incoming prompt), before every follow-up request inside a tool loop (counting the new tool results and injected steering), and before the first request of a continuation. If the threshold is crossed, Step compacts inside the same agent run and resumes with the summary and retained messages. Steering queued while a follow-up request compacts is delivered to that same request. Input queued while the pre-run check compacts (or added by an `agent_start` listener) joins the first request and is measured again before it is sent; input typed while that first request's own check compacts is delivered with the next request. A final answer that crosses the threshold does not compact on its own; the next request compacts first. Context-overflow and truncated-length recovery still run after the failed response. If measuring or preparing maintenance throws unexpectedly, the request is sent unchanged and overflow recovery remains the fallback. + +Token counts use the last valid assistant usage plus estimates of messages added after it. Usage reported before the latest compaction is ignored, because it measured the previous, larger context; without trustworthy usage, the estimate also includes the system prompt and tool schemas. + +If automatic threshold compaction fails three times in a row, it pauses for the session and the third failure's message says so; while paused, Step does not measure or attempt automatic compaction. An unexpected error while measuring or preparing maintenance also counts, is recorded as a `step-compaction-failure` entry, and the request is sent unchanged. Cancelled compactions, extension cancellations, and errors raised after a compaction was saved do not count. A successful compaction (including `/compact`), an accepted CLM edit, a model switch, or a registry update that changes the model's context window resumes it. Overflow recovery is unaffected. + +Set `triggerPolicy = "legacy"` to restore the previous `contextWindow - reserveTokens` threshold, checkpoints (including compaction right after a final answer), and token measurement. You can also trigger manually with `/compact [instructions]`, where optional instructions focus the summary. @@ -430,16 +436,25 @@ Configure compaction in `~/.stepcode/config.toml` or trusted `/.ste ```toml [compaction] enabled = true -reserveTokens = 16384 +triggerRatio = 0.9 keepRecentTokens = 20000 + +[compaction.modelTriggerRatios] +"my-provider/small-window-model" = 0.8 ``` | Setting | Default | Description | |---------|---------|-------------| | `enabled` | `true` | Enable auto-compaction | -| `reserveTokens` | `16384` | Tokens to reserve for LLM response | +| `triggerRatio` | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95) | +| `modelTriggerRatios` | none | Per-model `triggerRatio`, keyed by `provider/model-id` | +| `tokenLimit` | none | Absolute token ceiling; can only trigger earlier | +| `triggerPolicy` | `"v2"` | `"legacy"` restores the previous threshold and checkpoints | +| `reserveTokens` | `16384` | Summary output budget input; when set explicitly, also caps the trigger at `contextWindow - reserveTokens` | | `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) | +Invalid trigger values fall back to their defaults and are reported as settings warnings at startup. + Disable auto-compaction with `enabled = false`. You can still compact manually with `/compact`. The reserve also influences generated summary budgets, but each history/update diff --git a/packages/coding-agent/docs/context-management.md b/packages/coding-agent/docs/context-management.md index 008018a87..81afa8f25 100644 --- a/packages/coding-agent/docs/context-management.md +++ b/packages/coding-agent/docs/context-management.md @@ -54,9 +54,10 @@ size before an ordinary prompt and between completed tool turns. A successful final response with no queued continuation defers routine maintenance until the next request. This avoids an unused context-edit/summary call after a completed task, while explicit maintenance commands and error recovery keep their normal -behavior. By default it uses the original native compact trigger: -`contextTokens > contextWindow - compaction.reserveTokens`. The default reserve -is 16,384 tokens, so a 131,072-token window triggers above 114,688 tokens (87.5%). +behavior. By default it uses the native compact trigger, +`contextTokens > floor(contextWindow * compaction.triggerRatio)` with a default +ratio of 0.9, so a 131,072-token window triggers above 117,964 tokens. See +[Compaction](compaction.md#when-it-triggers) for per-model ratios and the legacy policy. With at least 8,000 context tokens and enough editable old text, CLM gets the first maintenance attempt at that threshold. No `/clm-compact` command, completed-plan signal, or reminder to the task model is needed. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 4b3a12bc1..9427ef09d 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -109,7 +109,11 @@ For VS Code, include `--wait` so step resumes after the editor exits: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `compaction.enabled` | boolean | `true` | Enable auto-compaction | -| `compaction.reserveTokens` | number | `16384` | Tokens reserved for LLM response | +| `compaction.triggerRatio` | number | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95) | +| `compaction.modelTriggerRatios` | object | `{}` | Per-model trigger ratios keyed by `provider/model-id` | +| `compaction.tokenLimit` | number | - | Absolute trigger ceiling; can only trigger earlier | +| `compaction.triggerPolicy` | string | `"v2"` | `"legacy"` restores the previous `contextWindow - reserveTokens` trigger and checkpoints | +| `compaction.reserveTokens` | number | `16384` | Summary output budget input; an explicit value also caps the trigger at `contextWindow - reserveTokens` | | `compaction.keepRecentTokens` | number | `20000` | Recent tokens to keep (not summarized) | | `compaction.contextProjection` | string | `"clm-v1"` | CLM working view; `off` selects native compaction, `lightweight-v1` selects deterministic projection | | `compaction.autoClm.enabled` | boolean | `true` | Attempt bounded CLM maintenance before native compaction | @@ -118,7 +122,7 @@ For VS Code, include `--wait` so step resumes after the editor exits: { "compaction": { "enabled": true, - "reserveTokens": 16384, + "triggerRatio": 0.9, "keepRecentTokens": 20000 } } @@ -349,7 +353,7 @@ See [packages.md](packages.md) for package management details. "theme": "dark", "compaction": { "enabled": true, - "reserveTokens": 16384, + "triggerRatio": 0.9, "keepRecentTokens": 20000 }, "retry": { @@ -372,18 +376,18 @@ Project settings (`.stepcode/settings.json`) override global settings. Nested ob // ~/.stepcode/agent/settings.json (global) { "theme": "dark", - "compaction": { "enabled": true, "reserveTokens": 16384 } + "compaction": { "enabled": true, "triggerRatio": 0.9 } } // .stepcode/settings.json (project) { - "compaction": { "reserveTokens": 8192 } + "compaction": { "triggerRatio": 0.8 } } // Result { "theme": "dark", - "compaction": { "enabled": true, "reserveTokens": 8192 } + "compaction": { "enabled": true, "triggerRatio": 0.8 } } ``` @@ -393,7 +397,7 @@ Project settings (`.stepcode/settings.json`) override global settings. Nested ob enabled. It also accepts `off` (native compaction) and `lightweight-v1` (deterministic request projection); explicit settings remain unchanged. Default CLM maintenance inherits native compaction's -`contextWindow - compaction.reserveTokens` threshold and falls back to native +`contextWindow * compaction.triggerRatio` threshold and falls back to native compaction if no valid useful edit can be accepted. `compaction.autoClm.enabled = false` disables automatic CLM maintenance while diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 54acd866d..27fe4e002 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -61,6 +61,7 @@ import { type BashResult, executeBashWithOperations } from "./bash-executor.ts"; import { type CompactionPreparation, type CompactionResult, + type CompactionSettings, calculateContextTokens, collectEntriesForBranchSummary, compact, @@ -70,6 +71,7 @@ import { type ProjectionByRuleStats, prepareCompaction, projectContextForRequest, + resolveCompactionThreshold, shouldCompact, } from "./compaction/index.ts"; import { AutoClmController, type AutoClmResult } from "./compaction/live-context/auto-compaction.ts"; @@ -319,10 +321,19 @@ function estimateMessagesTokens(messages: AgentMessage[]): number { return tokens; } +/** Approximate tokens of the system prompt and tool schemas, using the same chars/4 heuristic as messages. */ +function estimateRequestOverheadTokens(context: AgentContext): number { + const tools = (context.tools ?? []).map(({ name, description, parameters }) => ({ name, description, parameters })); + return Math.ceil(((context.systemPrompt ?? "").length + JSON.stringify(tools).length) / 4); +} + // ============================================================================ // Constants // ============================================================================ +/** Consecutive automatic threshold-compaction failures after which v2 pauses automatic compaction. */ +const AUTO_COMPACTION_FAILURE_LIMIT = 3; + // ============================================================================ // AgentSession Class // ============================================================================ @@ -370,6 +381,13 @@ export class AgentSession { private _autoClmAbortController?: AbortController; private _manualClm = false; private _nativeThresholdBoundary?: string; + /** Consecutive failed automatic threshold compactions; v2 pauses automatic compaction at the limit. */ + private _thresholdCompactionFailures = 0; + /** + * What `_runAgentPrompt` measured for the prompt's first request. The before-request hook skips its + * check only when the outgoing request still matches, so input added afterwards is measured again. + */ + private _checkedInitialRequest: { messageCount: number; lastMessage: AgentMessage; model: Model } | undefined; // Branch summarization state private _branchSummaryAbortController: AbortController | undefined = undefined; @@ -461,6 +479,7 @@ export class AgentSession { this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent); this._installAgentToolHooks(); this._installAgentNextTurnRefresh(); + this._installBeforeModelRequestCompaction(); this._installContextProjection(); this._installLiveContext(); @@ -638,13 +657,16 @@ export class AgentSession { private async _compactBeforeNextAssistantResponse(context: AgentContext): Promise { const model = this.model; - const settings = this.settingsManager.getCompactionSettings(); if ( !model || this._blocksNativeThreshold() || model.contextWindow <= 0 || - !shouldCompact(this._workingContextTokens(context), model.contextWindow, settings) + !shouldCompact( + this._workingContextTokens(context), + model.contextWindow, + this._triggerCompactionSettings(model), + ) ) { return context; } @@ -656,6 +678,102 @@ export class AgentSession { }; } + /** + * Run automatic CLM maintenance or native threshold compaction for the request about to be sent. + * Returns the input context unless native compaction rebuilt canonical state. + */ + private async _compactForRequest(context: AgentContext, signal?: AbortSignal): Promise { + // While paused, skip measurement too: a persistent estimator fault must stop being retried. + if (this._thresholdCompactionPaused()) return context; + const previousCompaction = getLatestCompactionEntry(this.sessionManager.getBranch())?.id; + try { + const automatic = await this._runAutomaticClm(context, signal); + if (!automatic?.attempted) { + const model = this.model; + if ( + model && + model.contextWindow > 0 && + !this._blocksNativeThreshold() && + shouldCompact( + this._workingContextTokens(context), + model.contextWindow, + this._triggerCompactionSettings(model), + ) + ) { + await this._runAutoCompaction("threshold", false); + } + } + } catch (error) { + // Maintenance is optional for this request: send it as-is, and let overflow recovery cover a + // request that does not fit. _runAutoCompaction reports its own failures. + if (!signal?.aborted) this._recordMaintenanceFault(error); + } + // Only native compaction rebuilds canonical state. Otherwise keep the loop context, which may + // exclude parser-resampled responses, while CLM overlays its existing prefix. + if (getLatestCompactionEntry(this.sessionManager.getBranch())?.id === previousCompaction) return context; + return { ...context, messages: this.agent.state.messages.slice() }; + } + + private _installBeforeModelRequestCompaction(): void { + const previous = this.agent.beforeModelRequest; + this.agent.beforeModelRequest = async (request, signal) => { + let context = request.context; + const checked = this._checkedInitialRequest; + this._checkedInitialRequest = undefined; + const alreadyChecked = + request.phase === "initial" && + checked !== undefined && + context.messages.length === checked.messageCount && + context.messages.at(-1) === checked.lastMessage && + // Same model object: a registry refresh replaces it even for the same id (e.g. a new window). + this.model === checked.model; + if (this._usesTriggerPolicyV2() && !alreadyChecked && !signal?.aborted) { + context = await this._compactForRequest(context, signal); + } + const previousUpdate = await previous?.({ ...request, context }, signal); + // A model/thinking-only update from a chained hook must not drop the compacted context. + const nextContext = previousUpdate?.context ?? context; + if (!previousUpdate && nextContext === request.context) return undefined; + return { ...previousUpdate, context: nextContext }; + }; + } + + private _usesTriggerPolicyV2(): boolean { + return this.settingsManager.getCompactionTriggerSettings().policy === "v2"; + } + + /** + * Compaction settings whose `reserveTokens` encodes the active automatic trigger, so + * `shouldCompact()` and CLM decisions fire above `resolveCompactionThreshold()` under v2. + * The summary budget keeps using the configured reserve through `getCompactionSettings()`. + */ + private _triggerCompactionSettings(model: Model): CompactionSettings { + const settings = this.settingsManager.getCompactionSettings(); + const trigger = this.settingsManager.getCompactionTriggerSettings(); + if (trigger.policy !== "v2") return settings; + const threshold = resolveCompactionThreshold(model, trigger); + if (threshold === undefined) return settings; + return { ...settings, reserveTokens: model.contextWindow - threshold }; + } + + /** Automatic threshold maintenance pauses after repeated failures until a compaction succeeds. */ + private _thresholdCompactionPaused(): boolean { + return this._usesTriggerPolicyV2() && this._thresholdCompactionFailures >= AUTO_COMPACTION_FAILURE_LIMIT; + } + + /** + * Record an unexpected throw while measuring or preparing automatic maintenance. It is persisted as a + * compaction failure diagnostic and counts toward the pause, so a persistent fault stops being retried. + */ + private _recordMaintenanceFault(error: unknown): void { + this._thresholdCompactionFailures++; + try { + this._recordCompactionFailure(error, "threshold"); + } catch { + // Callers must not throw; the counted failure above still bounds retries. + } + } + private _installAgentNextTurnRefresh(): void { const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ?? @@ -663,15 +781,18 @@ export class AgentSession { ? async (_turn: PrepareNextTurnContext, signal?: AbortSignal) => await this.agent.prepareNextTurn?.(signal) : undefined); this.agent.prepareNextTurnWithContext = async (turn, signal) => { - const previousCompaction = getLatestCompactionEntry(this.sessionManager.getBranch())?.id; - const automatic = await this._runAutomaticClm(turn.context, signal); let context = turn.context; - if (!automatic?.attempted) { - context = await this._compactBeforeNextAssistantResponse(turn.context); - } else if (getLatestCompactionEntry(this.sessionManager.getBranch())?.id !== previousCompaction) { - // Only native compaction rebuilds canonical state. Keep parser-resampling - // exclusions in the loop context while CLM overlays its existing prefix. - context = { ...turn.context, messages: this.agent.state.messages.slice() }; + // Under v2 the before-request hook owns automatic compaction, after steering is injected. + if (!this._usesTriggerPolicyV2()) { + const previousCompaction = getLatestCompactionEntry(this.sessionManager.getBranch())?.id; + const automatic = await this._runAutomaticClm(turn.context, signal); + if (!automatic?.attempted) { + context = await this._compactBeforeNextAssistantResponse(turn.context); + } else if (getLatestCompactionEntry(this.sessionManager.getBranch())?.id !== previousCompaction) { + // Only native compaction rebuilds canonical state. Keep parser-resampling + // exclusions in the loop context while CLM overlays its existing prefix. + context = { ...turn.context, messages: this.agent.state.messages.slice() }; + } } const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal); const nextContext = previousSnapshot?.context ?? context; @@ -822,12 +943,13 @@ export class AgentSession { this._manualClm || this._autoClmAbortController || this._blocksNativeThreshold() || + this._thresholdCompactionPaused() || signal?.aborted || interrupted() ) return undefined; const tokens = this._workingContextTokens({ ...context, messages: [...context.messages, ...incoming] }); - const native = this.settingsManager.getCompactionSettings(); + const native = this._triggerCompactionSettings(model); if ( tokens < settings.minContextTokens || tokens >= model.contextWindow || @@ -872,6 +994,8 @@ export class AgentSession { this._autoClmAbortController = undefined; } if (!result.attempted) return result; + // An accepted CLM edit is a successful automatic maintenance; it ends a failure streak. + if (result.accepted) this._thresholdCompactionFailures = 0; if (result.outcome) this._emit({ type: "live_context", outcome: result.outcome }); this._emit({ type: "auto_clm_end", result }); if (result.fallback && !signal?.aborted && !interrupted()) { @@ -882,7 +1006,23 @@ export class AgentSession { private _workingContextTokens(context: AgentContext): number { const live = this._getLiveContext(); - if (!live) return estimateContextTokens(context.messages).tokens; + if (!live) { + const estimate = estimateContextTokens(context.messages); + // The legacy trigger policy keeps its original measurement for a faithful rollback. + if (!this._usesTriggerPolicyV2()) return estimate.tokens; + const usageMessage = estimate.lastUsageIndex === null ? undefined : context.messages[estimate.lastUsageIndex]; + const compaction = getLatestCompactionEntry(this.sessionManager.getBranch()); + // Usage reported before the latest compaction measured the old, larger context; + // trusting it would retrigger compaction immediately after one completed. + const staleUsage = + compaction !== null && + usageMessage?.role === "assistant" && + usageMessage.timestamp <= new Date(compaction.timestamp).getTime(); + if (estimate.lastUsageIndex !== null && !staleUsage) return estimate.tokens; + // Without trustworthy usage, estimate everything the provider will receive: provider usage + // includes the system prompt and tool schemas, so a message-only estimate would undercount. + return estimateMessagesTokens(context.messages) + estimateRequestOverheadTokens(context); + } return live.estimate({ systemPrompt: context.systemPrompt + live.guidance(this._liveContextGuidanceOptions()), messages: convertToLlm(live.project(context.messages)), @@ -1253,6 +1393,7 @@ export class AgentSession { this.agent.state.messages, signal ?? this._agentRunAbortController?.signal, ); + if (outcome?.accepted) this._thresholdCompactionFailures = 0; if (outcome) this._emit({ type: "live_context", outcome }); } }; @@ -1720,23 +1861,59 @@ export class AgentSession { messages: this.agent.state.messages, tools: this.agent.state.tools, }; - const automatic = await this._runAutomaticClm(context, runAbortController.signal, incoming); - if ( - !automatic?.attempted && + const triggerV2 = this._usesTriggerPolicyV2(); + // Under v2 a maintenance fault must not reject the prompt (same fail-open contract as the + // before-request hook). It is recorded and counted, and the first request stays unmarked so + // the hook checks it again. Cancellation and the legacy policy keep propagating errors. + let maintenanceFailed = false; + const failOpen = async (step: () => T | Promise): Promise => { + if (!triggerV2) return await step(); + try { + return await step(); + } catch (error) { + if (runAbortController.signal.aborted) throw error; + this._recordMaintenanceFault(error); + maintenanceFailed = true; + return undefined; + } + }; + const automatic = await failOpen(() => this._runAutomaticClm(context, runAbortController.signal, incoming)); + const model = this.model; + // The pre-run check measures the incoming prompt before it is persisted, so CLM can + // protect it. Queued input defers the check to the loop's before-request hook. + const preRunCheck = !runAbortController.signal.aborted && !this.agent.hasQueuedMessages() && this.pendingMessageCount === 0 && - this._getLiveContext() && - this.model && - shouldCompact( - this._workingContextTokens({ ...context, messages: [...context.messages, ...incoming] }), - this.model.contextWindow, - this.settingsManager.getCompactionSettings(), - ) + (triggerV2 || this._getLiveContext() !== undefined); + if ( + !automatic?.attempted && + !maintenanceFailed && + preRunCheck && + model && + model.contextWindow > 0 && + !this._thresholdCompactionPaused() && + (await failOpen(() => + shouldCompact( + this._workingContextTokens({ ...context, messages: [...context.messages, ...incoming] }), + model.contextWindow, + this._triggerCompactionSettings(model), + ), + )) ) { await this._runAutoCompaction("threshold", false); } if (runAbortController.signal.aborted) return; + // Record what was measured. The before-request hook re-checks if the actual first request + // differs, e.g. steering queued during the pre-run compaction or by an agent_start listener. + this._checkedInitialRequest = + triggerV2 && model && !maintenanceFailed && (automatic?.attempted === true || preRunCheck) + ? { + messageCount: this.agent.state.messages.length + incoming.length, + lastMessage: incoming[incoming.length - 1], + model, + } + : undefined; await this.agent.prompt(messages); while ( !runAbortController.signal.aborted && @@ -1746,6 +1923,7 @@ export class AgentSession { await this.agent.continue(); } } finally { + this._checkedInitialRequest = undefined; this._systemPromptOverride = undefined; this._flushPendingBashMessages(); this._flushPendingCustomMessages(); @@ -1799,7 +1977,9 @@ export class AgentSession { return false; } - if (await this._checkCompaction(msg)) { + // Under the v2 trigger policy, threshold compaction waits for the next request's + // before-request check; only overflow and truncation recovery run here. + if (await this._checkCompaction(msg, true, this._usesTriggerPolicyV2())) { return true; } @@ -1912,12 +2092,13 @@ export class AgentSession { // The user's new prompt is sent below, so do not call agent.continue() here. const lastAssistant = this._findLastAssistantMessage(); if (lastAssistant) { - // CLM threshold maintenance needs the incoming prompt assembled below. - // Real overflow recovery still runs immediately. + // Threshold maintenance needs the incoming prompt assembled below: CLM and the v2 + // trigger policy check it in _runAgentPrompt. Real overflow recovery still runs immediately. await this._checkCompaction( lastAssistant, false, - !!this._getLiveContext() && this.settingsManager.getAutoClmSettings().enabled && !this._manualClm, + this._usesTriggerPolicyV2() || + (!!this._getLiveContext() && this.settingsManager.getAutoClmSettings().enabled && !this._manualClm), ); } @@ -2341,6 +2522,8 @@ export class AgentSession { source: "set" | "cycle" | "restore", ): Promise { if (modelsAreEqual(previousModel, nextModel)) return; + // A different model has its own window and summarizer behavior; give automatic compaction a fresh start. + this._thresholdCompactionFailures = 0; await this._extensionRunner.emit({ type: "model_select", model: nextModel, @@ -2734,6 +2917,7 @@ export class AgentSession { } this._liveContext?.invalidate(); this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage); + this._thresholdCompactionFailures = 0; const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; @@ -2976,8 +3160,10 @@ export class AgentSession { const runSignal = this._agentRunAbortController?.signal; if (runSignal?.aborted) return false; if (reason === "threshold" && this._blocksNativeThreshold()) return false; + if (reason === "threshold" && this._thresholdCompactionPaused()) return false; const settings = this.settingsManager.getCompactionSettings(); let started = false; + let committed = false; let fromExtension = false; try { @@ -3099,6 +3285,8 @@ export class AgentSession { } this._liveContext?.invalidate(); this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage); + committed = true; + this._thresholdCompactionFailures = 0; const newEntries = this.sessionManager.getEntries(); const sessionContext = this.sessionManager.buildSessionContext(); this.agent.state.messages = sessionContext.messages; @@ -3149,11 +3337,19 @@ export class AgentSession { if (started) { const aborted = this._autoCompactionAbortController?.signal.aborted ?? false; const failureDetails = aborted ? undefined : this._recordCompactionFailure(error, reason); - const formattedErrorMessage = aborted + let formattedErrorMessage = aborted ? undefined : reason === "overflow" ? `Context overflow recovery failed: ${failureDetails}` : `Auto-compaction failed: ${failureDetails}`; + // A throw after the compaction was persisted (for example from a session_compact + // handler) is not a failed compaction, so it does not count toward the pause. + if (!aborted && !committed && reason === "threshold") { + this._thresholdCompactionFailures++; + if (this._thresholdCompactionPaused()) { + formattedErrorMessage += `\nAutomatic compaction is paused for this session after ${AUTO_COMPACTION_FAILURE_LIMIT} consecutive failures. Run /compact to retry; a successful compaction resumes it.`; + } + } this._emit({ type: "compaction_end", reason, @@ -3290,6 +3486,8 @@ export class AgentSession { } this.agent.state.model = refreshedModel; + // A changed window moves the trigger, like a model switch; give automatic compaction a fresh start. + if (refreshedModel.contextWindow !== currentModel.contextWindow) this._thresholdCompactionFailures = 0; } private _bindExtensionCore(runner: ExtensionRunner): void { diff --git a/packages/coding-agent/src/core/compaction/index.ts b/packages/coding-agent/src/core/compaction/index.ts index 690555a98..66241fadb 100644 --- a/packages/coding-agent/src/core/compaction/index.ts +++ b/packages/coding-agent/src/core/compaction/index.ts @@ -5,4 +5,5 @@ export * from "./branch-summarization.ts"; export * from "./compaction.ts"; export * from "./projection.ts"; +export * from "./trigger-policy.ts"; export * from "./utils.ts"; diff --git a/packages/coding-agent/src/core/compaction/trigger-policy.ts b/packages/coding-agent/src/core/compaction/trigger-policy.ts new file mode 100644 index 000000000..79adda5b9 --- /dev/null +++ b/packages/coding-agent/src/core/compaction/trigger-policy.ts @@ -0,0 +1,136 @@ +/** + * Automatic compaction trigger policy. + * + * The threshold is a fraction of the model's context window. No absolute + * output reserve or buffer is subtracted: providers already clamp each + * request's `max_tokens` to the remaining context, and a response truncated + * by that clamp is recovered by the overflow/length path. The remaining + * fraction absorbs token-estimation error and scales with the window. + */ + +export type CompactionTriggerPolicy = "v2" | "legacy"; + +export const DEFAULT_COMPACTION_TRIGGER_RATIO = 0.9; +export const MIN_COMPACTION_TRIGGER_RATIO = 0.5; +export const MAX_COMPACTION_TRIGGER_RATIO = 0.95; + +export interface CompactionTriggerSettings { + policy: CompactionTriggerPolicy; + /** Global fraction of the context window that triggers compaction. */ + triggerRatio: number; + /** Per-model overrides keyed by `provider/id`. */ + modelTriggerRatios: Readonly>; + /** Absolute ceiling that can only make compaction trigger earlier. */ + tokenLimit?: number; + /** Explicitly configured legacy reserve; caps the trigger at `contextWindow - reserveTokens` when smaller than the window. */ + legacyReserveTokens?: number; +} + +export interface CompactionTriggerModel { + provider: string; + id: string; + contextWindow: number; +} + +/** Raw configuration fields read from the `compaction` settings object. */ +export interface RawCompactionTriggerSettings { + triggerPolicy?: unknown; + triggerRatio?: unknown; + modelTriggerRatios?: unknown; + tokenLimit?: unknown; + reserveTokens?: unknown; +} + +export interface ResolvedCompactionTriggerSettings { + settings: CompactionTriggerSettings; + /** Human-readable descriptions of ignored values. */ + warnings: string[]; +} + +function isValidRatio(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + value >= MIN_COMPACTION_TRIGGER_RATIO && + value <= MAX_COMPACTION_TRIGGER_RATIO + ); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +const RATIO_RANGE = `${MIN_COMPACTION_TRIGGER_RATIO}-${MAX_COMPACTION_TRIGGER_RATIO}`; + +/** Validate trigger configuration; invalid values fall back to defaults and are reported. */ +export function resolveCompactionTriggerSettings( + raw: RawCompactionTriggerSettings = {}, +): ResolvedCompactionTriggerSettings { + const warnings: string[] = []; + + let policy: CompactionTriggerPolicy = "v2"; + if (raw.triggerPolicy === "legacy" || raw.triggerPolicy === "v2") policy = raw.triggerPolicy; + else if (raw.triggerPolicy !== undefined) + warnings.push(`compaction.triggerPolicy must be "v2" or "legacy"; using "v2".`); + + let triggerRatio = DEFAULT_COMPACTION_TRIGGER_RATIO; + if (isValidRatio(raw.triggerRatio)) triggerRatio = raw.triggerRatio; + else if (raw.triggerRatio !== undefined) + warnings.push( + `compaction.triggerRatio must be a number in ${RATIO_RANGE}; using ${DEFAULT_COMPACTION_TRIGGER_RATIO}.`, + ); + + const modelTriggerRatios: Record = {}; + if (raw.modelTriggerRatios !== undefined) { + if ( + typeof raw.modelTriggerRatios !== "object" || + raw.modelTriggerRatios === null || + Array.isArray(raw.modelTriggerRatios) + ) { + warnings.push("compaction.modelTriggerRatios must be a table keyed by provider/model; ignoring it."); + } else { + for (const [key, value] of Object.entries(raw.modelTriggerRatios)) { + if (isValidRatio(value)) modelTriggerRatios[key] = value; + else + warnings.push( + `compaction.modelTriggerRatios["${key}"] must be a number in ${RATIO_RANGE}; ignoring it.`, + ); + } + } + } + + let tokenLimit: number | undefined; + if (isPositiveSafeInteger(raw.tokenLimit)) tokenLimit = raw.tokenLimit; + else if (raw.tokenLimit !== undefined) + warnings.push("compaction.tokenLimit must be a positive integer; ignoring it."); + + // reserveTokens is validated by its existing owner; only a usable explicit value participates here. + const legacyReserveTokens = isPositiveSafeInteger(raw.reserveTokens) ? raw.reserveTokens : undefined; + + return { settings: { policy, triggerRatio, modelTriggerRatios, tokenLimit, legacyReserveTokens }, warnings }; +} + +/** Trigger ratio that applies to `model`, honoring per-model overrides. */ +export function resolveTriggerRatio( + model: Pick, + settings: CompactionTriggerSettings, +): number { + return settings.modelTriggerRatios[`${model.provider}/${model.id}`] ?? settings.triggerRatio; +} + +/** + * Token count above which automatic compaction runs for `model`. + * Returns undefined when the context window is unknown. + */ +export function resolveCompactionThreshold( + model: CompactionTriggerModel, + settings: CompactionTriggerSettings, +): number | undefined { + const contextWindow = model.contextWindow; + if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined; + let threshold = Math.floor(contextWindow * resolveTriggerRatio(model, settings)); + if (settings.tokenLimit !== undefined) threshold = Math.min(threshold, settings.tokenLimit); + if (settings.legacyReserveTokens !== undefined && settings.legacyReserveTokens < contextWindow) + threshold = Math.min(threshold, contextWindow - settings.legacyReserveTokens); + return Math.max(0, threshold); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index b63850cbe..16fe61bb2 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -11,12 +11,29 @@ import { normalizePath, resolvePath } from "../utils/paths.ts"; import { stripBom } from "../utils/text.ts"; import { type AutoClmSettings, resolveAutoClmSettings } from "./compaction/live-context/auto-options.ts"; import type { ContextProjectionMode } from "./compaction/projection.ts"; +import { + type CompactionTriggerPolicy, + type CompactionTriggerSettings, + resolveCompactionTriggerSettings, +} from "./compaction/trigger-policy.ts"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS, parseHttpIdleTimeoutMs } from "./http-dispatcher.ts"; export interface CompactionSettings { enabled?: boolean; // default: true + /** + * Summary output budget. When set explicitly it also caps the automatic trigger at + * `contextWindow - reserveTokens` (legacy behavior); the default does not affect the trigger. + */ reserveTokens?: number; // default: 16384 keepRecentTokens?: number; // default: 20000 + /** `v2` (default): one pre-request check at `contextWindow * triggerRatio`. `legacy`: previous checkpoints and threshold. */ + triggerPolicy?: CompactionTriggerPolicy; + /** Fraction of the context window that triggers automatic compaction (0.5-0.95, default 0.9). */ + triggerRatio?: number; + /** Per-model trigger ratios keyed by `provider/id`. */ + modelTriggerRatios?: Record; + /** Absolute token ceiling that can only make automatic compaction trigger earlier. */ + tokenLimit?: number; /** Projection mode: clm-v1 by default when automatic compaction is enabled. */ contextProjection?: ContextProjectionMode; /** Host-triggered maintenance inside clm-v1; native compaction remains the fallback. */ @@ -404,6 +421,10 @@ export class SettingsManager { if (projectLoad.error) { initialErrors.push(toSettingsError("project", projectLoad.error, settingsPaths.project)); } + for (const warning of globalLoad.warnings) + initialErrors.push(toSettingsError("global", warning, settingsPaths.global)); + for (const warning of projectLoad.warnings) + initialErrors.push(toSettingsError("project", warning, settingsPaths.project)); return new SettingsManager( storage, @@ -447,11 +468,16 @@ export class SettingsManager { storage: SettingsStorage, scope: SettingsScope, projectTrusted = true, - ): { settings: Settings; error: Error | null } { + ): { settings: Settings; error: Error | null; warnings: Error[] } { try { - return { settings: SettingsManager.loadFromStorage(storage, scope, projectTrusted), error: null }; + const settings = SettingsManager.loadFromStorage(storage, scope, projectTrusted); + // Invalid trigger values fall back to defaults at read time; report them once per load. + const warnings = resolveCompactionTriggerSettings(settings.compaction ?? {}).warnings.map( + (warning) => new Error(warning), + ); + return { settings, error: null, warnings }; } catch (error) { - return { settings: {}, error: error as Error }; + return { settings: {}, error: error as Error, warnings: [] }; } } @@ -551,6 +577,7 @@ export class SettingsManager { if (projectLoad.error) { this.recordError("project", projectLoad.error); } + for (const warning of projectLoad.warnings) this.recordError("project", warning); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -564,6 +591,7 @@ export class SettingsManager { this.globalSettingsLoadError = globalLoad.error; this.recordError("global", globalLoad.error); } + for (const warning of globalLoad.warnings) this.recordError("global", warning); this.modifiedFields.clear(); this.modifiedNestedFields.clear(); @@ -578,6 +606,7 @@ export class SettingsManager { this.projectSettingsLoadError = projectLoad.error; this.recordError("project", projectLoad.error); } + for (const warning of projectLoad.warnings) this.recordError("project", warning); this.settings = deepMergeSettings(this.globalSettings, this.projectSettings); } @@ -941,6 +970,11 @@ export class SettingsManager { }; } + /** Automatic compaction trigger configuration; invalid values were reported when settings loaded. */ + getCompactionTriggerSettings(): CompactionTriggerSettings { + return resolveCompactionTriggerSettings(this.settings.compaction ?? {}).settings; + } + getBranchSummarySettings(): { reserveTokens: number; skipPrompt: boolean } { return { reserveTokens: this.settings.branchSummary?.reserveTokens ?? 16384, diff --git a/packages/coding-agent/test/compaction-trigger-policy.test.ts b/packages/coding-agent/test/compaction-trigger-policy.test.ts new file mode 100644 index 000000000..7e1d1d6fd --- /dev/null +++ b/packages/coding-agent/test/compaction-trigger-policy.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMPACTION_TRIGGER_RATIO, + resolveCompactionThreshold, + resolveCompactionTriggerSettings, +} from "../src/core/compaction/trigger-policy.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +const model = (contextWindow: number, id = "m") => ({ provider: "p", id, contextWindow }); + +describe("compaction trigger policy", () => { + it.each([ + [64_000, 57_600], + [128_000, 115_200], + [200_000, 180_000], + [1_000_000, 900_000], + ])("triggers at 90%% of a %i-token window by default", (contextWindow, threshold) => { + const { settings, warnings } = resolveCompactionTriggerSettings(); + expect(warnings).toEqual([]); + expect(settings.policy).toBe("v2"); + expect(settings.triggerRatio).toBe(DEFAULT_COMPACTION_TRIGGER_RATIO); + expect(resolveCompactionThreshold(model(contextWindow), settings)).toBe(threshold); + }); + + it("applies global and per-model ratios", () => { + const { settings } = resolveCompactionTriggerSettings({ + triggerRatio: 0.8, + modelTriggerRatios: { "p/small": 0.7 }, + }); + expect(resolveCompactionThreshold(model(100_000), settings)).toBe(80_000); + expect(resolveCompactionThreshold(model(100_000, "small"), settings)).toBe(70_000); + }); + + it("lets tokenLimit and an explicit legacy reserve only trigger earlier", () => { + expect( + resolveCompactionThreshold(model(200_000), resolveCompactionTriggerSettings({ tokenLimit: 150_000 }).settings), + ).toBe(150_000); + expect( + resolveCompactionThreshold(model(200_000), resolveCompactionTriggerSettings({ tokenLimit: 500_000 }).settings), + ).toBe(180_000); + expect( + resolveCompactionThreshold( + model(1_048_576), + resolveCompactionTriggerSettings({ reserveTokens: 851_968 }).settings, + ), + ).toBe(196_608); + expect( + resolveCompactionThreshold( + model(200_000), + resolveCompactionTriggerSettings({ reserveTokens: 8_192 }).settings, + ), + ).toBe(180_000); + // A reserve that leaves no room is ignored rather than triggering on every request. + for (const reserveTokens of [200_000, 250_000]) { + expect( + resolveCompactionThreshold(model(200_000), resolveCompactionTriggerSettings({ reserveTokens }).settings), + ).toBe(180_000); + } + }); + + it("returns no threshold for an unknown window", () => { + expect(resolveCompactionThreshold(model(0), resolveCompactionTriggerSettings().settings)).toBeUndefined(); + }); + + it("falls back to defaults and reports invalid values", () => { + const { settings, warnings } = resolveCompactionTriggerSettings({ + triggerPolicy: "eager", + triggerRatio: 0.99, + modelTriggerRatios: { "p/a": 0.2, "p/b": 0.6 }, + tokenLimit: -1, + }); + expect(settings).toEqual({ + policy: "v2", + triggerRatio: DEFAULT_COMPACTION_TRIGGER_RATIO, + modelTriggerRatios: { "p/b": 0.6 }, + tokenLimit: undefined, + legacyReserveTokens: undefined, + }); + expect(warnings).toHaveLength(4); + expect(resolveCompactionTriggerSettings({ modelTriggerRatios: [0.8] }).warnings).toHaveLength(1); + }); + + it("reports invalid trigger settings as settings diagnostics and accepts the legacy policy", () => { + const invalid = SettingsManager.inMemory({ compaction: { triggerRatio: 2 } }); + expect(invalid.getCompactionTriggerSettings().triggerRatio).toBe(DEFAULT_COMPACTION_TRIGGER_RATIO); + expect(invalid.drainErrors().map((error) => error.error.message)).toEqual([ + expect.stringContaining("compaction.triggerRatio"), + ]); + + const legacy = SettingsManager.inMemory({ compaction: { triggerPolicy: "legacy" } }); + expect(legacy.getCompactionTriggerSettings().policy).toBe("legacy"); + expect(legacy.drainErrors()).toEqual([]); + }); +}); diff --git a/packages/coding-agent/test/suite/agent-session-auto-clm.test.ts b/packages/coding-agent/test/suite/agent-session-auto-clm.test.ts index 1ccd66541..904b264d7 100644 --- a/packages/coding-agent/test/suite/agent-session-auto-clm.test.ts +++ b/packages/coding-agent/test/suite/agent-session-auto-clm.test.ts @@ -40,6 +40,7 @@ async function setup( native?: boolean; alignNativeThreshold?: boolean; contextWindow?: number; + triggerPolicy?: "v2" | "legacy"; tools?: AgentTool[]; extensions?: HarnessOptions["extensionFactories"]; } = {}, @@ -49,6 +50,7 @@ async function setup( settings: { compaction: { ...(options.mode === undefined ? {} : { contextProjection: options.mode }), + ...(options.triggerPolicy === undefined ? {} : { triggerPolicy: options.triggerPolicy }), enabled: options.native ?? true, reserveTokens: options.contextWindow ? 2048 : 8192, keepRecentTokens: options.contextWindow ? 4000 : 20000, @@ -126,6 +128,15 @@ describe("automatic CLM maintenance", () => { expect(JSON.stringify(taskRequest?.messages)).toContain("NEW REQUIREMENT"); expect(JSON.stringify(taskRequest?.messages)).toContain("csv.reader"); }); + it("treats an accepted automatic CLM edit as a success that ends a native failure streak", async () => { + const h = await setup({ alignNativeThreshold: true }); + seed(h, "old completed observation\n".repeat(8800)); + (h.session as unknown as { _thresholdCompactionFailures: number })._thresholdCompactionFailures = 2; + h.setResponses([(context) => automaticEdit(context), fauxAssistantMessage("done")]); + await h.session.prompt("NEW REQUIREMENT: preserve output order"); + expect(h.session.getLiveContextStatus()!.revision).toBe(1); + expect((h.session as unknown as { _thresholdCompactionFailures: number })._thresholdCompactionFailures).toBe(0); + }); it("uses the same native threshold after a complete parallel tool turn", async () => { let completed = 0; const bulk: AgentTool = { @@ -210,8 +221,8 @@ describe("automatic CLM maintenance", () => { expect(h.getPendingResponseCount()).toBe(0); }); - it("retains the existing eager native behavior when CLM is off", async () => { - const h = await setup({ alignNativeThreshold: true, mode: "off" }); + it("retains the existing eager native behavior when CLM is off under the legacy trigger policy", async () => { + const h = await setup({ alignNativeThreshold: true, mode: "off", triggerPolicy: "legacy" }); h.setResponses([ fauxAssistantMessage("completed task output ".repeat(2900)), fauxAssistantMessage("## Goal\nTask completed. Preserve its result."), @@ -221,6 +232,23 @@ describe("automatic CLM maintenance", () => { expect(h.eventsOfType("compaction_start")).toHaveLength(1); expect(h.getPendingResponseCount()).toBe(0); }); + it("defers native threshold compaction to the next request when CLM is off", async () => { + const h = await setup({ alignNativeThreshold: true, mode: "off" }); + h.setResponses([fauxAssistantMessage("completed task output ".repeat(2900))]); + await h.session.prompt("complete this step without CLM"); + expect(h.eventsOfType("compaction_start")).toHaveLength(0); + h.setResponses([ + fauxAssistantMessage("## Goal\nTask completed. Preserve its result."), + (context) => { + expect(JSON.stringify(context.messages)).toContain("NEXT: continue from the summary"); + return fauxAssistantMessage("continued"); + }, + ]); + await h.session.prompt("NEXT: continue from the summary"); + expect(h.eventsOfType("compaction_start")).toEqual([{ type: "compaction_start", reason: "threshold" }]); + expect(h.eventsOfType("auto_clm_start")).toHaveLength(0); + expect(h.getPendingResponseCount()).toBe(0); + }); it("runs before an ordinary user prompt with no manual compact or model reminder", async () => { const h = await setup(); let automaticRequests = 0; diff --git a/packages/coding-agent/test/suite/agent-session-compaction-trigger.test.ts b/packages/coding-agent/test/suite/agent-session-compaction-trigger.test.ts new file mode 100644 index 000000000..d7a10b9cc --- /dev/null +++ b/packages/coding-agent/test/suite/agent-session-compaction-trigger.test.ts @@ -0,0 +1,329 @@ +import type { AgentMessage, AgentTool } from "@step-harness/agent-core"; +import { type Context, fauxAssistantMessage, fauxToolCall } from "@step-harness/providers"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SUMMARIZATION_SYSTEM_PROMPT } from "../../src/core/compaction/utils.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const SUMMARY = "## Goal\nTask in progress. Preserve the bulk evidence result."; + +const harnesses: Harness[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const h of harnesses.splice(0)) h.cleanup(); +}); + +async function setup( + options: { + tools?: AgentTool[]; + triggerRatio?: number; + contextWindow?: number; + keepRecentTokens?: number; + contextProjection?: "off" | "clm-v1"; + } = {}, +): Promise { + const h = await createHarness({ + models: [{ id: "trigger-model", contextWindow: options.contextWindow ?? 16_000, maxTokens: 2_048 }], + settings: { + compaction: { + contextProjection: options.contextProjection ?? "off", + keepRecentTokens: options.keepRecentTokens ?? 1, + ...(options.triggerRatio === undefined ? {} : { triggerRatio: options.triggerRatio }), + }, + retry: { enabled: false }, + }, + tools: options.tools ?? [], + }); + harnesses.push(h); + return h; +} + +function seed(h: Harness, text: string): void { + const messages: AgentMessage[] = [ + { role: "user", content: "Investigate the parser", timestamp: 1 }, + fauxAssistantMessage(text, { timestamp: 2 }), + { role: "user", content: "Investigation complete", timestamp: 3 }, + fauxAssistantMessage("Ready for the next step", { timestamp: 4 }), + ]; + for (const message of messages) + h.sessionManager.appendMessage(message as Parameters[0]); + h.session.agent.state.messages = h.sessionManager.buildSessionContext().messages; +} + +describe("compaction trigger policy v2", () => { + it("compacts before the follow-up request once tool results cross the threshold", async () => { + const bulk: AgentTool = { + name: "bulk", + label: "Bulk", + description: "Collect evidence", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `${"bulk evidence ".repeat(7)}\n`.repeat(100) }], + details: {}, + }), + }; + // Threshold 10K: the ~8K seed plus prompt stays below it until the ~2.5K tool result arrives. + const h = await setup({ tools: [bulk], contextWindow: 20_000, triggerRatio: 0.5, keepRecentTokens: 3_000 }); + seed(h, "prior observation ".repeat(1_800)); + let finalContext: Context | undefined; + h.setResponses([ + fauxAssistantMessage(fauxToolCall("bulk", {}), { stopReason: "toolUse" }), + fauxAssistantMessage(SUMMARY), + (context) => { + finalContext = context; + return fauxAssistantMessage("final answer"); + }, + ]); + + await h.session.prompt("run the bulk check"); + + expect(h.eventsOfType("compaction_start")).toEqual([{ type: "compaction_start", reason: "threshold" }]); + expect(h.getPendingResponseCount()).toBe(0); + expect(JSON.stringify(finalContext?.messages)).toContain("Preserve the bulk evidence result"); + expect(h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it("does not compact after a final answer, even above the threshold", async () => { + const h = await setup(); + seed(h, "short prior answer"); + h.setResponses([fauxAssistantMessage("large final answer ".repeat(4_000))]); + + await h.session.prompt("write a long answer"); + + expect(h.eventsOfType("compaction_start")).toHaveLength(0); + expect(h.getPendingResponseCount()).toBe(0); + }); + + it("honors a configured trigger ratio", async () => { + const h = await setup({ triggerRatio: 0.5 }); + // About 9.5K estimated tokens: below the 90% default (14.4K) but above 50% (8K). + seed(h, "prior observation ".repeat(2_100)); + h.setResponses([fauxAssistantMessage(SUMMARY), fauxAssistantMessage("answer")]); + + await h.session.prompt("continue"); + + expect(h.eventsOfType("compaction_start")).toEqual([{ type: "compaction_start", reason: "threshold" }]); + expect(h.getPendingResponseCount()).toBe(0); + }); + + it("pauses automatic compaction after three consecutive failures", async () => { + const h = await setup(); + // About 14.9K estimated tokens: above the 14.4K threshold, below the 16K window. + seed(h, "prior observation ".repeat(3_300)); + const failing = vi + .spyOn(h.session as unknown as { _runDefaultCompaction: () => Promise }, "_runDefaultCompaction") + .mockRejectedValue(new Error("summary model unavailable")); + + for (let attempt = 0; attempt < 4; attempt++) { + h.setResponses([fauxAssistantMessage(`answer ${attempt}`)]); + await h.session.prompt(`prompt ${attempt}`); + } + + const ends = h.eventsOfType("compaction_end"); + expect(failing).toHaveBeenCalledTimes(3); + expect(ends).toHaveLength(3); + expect(ends.map((event) => event.errorMessage?.includes("paused"))).toEqual([false, false, true]); + }); + + it("resumes automatic compaction after a successful manual compaction", async () => { + const h = await setup(); + seed(h, "prior observation ".repeat(3_300)); + const internals = h.session as unknown as { _runDefaultCompaction: (...args: unknown[]) => Promise }; + const original = internals._runDefaultCompaction.bind(h.session); + const failing = vi + .spyOn(internals, "_runDefaultCompaction") + .mockRejectedValue(new Error("summary model unavailable")); + for (let attempt = 0; attempt < 3; attempt++) { + h.setResponses([fauxAssistantMessage(`answer ${attempt}`)]); + await h.session.prompt(`prompt ${attempt}`); + } + expect(h.eventsOfType("compaction_end").at(-1)?.errorMessage).toContain("paused"); + + failing.mockImplementation(original); + // History summary plus split-turn prefix summary. + h.setResponses([fauxAssistantMessage(SUMMARY), fauxAssistantMessage(SUMMARY)]); + await h.session.compact(); + seed(h, "prior observation ".repeat(3_300)); + h.setResponses([fauxAssistantMessage(SUMMARY), fauxAssistantMessage("answer after resume")]); + await h.session.prompt("prompt after manual compaction"); + + expect(h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual([ + "threshold", + "threshold", + "threshold", + "manual", + "threshold", + ]); + expect(h.getPendingResponseCount()).toBe(0); + }); + + it("does not count a failure raised after the compaction was saved", async () => { + const h = await setup(); + const appendCompaction = h.sessionManager.appendCompaction.bind(h.sessionManager); + let failNextRebuild = false; + vi.spyOn(h.sessionManager, "appendCompaction").mockImplementation((...args) => { + const id = appendCompaction(...args); + failNextRebuild = true; + return id; + }); + const buildSessionContext = h.sessionManager.buildSessionContext.bind(h.sessionManager); + vi.spyOn(h.sessionManager, "buildSessionContext").mockImplementation(() => { + if (failNextRebuild) { + failNextRebuild = false; + // Simulate a failure after the compaction entry is persisted. + throw new Error("post-commit failure"); + } + return buildSessionContext(); + }); + for (let attempt = 0; attempt < 4; attempt++) { + seed(h, "prior observation ".repeat(3_300)); + // Two summaries (history + split-turn prefix) per compaction, then the answer. + h.setResponses([ + fauxAssistantMessage(SUMMARY), + fauxAssistantMessage(SUMMARY), + fauxAssistantMessage(`answer ${attempt}`), + ]); + await h.session.prompt(`prompt ${attempt}`); + } + const ends = h.eventsOfType("compaction_end"); + expect(ends.map((event) => event.errorMessage === undefined)).toEqual([false, false, false, false]); + expect(ends.some((event) => event.errorMessage?.includes("paused"))).toBe(false); + // Each attempt persisted a compaction, so none of them may count as a failure. + expect((h.session as unknown as { _thresholdCompactionFailures: number })._thresholdCompactionFailures).toBe(0); + }); + + it("keeps the compacted context when a chained request hook returns only a thinking update", async () => { + const bulk: AgentTool = { + name: "bulk", + label: "Bulk", + description: "Collect evidence", + parameters: Type.Object({}), + execute: async () => ({ + content: [{ type: "text", text: `${"bulk evidence ".repeat(7)}\n`.repeat(100) }], + details: {}, + }), + }; + const h = await setup({ tools: [bulk], contextWindow: 20_000, triggerRatio: 0.5, keepRecentTokens: 3_000 }); + // A hook installed before the session's wrapper that never returns a context. + h.session.agent.beforeModelRequest = async () => ({ thinkingLevel: "off" }); + (h.session as unknown as { _installBeforeModelRequestCompaction(): void })._installBeforeModelRequestCompaction(); + seed(h, "prior observation ".repeat(1_800)); + let finalContext: Context | undefined; + h.setResponses([ + fauxAssistantMessage(fauxToolCall("bulk", {}), { stopReason: "toolUse" }), + fauxAssistantMessage(SUMMARY), + (context) => { + finalContext = context; + return fauxAssistantMessage("final answer"); + }, + ]); + await h.session.prompt("run the bulk check"); + expect(h.eventsOfType("compaction_start")).toHaveLength(1); + // The pre-compaction loop context has no summary; seeing it proves the compacted context was sent. + expect(JSON.stringify(finalContext?.messages)).toContain("Preserve the bulk evidence result"); + }); + + it("checks the first request again when steering arrives during the pre-run compaction", async () => { + const h = await setup({ contextWindow: 20_000, triggerRatio: 0.5, keepRecentTokens: 3_000 }); + seed(h, "prior observation ".repeat(2_400)); + const requestChecks: string[] = []; + const previous = h.session.agent.beforeModelRequest; + h.session.agent.beforeModelRequest = async (request, signal) => { + requestChecks.push(request.phase); + return previous?.(request, signal); + }; + let steered = false; + h.setResponses([ + async () => { + // Summary request for the pre-run compaction: queue large steering while it runs. + if (!steered) { + steered = true; + await h.session.prompt(`STEER ${"large pasted input ".repeat(2_400)}`, { streamingBehavior: "steer" }); + } + return fauxAssistantMessage(SUMMARY); + }, + fauxAssistantMessage(SUMMARY), + fauxAssistantMessage("answer"), + ]); + await h.session.prompt("start"); + expect(requestChecks[0]).toBe("initial"); + // Pre-run compaction, then a second compaction for the request that now carries the steering. + expect(h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold", "threshold"]); + expect(h.getPendingResponseCount()).toBe(0); + }); + + it("checks the first request again when an agent_start listener queues steering", async () => { + const h = await setup({ contextWindow: 20_000, triggerRatio: 0.5, keepRecentTokens: 3_000 }); + // ~6K tokens: below the 10K threshold during the pre-run check. + seed(h, "prior observation ".repeat(1_300)); + let steered = false; + h.session.subscribe((event) => { + if (event.type === "agent_start" && !steered) { + steered = true; + void h.session.prompt(`STEER ${"large pasted input ".repeat(1_700)}`, { streamingBehavior: "steer" }); + } + }); + let firstTaskContext: Context | undefined; + const respond = (context: Context) => { + if (context.systemPrompt === SUMMARIZATION_SYSTEM_PROMPT) return fauxAssistantMessage(SUMMARY); + firstTaskContext ??= context; + return fauxAssistantMessage("answer"); + }; + h.setResponses([respond, respond, respond]); + await h.session.prompt("start"); + expect(steered).toBe(true); + expect(h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + expect(JSON.stringify(firstTaskContext?.messages)).toContain("STEER"); + expect(h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it("re-measures the first request when an agent_start listener refreshes the model to a smaller window", async () => { + const h = await setup({ contextWindow: 40_000, triggerRatio: 0.5, keepRecentTokens: 3_000 }); + // ~15K tokens: below 20K (50% of 40K) during the pre-run check, above 10K (50% of 20K) afterwards. + seed(h, "prior observation ".repeat(3_300)); + h.session.subscribe((event) => { + if (event.type === "agent_start") { + // Same provider/id, new object with a smaller window, as a registry refresh produces. + h.session.agent.state.model = { ...h.session.agent.state.model, contextWindow: 20_000 }; + } + }); + let taskContext: Context | undefined; + const respond = (context: Context) => { + if (context.systemPrompt === SUMMARIZATION_SYSTEM_PROMPT) return fauxAssistantMessage(SUMMARY); + taskContext ??= context; + return fauxAssistantMessage("answer"); + }; + h.setResponses([respond, respond, respond]); + await h.session.prompt("start"); + expect(h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + expect(JSON.stringify(taskContext?.messages)).toContain("Preserve the bulk evidence result"); + expect(h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it.each([ + ["native", "off"], + ["default CLM", "clm-v1"], + ] as const)( + "fails open on %s measurement faults and stops measuring after three", + async (_label, contextProjection) => { + const h = await setup({ contextProjection }); + seed(h, "prior observation ".repeat(1_000)); + const measure = vi + .spyOn(h.session as unknown as { _workingContextTokens: () => number }, "_workingContextTokens") + .mockImplementation(() => { + throw new Error("estimator fault"); + }); + for (let attempt = 0; attempt < 5; attempt++) { + h.setResponses([fauxAssistantMessage(`answer ${attempt}`)]); + await h.session.prompt(`prompt ${attempt}`); + expect(h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + } + expect(measure).toHaveBeenCalledTimes(3); + expect(h.eventsOfType("compaction_start")).toHaveLength(0); + const failures = h.sessionManager + .getEntries() + .filter((entry) => entry.type === "custom" && entry.customType === "step-compaction-failure"); + expect(failures).toHaveLength(3); + }, + ); +}); diff --git a/packages/coding-agent/test/suite/compaction-trigger-simulation.test.ts b/packages/coding-agent/test/suite/compaction-trigger-simulation.test.ts new file mode 100644 index 000000000..769f4dda0 --- /dev/null +++ b/packages/coding-agent/test/suite/compaction-trigger-simulation.test.ts @@ -0,0 +1,351 @@ +/** + * Scenario simulations for the v2 compaction trigger policy. + * + * Each case drives a full AgentSession through a realistic multi-step coding flow with a scripted + * faux model: the model answers task requests from a script and answers summarization requests + * with a summary, so scenarios do not depend on how many summary calls a compaction needs. + * No real provider is contacted. + */ +import type { AgentMessage, AgentTool } from "@step-harness/agent-core"; +import { type Context, fauxAssistantMessage, fauxToolCall, type Model } from "@step-harness/providers"; +import { Type } from "typebox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { estimateTokens } from "../../src/core/compaction/index.ts"; +import { SUMMARIZATION_SYSTEM_PROMPT } from "../../src/core/compaction/utils.ts"; +import { createHarness, type Harness } from "./harness.ts"; + +const SUMMARY = "## User Goal\nFix the CSV parser.\n\n## Current State\n### Done\n- Read the parser and logs."; + +type TaskStep = ( + context: Context, +) => ReturnType | Promise>; + +interface Simulation { + h: Harness; + /** Every model request, in order, classified as task or summary. */ + requests: Array<{ kind: "task" | "summary"; tokens: number; context: Context }>; + taskRequests(): Context[]; + script(steps: TaskStep[]): void; + pending(): number; +} + +const harnesses: Harness[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const h of harnesses.splice(0)) h.cleanup(); +}); + +function isSummaryRequest(context: Context): boolean { + return context.systemPrompt === SUMMARIZATION_SYSTEM_PROMPT; +} + +/** Estimated size of everything the provider receives: messages, system prompt and tool schemas. */ +function requestTokens(context: Context): number { + const messages = context.messages.reduce((total, message) => total + estimateTokens(message as AgentMessage), 0); + return messages + Math.ceil(((context.systemPrompt ?? "").length + JSON.stringify(context.tools ?? []).length) / 4); +} + +function text(context: Context): string { + return JSON.stringify(context.messages); +} + +async function simulate(options: { + contextWindow: number; + maxTokens?: number; + tools?: AgentTool[]; + compaction?: Record; + models?: Array<{ id: string; contextWindow: number; maxTokens?: number }>; +}): Promise { + const h = await createHarness({ + models: options.models ?? [ + { id: "sim-model", contextWindow: options.contextWindow, maxTokens: options.maxTokens ?? 4096 }, + ], + settings: { + compaction: { contextProjection: "off", keepRecentTokens: 8_000, ...options.compaction }, + retry: { enabled: false }, + }, + tools: options.tools ?? [], + }); + harnesses.push(h); + const requests: Simulation["requests"] = []; + let steps: TaskStep[] = []; + const respond = async (context: Context) => { + if (isSummaryRequest(context)) { + requests.push({ kind: "summary", tokens: requestTokens(context), context }); + return fauxAssistantMessage(SUMMARY); + } + requests.push({ kind: "task", tokens: requestTokens(context), context }); + const step = steps.shift(); + if (!step) throw new Error("scenario script exhausted"); + return step(context); + }; + // A generous pool of responders; each consults the shared script. + h.setResponses(Array.from({ length: 200 }, () => respond)); + const sim: Simulation = { + h, + requests, + taskRequests: () => requests.filter((request) => request.kind === "task").map((request) => request.context), + script: (next) => { + steps = [...next]; + }, + pending: () => steps.length, + }; + return sim; +} + +function tool(name: string, output: () => string): AgentTool { + return { + name, + label: name, + description: `${name} tool`, + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: output() }], details: {} }), + }; +} + +const reply = + (message: string): TaskStep => + () => + fauxAssistantMessage(message); +const callTool = + (name: string): TaskStep => + () => + fauxAssistantMessage(fauxToolCall(name, {}), { stopReason: "toolUse" }); +/** About `tokens` estimated tokens of tool output (chars/4). */ +const output = (label: string, tokens: number) => `${label}: ${"x".repeat(tokens * 4 - label.length - 2)}`; + +describe("compaction trigger simulations (faux provider)", () => { + it("long tool-heavy task: compacts mid-loop at 90% and every request stays inside the window", async () => { + // 32K window -> threshold 28.8K. Each read returns ~6K tokens; the task needs 8 reads. + const reads = { count: 0 }; + const sim = await simulate({ + contextWindow: 32_000, + tools: [tool("read_file", () => output(`file-${++reads.count}`, 6_000))], + }); + sim.script([ + ...Array.from({ length: 8 }, () => callTool("read_file")), + reply("The parser bug is in the quote handling; fix applied."), + ]); + + await sim.h.session.prompt("Read the eight parser modules and find the CSV quoting bug."); + + expect(sim.pending()).toBe(0); + expect(reads.count).toBe(8); + const compactions = sim.h.eventsOfType("compaction_start").map((event) => event.reason); + expect(compactions.length).toBeGreaterThanOrEqual(1); + expect(compactions.every((reason) => reason === "threshold")).toBe(true); + expect(sim.h.eventsOfType("compaction_end").every((event) => event.errorMessage === undefined)).toBe(true); + // No task request exceeded the window, and none needed overflow recovery. + for (const request of sim.requests.filter((entry) => entry.kind === "task")) { + expect(request.tokens).toBeLessThan(32_000); + } + // Requests after the first compaction carry the summary. + const firstSummaryIndex = sim.requests.findIndex((entry) => entry.kind === "summary"); + const afterCompaction = sim.requests.slice(firstSummaryIndex).find((entry) => entry.kind === "task"); + expect(afterCompaction && text(afterCompaction.context)).toContain("Fix the CSV parser"); + expect(sim.h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it("multi-prompt conversation: final answers never compact; the next prompt compacts first", async () => { + // 20K window -> threshold 18K. Each answer adds ~5K tokens. + const sim = await simulate({ contextWindow: 20_000 }); + const long = (label: string) => reply(`${label} ${"detail ".repeat(2_850)}`); + const compactionsAfter: number[] = []; + for (let turn = 0; turn < 5; turn++) { + sim.script([long(`answer-${turn}`)]); + await sim.h.session.prompt(`question ${turn}`); + expect(sim.pending()).toBe(0); + compactionsAfter.push(sim.h.eventsOfType("compaction_start").length); + // The session never ends a prompt with a compaction after the final answer. + expect(sim.requests.at(-1)?.kind).toBe("task"); + } + expect(compactionsAfter.at(-1)).toBeGreaterThanOrEqual(1); + // Each compaction happened before the task request of the prompt that crossed the threshold. + const summaryPositions = sim.requests.flatMap((entry, index) => (entry.kind === "summary" ? [index] : [])); + for (const position of summaryPositions) { + expect(sim.requests.slice(position).find((entry) => entry.kind === "task")).toBeDefined(); + } + for (const request of sim.requests.filter((entry) => entry.kind === "task")) { + expect(request.tokens).toBeLessThan(20_000); + } + }); + + it("user pastes a huge prompt into a nearly full session: compacts before the first request", async () => { + const sim = await simulate({ contextWindow: 40_000 }); + sim.script([reply(`analysis ${"note ".repeat(6_400)}`)]); + await sim.h.session.prompt("Summarize the incident timeline."); + expect(sim.h.eventsOfType("compaction_start")).toHaveLength(0); + + // ~8K tokens of history plus ~30K pasted tokens crosses the 36K threshold before the first request. + const pasted = `LOG DUMP\n${"2026-10-29T10:00:00Z ERROR csv quote mismatch\n".repeat(2_700)}`; + sim.script([reply("The quote mismatch starts at 10:00.")]); + await sim.h.session.prompt(pasted); + + expect(sim.pending()).toBe(0); + expect(sim.h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + const lastTask = sim.requests.filter((entry) => entry.kind === "task").at(-1)!; + expect(lastTask.tokens).toBeLessThan(40_000); + expect(text(lastTask.context)).toContain("Fix the CSV parser"); + // The pasted prompt itself survives the compaction and reaches the model. + expect(text(lastTask.context)).toContain("LOG DUMP"); + expect(sim.h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it("user steers while a mid-loop compaction runs: the steering reaches the next request", async () => { + const reads = { count: 0 }; + // 30K window -> threshold 27K; the fifth read pushes the next request past it. + const sim = await simulate({ + contextWindow: 30_000, + tools: [tool("read_file", () => output(`file-${++reads.count}`, 6_000))], + }); + let steered = false; + const steerOnFirstSummary = sim.h.session.agent.streamFunction; + sim.h.session.agent.streamFunction = (model, context, options) => { + if (!steered && isSummaryRequest(context)) { + steered = true; + void sim.h.session.prompt("STEER: only report the first failing line", { streamingBehavior: "steer" }); + } + return steerOnFirstSummary(model, context, options); + }; + let steeringSeenAt = -1; + sim.script([ + callTool("read_file"), + callTool("read_file"), + callTool("read_file"), + callTool("read_file"), + callTool("read_file"), + (context) => { + if (text(context).includes("STEER: only report the first failing line")) + steeringSeenAt = sim.taskRequests().length; + return fauxAssistantMessage("First failing line: 42."); + }, + ]); + + await sim.h.session.prompt("Read the five parser files."); + + expect(steered).toBe(true); + expect(sim.pending()).toBe(0); + expect(sim.h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + // The request right after the compaction is the one that carries the steering. + expect(steeringSeenAt).toBe(6); + expect(sim.h.session.messages.at(-1)).toMatchObject({ role: "assistant", stopReason: "stop" }); + }); + + it("summarizer outage: three failures pause compaction, requests still go out, and /compact resumes it", async () => { + const sim = await simulate({ contextWindow: 20_000 }); + const realStream = sim.h.session.agent.streamFunction; + let summarizerDown = true; + sim.h.session.agent.streamFunction = (model, context, options) => { + if (summarizerDown && isSummaryRequest(context)) { + return realStream(model, { ...context, systemPrompt: "BROKEN" }, options); + } + return realStream(model, context, options); + }; + // While "down", summary requests reach the task script; fail them as an outage would. + const outage: TaskStep = (context) => { + if (context.systemPrompt === "BROKEN") { + return fauxAssistantMessage("", { stopReason: "error", errorMessage: "503 summarizer unavailable" }); + } + return fauxAssistantMessage("unexpected"); + }; + const seedLarge = () => { + sim.h.sessionManager.appendMessage({ role: "user", content: "earlier task", timestamp: Date.now() }); + sim.h.sessionManager.appendMessage(fauxAssistantMessage(`earlier ${"x".repeat(72_000)}`)); + sim.h.session.agent.state.messages = sim.h.sessionManager.buildSessionContext().messages; + }; + seedLarge(); + for (let attempt = 0; attempt < 3; attempt++) { + sim.script([outage, outage, outage, outage, reply(`answer ${attempt}`)]); + await sim.h.session.prompt(`prompt ${attempt}`); + } + const failures = sim.h.eventsOfType("compaction_end").filter((event) => event.errorMessage); + expect(failures).toHaveLength(3); + expect(failures.at(-1)?.errorMessage).toContain("paused"); + + // Paused: the next prompt sends without trying to compact. + const before = sim.h.eventsOfType("compaction_start").length; + sim.script([reply("answer while paused")]); + await sim.h.session.prompt("prompt while paused"); + expect(sim.h.eventsOfType("compaction_start")).toHaveLength(before); + + // Summarizer recovers; manual /compact succeeds and automatic compaction resumes. + summarizerDown = false; + await sim.h.session.compact(); + seedLarge(); + sim.script([reply("answer after resume")]); + await sim.h.session.prompt("prompt after resume"); + const reasons = sim.h.eventsOfType("compaction_start").map((event) => event.reason); + expect(reasons.slice(-2)).toEqual(["manual", "threshold"]); + expect(sim.h.eventsOfType("compaction_end").at(-1)?.errorMessage).toBeUndefined(); + }); + + it("switching to a smaller-window model compacts before the first request on the new model", async () => { + const sim = await simulate({ + contextWindow: 128_000, + models: [ + { id: "large", contextWindow: 128_000 }, + { id: "small", contextWindow: 32_000 }, + ], + }); + // Two ~15K-token answers fit the 128K model but exceed the small model's 28.8K threshold. + for (const topic of ["lexer", "parser"]) { + sim.script([reply(`${topic} deep dive ${"text ".repeat(12_000)}`)]); + await sim.h.session.prompt(`Explain the ${topic} in depth.`); + } + expect(sim.h.eventsOfType("compaction_start")).toHaveLength(0); + + await sim.h.session.setModel(sim.h.getModel("small") as Model); + sim.script([reply("short answer on the small model")]); + await sim.h.session.prompt("Now give me a one-line summary."); + + expect(sim.h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + const lastTask = sim.requests.filter((entry) => entry.kind === "task").at(-1)!; + expect(lastTask.tokens).toBeLessThan(32_000 * 0.9); + expect(text(lastTask.context)).toContain("Fix the CSV parser"); + }); + + it("per-model ratio and tokenLimit change when compaction starts", async () => { + const run = async (compaction: Record) => { + const sim = await simulate({ contextWindow: 100_000, compaction }); + const sizes: number[] = []; + for (let turn = 0; turn < 12; turn++) { + sim.script([reply(`answer ${turn} ${"word ".repeat(9_000)}`)]); + await sim.h.session.prompt(`turn ${turn}`); + if (sim.h.eventsOfType("compaction_start").length > 0) break; + sizes.push(sim.requests.filter((entry) => entry.kind === "task").at(-1)!.tokens); + } + return { compacted: sim.h.eventsOfType("compaction_start").length, largestUncompacted: Math.max(...sizes) }; + }; + const byDefault = await run({}); + const byModel = await run({ modelTriggerRatios: { "faux/sim-model": 0.6 } }); + const byLimit = await run({ tokenLimit: 40_000 }); + expect(byDefault.compacted).toBe(1); + expect(byModel.compacted).toBe(1); + expect(byLimit.compacted).toBe(1); + // Earlier triggers leave smaller uncompacted requests. + expect(byModel.largestUncompacted).toBeLessThan(byDefault.largestUncompacted); + expect(byLimit.largestUncompacted).toBeLessThan(byModel.largestUncompacted); + expect(byLimit.largestUncompacted).toBeLessThanOrEqual(40_000); + }); + + it("legacy policy keeps compacting right after a final answer", async () => { + const sim = await simulate({ + contextWindow: 20_000, + compaction: { triggerPolicy: "legacy", reserveTokens: 4_000 }, + }); + sim.script([reply(`answer ${"detail ".repeat(10_000)}`)]); + await sim.h.session.prompt("write a long answer"); + expect(sim.h.eventsOfType("compaction_start").map((event) => event.reason)).toEqual(["threshold"]); + expect(sim.requests.at(-1)?.kind).toBe("summary"); + }); + + it("compaction disabled: no automatic compaction even far past the threshold", async () => { + const sim = await simulate({ contextWindow: 20_000, compaction: { enabled: false } }); + for (let turn = 0; turn < 3; turn++) { + sim.script([reply(`answer ${turn} ${"detail ".repeat(2_000)}`)]); + await sim.h.session.prompt(`turn ${turn}`); + } + expect(sim.h.eventsOfType("compaction_start")).toHaveLength(0); + expect(sim.requests.every((entry) => entry.kind === "task")).toBe(true); + }); +}); From 4492e3b80d26f389a081d60f2f2599da1fd38d02 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:58:58 +0800 Subject: [PATCH 3/5] fix(coding-agent): keep response room when triggering compaction on small windows Providers clamp each request's max_tokens to the remaining context minus a 4096-token safety margin. With the default 0.9 trigger ratio, a small window leaves less than that margin before compaction fires: on a 32K window about 3.3K tokens remain, so output is clamped to almost nothing and every turn near the threshold falls into length recovery. Cap the v2 threshold at contextWindow - CONTEXT_SAFETY_TOKENS - min(model.maxTokens, 8192), never below half the window. With the default ratio this gives 20,480 tokens (62.5%) for 32K, 53,248 (81.25%) for 64K and leaves 128K and larger windows at 90%. A lower configured ratio, tokenLimit, or explicit reserveTokens can still trigger earlier. Export CONTEXT_SAFETY_TOKENS from @step-harness/providers so the trigger and the request clamp share one value. --- docs/compaction-integrity.md | 5 +- packages/coding-agent/docs/compaction.md | 9 ++-- .../coding-agent/docs/context-management.md | 5 +- packages/coding-agent/docs/settings.md | 2 +- .../src/core/compaction/trigger-policy.ts | 52 +++++++++++++++++-- .../test/compaction-trigger-policy.test.ts | 38 +++++++++++++- packages/providers/src/api/simple-options.ts | 7 ++- packages/providers/src/index.ts | 1 + 8 files changed, 103 insertions(+), 16 deletions(-) diff --git a/docs/compaction-integrity.md b/docs/compaction-integrity.md index 295561e14..8d763780c 100644 --- a/docs/compaction-integrity.md +++ b/docs/compaction-integrity.md @@ -66,8 +66,9 @@ leaves less context headroom. This avoids starving a reasoning-enabled summary with a smaller fixed cap. The coding-agent and generic harness follow this rule. Under the default `v2` trigger policy, automatic compaction fires at -`contextWindow * triggerRatio`; `tokenLimit` and an explicit -`compaction.reserveTokens` smaller than the context window can only move it +`contextWindow * triggerRatio`, capped so the next request keeps the provider +safety margin plus up to 8192 output tokens, and never below half the window. +`tokenLimit` and an explicit `compaction.reserveTokens` can only move it earlier. The `legacy` policy keeps `contextTokens > contextWindow - reserveTokens`. The positional reserve argument to `generateSummary` and `generateSummaryWithUsage` remains accepted for caller compatibility but is diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index aa863dea1..ea296446e 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -51,10 +51,13 @@ A `length` response remains an incomplete summary and is never saved as a succes Auto-compaction triggers when: ``` -contextTokens > floor(contextWindow * triggerRatio) +contextTokens > min( + floor(contextWindow * triggerRatio), + max(contextWindow - 4096 - min(model.maxTokens, 8192), floor(contextWindow * 0.5)) +) ``` -`triggerRatio` defaults to `0.9` for every model (configurable globally or per model in `~/.stepcode/config.toml` or trusted `/.stepcode/config.toml`). No fixed output reserve is subtracted: providers clamp each request's `max_tokens` to the remaining context, and a response truncated by that clamp goes through the overflow/length recovery below. `tokenLimit` can only make compaction trigger earlier. An explicitly configured `reserveTokens` keeps its old effect as an additional ceiling, `contextWindow - reserveTokens`. +`triggerRatio` defaults to `0.9` for every model (configurable globally or per model in `~/.stepcode/config.toml` or trusted `/.stepcode/config.toml`). Providers clamp each request's `max_tokens` to the remaining context minus a 4096-token safety margin, so the second term keeps room for the next response: on a small window 10% of the context is less than that margin. The trigger never drops below half the window. With an 8K-or-larger output limit, a 32K window compacts at 20,480 tokens (62.5%), 64K at 53,248 (81.25%), and 128K or larger at 90%. A response truncated by the clamp still goes through the overflow/length recovery below. `tokenLimit` can only make compaction trigger earlier. An explicitly configured `reserveTokens` keeps its old effect as an additional ceiling, `contextWindow - reserveTokens`. Step checks the threshold once immediately before every model request: before the first request of a prompt (counting the incoming prompt), before every follow-up request inside a tool loop (counting the new tool results and injected steering), and before the first request of a continuation. If the threshold is crossed, Step compacts inside the same agent run and resumes with the summary and retained messages. Steering queued while a follow-up request compacts is delivered to that same request. Input queued while the pre-run check compacts (or added by an `agent_start` listener) joins the first request and is measured again before it is sent; input typed while that first request's own check compacts is delivered with the next request. A final answer that crosses the threshold does not compact on its own; the next request compacts first. Context-overflow and truncated-length recovery still run after the failed response. If measuring or preparing maintenance throws unexpectedly, the request is sent unchanged and overflow recovery remains the fallback. @@ -446,7 +449,7 @@ keepRecentTokens = 20000 | Setting | Default | Description | |---------|---------|-------------| | `enabled` | `true` | Enable auto-compaction | -| `triggerRatio` | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95) | +| `triggerRatio` | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95); small windows trigger earlier to keep response room | | `modelTriggerRatios` | none | Per-model `triggerRatio`, keyed by `provider/model-id` | | `tokenLimit` | none | Absolute token ceiling; can only trigger earlier | | `triggerPolicy` | `"v2"` | `"legacy"` restores the previous threshold and checkpoints | diff --git a/packages/coding-agent/docs/context-management.md b/packages/coding-agent/docs/context-management.md index 81afa8f25..49b7b9757 100644 --- a/packages/coding-agent/docs/context-management.md +++ b/packages/coding-agent/docs/context-management.md @@ -56,8 +56,9 @@ next request. This avoids an unused context-edit/summary call after a completed task, while explicit maintenance commands and error recovery keep their normal behavior. By default it uses the native compact trigger, `contextTokens > floor(contextWindow * compaction.triggerRatio)` with a default -ratio of 0.9, so a 131,072-token window triggers above 117,964 tokens. See -[Compaction](compaction.md#when-it-triggers) for per-model ratios and the legacy policy. +ratio of 0.9, so a 131,072-token window triggers above 117,964 tokens. Smaller +windows trigger earlier to keep room for the next response. See +[Compaction](compaction.md#when-it-triggers) for the exact rule, per-model ratios, and the legacy policy. With at least 8,000 context tokens and enough editable old text, CLM gets the first maintenance attempt at that threshold. No `/clm-compact` command, completed-plan signal, or reminder to the task model is needed. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 84733dcdd..7e8b060a3 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -109,7 +109,7 @@ For VS Code, include `--wait` so step resumes after the editor exits: | Setting | Type | Default | Description | |---------|------|---------|-------------| | `compaction.enabled` | boolean | `true` | Enable auto-compaction | -| `compaction.triggerRatio` | number | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95) | +| `compaction.triggerRatio` | number | `0.9` | Fraction of the context window that triggers auto-compaction (0.5-0.95); small windows trigger earlier to keep response room | | `compaction.modelTriggerRatios` | object | `{}` | Per-model trigger ratios keyed by `provider/model-id` | | `compaction.tokenLimit` | number | - | Absolute trigger ceiling; can only trigger earlier | | `compaction.triggerPolicy` | string | `"v2"` | `"legacy"` restores the previous `contextWindow - reserveTokens` trigger and checkpoints | diff --git a/packages/coding-agent/src/core/compaction/trigger-policy.ts b/packages/coding-agent/src/core/compaction/trigger-policy.ts index 79adda5b9..2c8752cff 100644 --- a/packages/coding-agent/src/core/compaction/trigger-policy.ts +++ b/packages/coding-agent/src/core/compaction/trigger-policy.ts @@ -1,19 +1,32 @@ /** * Automatic compaction trigger policy. * - * The threshold is a fraction of the model's context window. No absolute - * output reserve or buffer is subtracted: providers already clamp each - * request's `max_tokens` to the remaining context, and a response truncated - * by that clamp is recovered by the overflow/length path. The remaining - * fraction absorbs token-estimation error and scales with the window. + * The threshold is a fraction of the model's context window, capped so the + * next response keeps usable output room. Providers clamp each request's + * `max_tokens` to `contextWindow - input - CONTEXT_SAFETY_TOKENS`; on a large + * window the remaining fraction covers that margin and estimation error, but + * on a small window 10% can be smaller than the margin itself (about 3.3K + * tokens of a 32K window against a 4,096-token margin), which would clamp + * output to almost nothing before compaction fires. See + * {@link resolveCompactionThreshold}. */ +import { CONTEXT_SAFETY_TOKENS } from "@step-harness/providers"; + export type CompactionTriggerPolicy = "v2" | "legacy"; export const DEFAULT_COMPACTION_TRIGGER_RATIO = 0.9; export const MIN_COMPACTION_TRIGGER_RATIO = 0.5; export const MAX_COMPACTION_TRIGGER_RATIO = 0.95; +/** + * Output tokens the trigger keeps available for the next response, in addition to + * the providers' context safety margin. A model with a smaller declared output limit + * reserves only that limit. Large windows are unaffected: at 128K and above the + * default ratio already leaves more than this. + */ +export const MIN_RESPONSE_OUTPUT_TOKENS = 8192; + export interface CompactionTriggerSettings { policy: CompactionTriggerPolicy; /** Global fraction of the context window that triggers compaction. */ @@ -30,6 +43,8 @@ export interface CompactionTriggerModel { provider: string; id: string; contextWindow: number; + /** Declared output limit; unknown or nonpositive values reserve {@link MIN_RESPONSE_OUTPUT_TOKENS}. */ + maxTokens?: number; } /** Raw configuration fields read from the `compaction` settings object. */ @@ -118,9 +133,35 @@ export function resolveTriggerRatio( return settings.modelTriggerRatios[`${model.provider}/${model.id}`] ?? settings.triggerRatio; } +/** + * Highest threshold that still leaves the next response its output room: + * `contextWindow - CONTEXT_SAFETY_TOKENS - min(maxTokens, MIN_RESPONSE_OUTPUT_TOKENS)`. + * It never goes below half the window, so a very small window compacts at 50% + * instead of on every request. + */ +function responseHeadroomCeiling(model: CompactionTriggerModel): number { + const contextWindow = model.contextWindow; + const declaredOutput = model.maxTokens !== undefined && model.maxTokens > 0 ? model.maxTokens : Infinity; + const outputReserve = Math.min(declaredOutput, MIN_RESPONSE_OUTPUT_TOKENS); + const ceiling = contextWindow - CONTEXT_SAFETY_TOKENS - outputReserve; + return Math.max(ceiling, Math.floor(contextWindow * MIN_COMPACTION_TRIGGER_RATIO)); +} + /** * Token count above which automatic compaction runs for `model`. * Returns undefined when the context window is unknown. + * + * The configured ratio applies first, then the response-headroom ceiling. With the + * default ratio and an 8K-or-larger output limit this gives: + * + * | window | threshold | share | + * | --- | --- | --- | + * | 16,384 | 8,192 | 50% (floor) | + * | 32,768 | 20,480 | 62.5% | + * | 65,536 | 53,248 | 81.25% | + * | 131,072 and larger | `contextWindow * 0.9` | 90% | + * + * `tokenLimit` and an explicit legacy reserve can only move it earlier. */ export function resolveCompactionThreshold( model: CompactionTriggerModel, @@ -129,6 +170,7 @@ export function resolveCompactionThreshold( const contextWindow = model.contextWindow; if (!Number.isFinite(contextWindow) || contextWindow <= 0) return undefined; let threshold = Math.floor(contextWindow * resolveTriggerRatio(model, settings)); + threshold = Math.min(threshold, responseHeadroomCeiling(model)); if (settings.tokenLimit !== undefined) threshold = Math.min(threshold, settings.tokenLimit); if (settings.legacyReserveTokens !== undefined && settings.legacyReserveTokens < contextWindow) threshold = Math.min(threshold, contextWindow - settings.legacyReserveTokens); diff --git a/packages/coding-agent/test/compaction-trigger-policy.test.ts b/packages/coding-agent/test/compaction-trigger-policy.test.ts index 7e1d1d6fd..f243d2ce4 100644 --- a/packages/coding-agent/test/compaction-trigger-policy.test.ts +++ b/packages/coding-agent/test/compaction-trigger-policy.test.ts @@ -1,17 +1,24 @@ +import { CONTEXT_SAFETY_TOKENS } from "@step-harness/providers"; import { describe, expect, it } from "vitest"; import { DEFAULT_COMPACTION_TRIGGER_RATIO, + MIN_RESPONSE_OUTPUT_TOKENS, resolveCompactionThreshold, resolveCompactionTriggerSettings, } from "../src/core/compaction/trigger-policy.ts"; import { SettingsManager } from "../src/core/settings-manager.ts"; -const model = (contextWindow: number, id = "m") => ({ provider: "p", id, contextWindow }); +const model = (contextWindow: number, id = "m", maxTokens?: number) => ({ + provider: "p", + id, + contextWindow, + maxTokens, +}); describe("compaction trigger policy", () => { it.each([ - [64_000, 57_600], [128_000, 115_200], + [131_072, 117_964], [200_000, 180_000], [1_000_000, 900_000], ])("triggers at 90%% of a %i-token window by default", (contextWindow, threshold) => { @@ -22,6 +29,33 @@ describe("compaction trigger policy", () => { expect(resolveCompactionThreshold(model(contextWindow), settings)).toBe(threshold); }); + it.each([ + // window, model output limit, threshold + [65_536, 65_536, 53_248], + [64_000, undefined, 51_712], + [32_768, 32_768, 20_480], + [32_768, 4_096, 24_576], + [16_384, 16_384, 8_192], + [8_192, 8_192, 4_096], + ])("keeps response room on a %i-token window with output limit %s", (contextWindow, maxTokens, threshold) => { + const settings = resolveCompactionTriggerSettings().settings; + expect(resolveCompactionThreshold(model(contextWindow, "m", maxTokens), settings)).toBe(threshold); + // Below the floor, the threshold is half the window; otherwise the provider's + // context-clamped output budget at the threshold is at least the reserved room. + if (threshold > contextWindow / 2) { + const reserved = Math.min(maxTokens ?? MIN_RESPONSE_OUTPUT_TOKENS, MIN_RESPONSE_OUTPUT_TOKENS); + expect(contextWindow - threshold - CONTEXT_SAFETY_TOKENS).toBeGreaterThanOrEqual(reserved); + } + }); + + it("lets a lower ratio trigger earlier than the response-room ceiling", () => { + const { settings } = resolveCompactionTriggerSettings({ triggerRatio: 0.5 }); + expect(resolveCompactionThreshold(model(32_768, "m", 32_768), settings)).toBe(16_384); + // A higher per-model ratio cannot exceed the ceiling on a small window. + const high = resolveCompactionTriggerSettings({ modelTriggerRatios: { "p/m": 0.95 } }).settings; + expect(resolveCompactionThreshold(model(32_768, "m", 32_768), high)).toBe(20_480); + }); + it("applies global and per-model ratios", () => { const { settings } = resolveCompactionTriggerSettings({ triggerRatio: 0.8, diff --git a/packages/providers/src/api/simple-options.ts b/packages/providers/src/api/simple-options.ts index 2a30265ad..4e3359f51 100644 --- a/packages/providers/src/api/simple-options.ts +++ b/packages/providers/src/api/simple-options.ts @@ -9,7 +9,12 @@ import type { } from "../types.ts"; import { estimateContextTokens } from "../utils/estimate.ts"; -const CONTEXT_SAFETY_TOKENS = 4096; +/** + * Tokens left unused when a request's `max_tokens` is clamped to the remaining context. + * Callers that choose when to shrink context (for example, automatic compaction) must + * account for it: output room is `contextWindow - input - CONTEXT_SAFETY_TOKENS`. + */ +export const CONTEXT_SAFETY_TOKENS = 4096; const MIN_MAX_TOKENS = 1; export function clampMaxTokensToContext(model: Model, context: Context, maxTokens: number): number { diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index 7c7799042..b6fe9b154 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -10,6 +10,7 @@ export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from export * from "./api/lazy.ts"; export type { OpenAICompletionsOptions } from "./api/openai-completions.ts"; export type { OpenAIResponsesOptions } from "./api/openai-responses.ts"; +export { CONTEXT_SAFETY_TOKENS } from "./api/simple-options.ts"; export * from "./auth/context.ts"; export * from "./auth/credential-store.ts"; export * from "./auth/helpers.ts"; From 8421480d5aa81835cb2d171a0aa24a55dfd46651 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:59:11 +0800 Subject: [PATCH 4/5] docs(compaction): drop thinking from summary serialization examples Summary serialization omits provider thinking blocks since the summary input change; update both examples to match and note that stored messages keep them. Also remove a stray blank line in the coding-agent changelog. --- packages/coding-agent/CHANGELOG.md | 1 - packages/coding-agent/docs/compaction.md | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1fa498538..c1d66b99f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,5 +6,4 @@ - Summary requests now use the provider's model output limit and remaining-context sizing instead of fixed compaction/branch caps or reserve-derived budgets. Existing reserve arguments remain accepted for call compatibility; automatic trigger and branch source-selection settings are unchanged. - Compaction and branch-summary inputs omit provider thinking blocks while retaining visible replies, tool calls, diagnostic output, and existing skill-retention rules. Original session history is unchanged. These changes adapt Pi commits `15e7bdc` and `6fcb486` (upstream PR #11036). - - Recognize `search_files`, `find_files`, and `list_directory` when selecting default system-prompt file-exploration guidance. diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md index ea296446e..889aa36c3 100644 --- a/packages/coding-agent/docs/compaction.md +++ b/packages/coding-agent/docs/compaction.md @@ -297,12 +297,13 @@ Before summarization, messages are serialized to text via [`serializeConversatio ``` [User]: What they said -[Assistant thinking]: Internal reasoning [Assistant]: Response text [Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...) [Tool result]: Output from tool ``` +Provider thinking blocks are omitted from this view; the stored messages keep them. + This prevents the model from treating it as a conversation to continue. Oversized tool results are truncated during serialization with a head + tail + salient-lines strategy (defaults: 800-char head, 800-char tail, up to 20 salient lines re-surfaced from the omitted middle, ~2400-char total budget). Salient lines match `error|fail|test|exit|path|diff|warning` or stack-frame patterns (`traceback`, `File "`, `-->`, and `file.ext:123` / `file.ext(123` source locations). A marker records how many characters were omitted and what was kept. This keeps summarization requests within reasonable token budgets while preserving trailing errors and diagnostic lines, since tool results (especially from `read` and `bash`) are typically the largest contributors to context size. @@ -364,7 +365,6 @@ pi.on("session_before_compact", async (event, ctx) => { ); // Returns: // [User]: message text - // [Assistant thinking]: thinking content // [Assistant]: response text // [Assistant tool calls]: read(path="..."); bash(command="...") // [Tool result]: output text From bd2335fdf5b22bc34b0d8f900f1bf5372f56b5c2 Mon Sep 17 00:00:00 2001 From: liuedcson <332840681+liuedcson@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:59:11 +0800 Subject: [PATCH 5/5] docs: describe StepCode behavior without third-party product comparisons Replace comparison sections and references to other agent products with descriptions of StepCode's own behavior, design rationale, and follow-ups. Compatibility notes for importing external configuration, provider names, and license attributions are unchanged. --- docs/goal-lifecycle.md | 19 +++---- docs/orchestration-lifecycle.md | 14 ++--- docs/plan-loop-cron-workflow-hoh.md | 5 +- docs/recent-models.md | 7 --- .../README.md | 56 ++++++------------- docs/step-unified-config-and-mcp.md | 6 +- packages/coding-agent/README.md | 1 - .../coding-agent/docs/step-integration.md | 9 +-- 8 files changed, 38 insertions(+), 79 deletions(-) diff --git a/docs/goal-lifecycle.md b/docs/goal-lifecycle.md index 320404f79..8220c793b 100644 --- a/docs/goal-lifecycle.md +++ b/docs/goal-lifecycle.md @@ -63,27 +63,24 @@ failure leaves the prior limit authoritative. `/goal status` shows spent tokens even when the goal has no limit, for example `Tokens: 123 (unbounded)`. The exact arguments `stop`, `off`, `reset`, `none`, and `cancel` are aliases for -`clear`, matching Claude Code. Longer text such as `/goal stop flaky tests from +`clear`. Longer text such as `/goal stop flaky tests from failing` is still an objective. If a pause persists but the host cannot interrupt the continuation, the paused footer remains visible and the command reports the interruption failure instead of claiming the run stopped. -## Claude Code comparison +## Completion decisions and follow-ups StepCode uses explicit `update_goal` tool results from the working agent for completion and blocker decisions. It has no separate completion evaluator. -[Claude Code's documented goal behavior](https://code.claude.com/docs/en/goal) -uses a small model to evaluate the conversation after each turn and record a -verdict and reason. Adding that evaluator is a separate follow-up: it needs a -provider/model selection policy, evidence inputs, token accounting, and a stop -policy when evaluation fails or no progress is made. +An evaluator that judges the conversation after each turn and records a verdict +and reason is a separate follow-up: it needs a provider/model selection policy, +evidence inputs, token accounting, and a stop policy when evaluation fails or no +progress is made. StepCode also preserves counters on resume and rejects replacement of an -unfinished goal. Claude Code documents resetting usage baselines on resume and -replacing an active goal with a new condition. These are product differences, -not behaviors introduced by the command aliases. Goal budgets count uncached +unfinished goal. Goal budgets count uncached input plus output and gate subsequent continuations; an in-flight model request can exceed the remaining limit. See [workflow and cron lifecycle](orchestration-lifecycle.md) for the neighboring -coordination primitives and remaining Claude Code differences. +coordination primitives and remaining follow-ups. diff --git a/docs/orchestration-lifecycle.md b/docs/orchestration-lifecycle.md index 1378703b7..c9cb86ac2 100644 --- a/docs/orchestration-lifecycle.md +++ b/docs/orchestration-lifecycle.md @@ -289,8 +289,7 @@ drains at `agent_settled`, after retry and compaction recovery. Due work waits while the current turn runs; missed intervals do not create a burst of catch-up turns. -Offsets follow the documented Claude Code bounds and are derived from the task -ID by default: +Offsets are derived from the task ID by default: | Task | Offset from the matching local-time minute | | --- | --- | @@ -347,20 +346,15 @@ If the process crashes or a disk write fails after handoff, the stored occurrenc can be delivered again on recovery. Atomic exactly-once execution would require an acknowledged outbox/consumer protocol across that boundary. -## Remaining Claude Code differences +## Follow-ups -The comparison uses official documentation retrieved on 2026-09-20: -[workflows](https://code.claude.com/docs/en/workflows), -[goals](https://code.claude.com/docs/en/goal), and -[scheduled tasks](https://code.claude.com/docs/en/scheduled-tasks). - -| Area | Current StepCode behavior | Follow-up for closer alignment | +| Area | Current StepCode behavior | Follow-up | | --- | --- | --- | | Workflow entry | `/ultracode` and `/ultraloop` share session controls; `ultracode: ` and `ultraloop: ` opt in for one turn | — | | Workflow execution | Foreground tool with live agent counts/tasks, journals, cancellation, replay | Background workflow task view with pause/resume controls | | Goal completion | Working agent calls `update_goal`; user pause/resume and persisted budgets | Independent completion evaluator with visible verdict/reason and a no-progress stop policy | | Interval scheduling | `cron_create`, `cron_list`, `cron_delete`, `/cron` | `/loop` convenience command, including a design for completion-relative intervals | -| Scheduling persistence | Optional shared project store | Claude Code session schedules are session-local; retain the stronger StepCode persistence contract explicitly | +| Scheduling persistence | Optional shared project store | Keep the project-store persistence contract explicit | See [session goal lifecycle](goal-lifecycle.md) for goal controls, budget recovery, and cancellation semantics. The prior diff --git a/docs/plan-loop-cron-workflow-hoh.md b/docs/plan-loop-cron-workflow-hoh.md index 605fa6155..ed47fd3da 100644 --- a/docs/plan-loop-cron-workflow-hoh.md +++ b/docs/plan-loop-cron-workflow-hoh.md @@ -357,11 +357,11 @@ telemetry 和 evidence 在进程内串行 append;读取时容忍被杀进程 | 决策 | 选择 | 原因 | |---|---|---| -| Loop 工具名 | `schedule_wakeup` / `list_wakeups` / `cancel_wakeup` | 与 Claude Code 语料一致,表达 session continuation | +| Loop 工具名 | `schedule_wakeup` / `list_wakeups` / `cancel_wakeup` | 名称直接表达 session continuation | | Loop 时钟 | 标准 timer + 注入 clock | 零运行时依赖,测试可控 | | Cron 表达式 | 五字段、项目本地时区 | 足够覆盖 agent 场景,避免秒级复杂度 | | Cron durable 位置 | `.stepcode/cron/tasks.json` | 项目边界清晰、可备份、可迁移 | -| Workflow DSL | JavaScript | 能表达控制流且接近 Claude Code Workflow 心智 | +| Workflow DSL | JavaScript | 能直接表达控制流,模型和用户都熟悉 | | Workflow 隔离 | `isolated-vm` 或等效强隔离 | 禁止把 `node:vm` 当安全边界 | | schema 校验 | TypeBox JSON Schema engine | VM 只传 JSON,错误可解释且不重复实现 validator | | schema retry | 默认最多三次,可下调 | 防止无界 token 消耗 | @@ -422,7 +422,6 @@ telemetry 和 evidence 在进程内串行 append;读取时容忍被杀进程 ## 参考 -- Claude Code:`ScheduleWakeup`、`CronCreate`/`CronDelete`/`CronList`、Workflow DSL。 - Yan et al., *Harness-of-Harness: Multi-Day Autonomous Software Development with Continual Improvement*, arXiv:2609.01481 (2026-09-01)。本文借鉴其 planner → developer → independent QA、evidence carry-over、single-writer、版本化 artifact diff --git a/docs/recent-models.md b/docs/recent-models.md index 3ca38af1c..0c430bf9a 100644 --- a/docs/recent-models.md +++ b/docs/recent-models.md @@ -7,10 +7,3 @@ Successful interactive selections (Enter, exact `/model provider/id`, and model History is stored as the global settings field `recentModels`, using Step's existing `config.toml` storage adapter (or the embedded host's settings storage). It is global-only, is validated when read, and concurrent sessions merge selections under the existing storage lock. No credentials or model objects are stored. Recent history does not change startup defaults or model cycling order. With `STEPCODE_CONFIG_PATH`, default selection still writes the external active model; history uses the wrapped global settings storage. The selector only receives history references. Settings persistence stays in `packages/coding-agent`; `packages/tui` is unchanged. Unavailable references are hidden rather than deleting offline history. Scoped mode filters Recent to scoped models and preserves the order of the remaining scoped models. - -## Reference - -Behavior was checked against OpenCode v1.2.0, not copied into StepCode: - -- [Model dialog](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx): Recent section, unavailable-model filtering, search without duplicated rows. -- [Local model state](https://github.com/anomalyco/opencode/blob/v1.2.0/packages/opencode/src/cli/cmd/tui/context/local.tsx): provider/model identity, move-to-front history, ten-entry limit. diff --git a/docs/research/2026-10-27-subagent-live-update-amplification/README.md b/docs/research/2026-10-27-subagent-live-update-amplification/README.md index 9dbb2ff74..0c729fe54 100644 --- a/docs/research/2026-10-27-subagent-live-update-amplification/README.md +++ b/docs/research/2026-10-27-subagent-live-update-amplification/README.md @@ -98,38 +98,19 @@ assert: quadrupling the child does not move the number. With `--include-live-events` the same 40-turn run writes 0.010 MiB, of which 0.005 MiB is frames, and no frame carries a transcript. -## 4. Competitor survey - -Six implementations, source-read. Versions: opencode `f669c46c`, codex `970111f6` -(Rust under `codex-rs/`), kimi-code `743f14097`, minimax-code `da22f11`, -deepseek-harness, ZCode. - -| implementation | where the decision lives | deltas persisted | progress persisted | persistence trigger | child history | -| --- | --- | --- | --- | --- | --- | -| opencode | **event type**: `durable` / `ephemeral` are mutually exclusive | no | no | `text.ended`, `tool.success`, `step.ended` | own session; parent keeps sessionID + final text | -| codex | **storage boundary**: `rollout/policy.rs` transient list | no | no | `ItemCompleted`, full `ResponseItem` | own thread; parent keeps agent_thread_id | -| kimi-code | **event type**: static `durable` flag, default false | no | no | durable event; content parts at step end | own agent log; parent keeps resultSummary | -| minimax-code | **transport**: `VOLATILE_EVENT_TYPES` | no | no | `message_end`, `tool_execution_end` | own child session; parent keeps a report | -| deepseek-harness | **event vocabulary**: no progress event type exists | no | no | turn/step boundaries, 200ms batches | own session; parent keeps childId + label | -| ZCode | **event type set**: `TRANSIENT_SESSION_EVENT_TYPES` | no | no | pending/running/completed upserts | own session; parent keeps childSessionId + outputPath | - -What all six share: - -1. **A non-durable event flows to the UI and not to storage.** codex's - `SubAgentActivity` is durable only when `kind == Completed`, so `Started`, - `Interacted` and `Interrupted` never reach the rollout; opencode's - `session.tool.progress` is `Event.ephemeral`, whose type has no durable - envelope at all. -2. **The successor is the replayable value.** `message_end` / `tool_execution_end` - / `Text.Ended` carry the authoritative payload; a reader can skip every frame. -3. **Capability, not throttling, is what makes them more live.** Because a frame - is never written, emitting one per token is free — there is no trade between - latency and log size to make. -4. **The child's transcript has its own home**, referenced by id. - -One structural difference matters for us: in all six the log file *is* their -storage layer, so filtering at the storage boundary works. Our `stepcode.jsonl` -is a **redirected stdout**, and the session file itself only ever received +## 4. Where the classification belongs + +The usual answer to this problem is a durability split: an event either flows +to live consumers only, or it is a durable fact. Deltas and progress frames are +never persisted; the successor event (`message_end`, `tool_execution_end`) +carries the replayable value, so a reader can skip every frame. Once a frame is +never written, emitting one per token is free — capability, not throttling, is +what makes the stream live. A child's transcript has its own home and is +referenced by id. + +One structural detail matters for us. When the log file *is* the storage layer, +filtering at the storage boundary is enough. Our `stepcode.jsonl` is a +**redirected stdout**, and the session file itself only ever received `message_end` — it was never the problem. So the classification has to be applied by the serialized transports, not by the session store. @@ -160,8 +141,7 @@ export function sessionEventDurability(event): "durable" | "ephemeral" { It is an allowlist of *frames*, so an event added later is durable by default: forgetting to classify cannot silently opt a new event into a log. The criterion -is "payload is a cumulative snapshot of in-flight work", the same boundary -opencode draws at `Text.Ended`. +is "payload is a cumulative snapshot of in-flight work". The classification is applied in `modes/json-event.ts`, and nowhere else. `toJsonEvent(event, { includeLiveEvents })` returns `undefined` for a frame the @@ -345,7 +325,7 @@ the revert — read the child session instead. 1. **Progress does not yet have a channel of its own.** It is classified as non-durable and excluded from the log, which removes the amplification, but it still travels as a session event. The target design gives live progress a - dedicated surface — the shape codex and opencode converge on — so that + dedicated surface so that `omittedResults`, the roster byte budget and `rosterFields` can be deleted outright. **Removal condition:** when a live frame no longer carries a roster, all three go, and no test needs them. @@ -354,8 +334,7 @@ the revert — read the child session instead. clarity only, at the cost of a new type plus every consumer. Prefer to revisit it together with (1). 3. **Session-file compaction.** After this change growth is linear, not quadratic, - but a long session is still large. Competitors have zstd or a file cap; we do - not. + but a long session is still large; there is no compression or file cap yet. 4. **Full backpressure.** One frame in flight bounds the loop's queue, but `output-guard`'s stdout queue has no hard limit. With facts only, that traffic is small, so this is secondary. @@ -368,4 +347,5 @@ the revert — read the child session instead. boundary. 6. **Type-level durability.** The classification is enforced in one projection module, not by the event type, and a check script fails the build if a second - policy appears. opencode makes it a compile-time property; we do not yet. + policy appears. Making it a compile-time property of the event type is not + done yet. diff --git a/docs/step-unified-config-and-mcp.md b/docs/step-unified-config-and-mcp.md index c38ed27d6..f72d5ec7f 100644 --- a/docs/step-unified-config-and-mcp.md +++ b/docs/step-unified-config-and-mcp.md @@ -260,10 +260,10 @@ if (ctx.mode !== "tui") await startup; // 仅 TUI 分离 **解决**:先 `resolve()` 成绝对路径再取 `dirname()`;若已经是文件系统根,则退回 agent 目录本身,而不是把凭据写到宿主指定的命名空间之外。 -### 4.4 OAuth 借用了 Codex 的应用身份 +### 4.4 OAuth 借用了第三方应用身份 -早期实现硬编码了 Codex 的 client id,导致授权页面显示 "Figma MCP in Codex"。该硬编码 -已移除,**不得恢复或借用 Claude / Codex 的应用身份**。 +早期实现硬编码了其他客户端的 OAuth client id,导致授权页面显示的应用不是 Step。该硬编码 +已移除,**不得恢复或借用其他产品的应用身份**。 官方 Figma MCP 对 Step 返回 403,现在会给出明确指引: diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index c7f354073..1d58c1e56 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -326,7 +326,6 @@ The default export can also be `async`. step waits for async extension factories - Git checkpointing and auto-commit - SSH and sandbox execution - MCP server integration -- Make step look like Claude Code - Games while waiting (yes, Doom runs) - ...anything you can dream up diff --git a/packages/coding-agent/docs/step-integration.md b/packages/coding-agent/docs/step-integration.md index 2422eb03e..0a30d7922 100644 --- a/packages/coding-agent/docs/step-integration.md +++ b/packages/coding-agent/docs/step-integration.md @@ -180,7 +180,7 @@ They do not alter the session file format or agent loop. Callers that supply a custom or in-memory manager should pass explicit `stepSettingsPaths` when they need to control where the Step sidecar is stored. -`/init` submits a Codex-style repository-instructions prompt through the +`/init` submits a repository-instructions prompt through the normal user-message path. The model inspects the project and uses the native write and approval flow; the prompt explicitly preserves an existing `AGENTS.md` instead of replacing it. @@ -435,11 +435,8 @@ tools a profile lacks (`version: 2` entries) store no replaced list, so a branch written by them never replays an older snapshot. There is no additional `TodoWrite` / `update_plan` alias or second checklist store. -The separation follows [Claude Code's plan and task tools](https://code.claude.com/docs/en/tools-reference) -and [permission modes](https://code.claude.com/docs/en/permission-modes). The -[Codex checklist handler](https://github.com/openai/codex/blob/main/codex-rs/core/src/tools/handlers/plan.rs) -also distinguishes its `update_plan` checklist from Plan mode. Step borrows the -separation, not model-specific restrictions on which task tools are available. +Plan mode and the task checklist remain separate tools. Step does not restrict +which task tools are available by model. ## Compatibility matrix