Skip to content

feat(coding-agent): trigger auto-compaction at a configurable context ratio - #326

Merged
liuedcson merged 6 commits into
mainfrom
feat/compaction-trigger-timing
Oct 30, 2026
Merged

liuedcson merged 6 commits into
mainfrom
feat/compaction-trigger-timing

Conversation

@liuedcson

@liuedcson liuedcson commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Automatic compaction timing was hard to predict and sometimes wrong:

  • Threshold ignored window size. It fired at contextWindow - reserveTokens (16384 by default): ~74% of a 64K window, ~92% of 200K, ~98.4% of 1M. Large-window models compacted with almost no room for estimation error.
  • Scattered checkpoints. Threshold checks ran in four places (prompt(), _runAgentPrompt, prepareNextTurn, _handlePostAgentRun) with three different token measurements. Outside CLM mode, the first request of a run and the first request after agent.continue() were never checked before sending.
  • Wasted compaction after final answers. In non-CLM mode a final answer that crossed the threshold triggered a compaction immediately, even if the user never sent another message.
  • No circuit breaker. A failing summarizer was retried on every opportunity.
  • Stale usage. Usage reported before a compaction could be read as the current size and immediately retrigger it (non-CLM path).

Changes

agent-core — new optional beforeModelRequest loop hook (AgentLoopConfig, AgentOptions, Agent). Called once before every provider request (phase: "initial" | "follow-up"), after injected steering, so it sees exactly what will be sent; it may replace context/model/thinking. Tool-call resampling does not call it again. On follow-up requests without injected steering, the loop polls steering once more after the hook (not when aborted), so input queued during compaction reaches the same request while one-at-a-time delivery stays one message per turn. prepareNextTurn is unchanged.

coding-agent — trigger policy v2 (default):

  • Threshold (trigger-policy.ts):
    min(floor(contextWindow * triggerRatio),
        max(contextWindow - CONTEXT_SAFETY_TOKENS - min(model.maxTokens, 8192), floor(contextWindow * 0.5)))
    
    Default triggerRatio is 0.9. Providers clamp max_tokens to contextWindow - input - CONTEXT_SAFETY_TOKENS (4096), so on a small window 10% headroom is smaller than that margin and output would be clamped to almost nothing before compaction fires. The second term keeps the safety margin plus up to 8192 output tokens, never below half the window. CONTEXT_SAFETY_TOKENS is now exported from @step-harness/providers so the trigger and the request clamp share one value.
  • New settings: compaction.triggerRatio (0.5–0.95), compaction.modelTriggerRatios["provider/id"], compaction.tokenLimit (only earlier), compaction.triggerPolicy ("v2" | "legacy"). Invalid values fall back and are surfaced as settings diagnostics.
  • Single checkpoint: AgentSession installs beforeModelRequest and runs CLM maintenance first, native compaction as fallback. The pre-run check in _runAgentPrompt still measures the incoming prompt (so CLM can protect it); the hook re-measures the first request if it differs from what was checked (steering queued meanwhile, agent_start listeners, model object replaced).
  • prompt() / _handlePostAgentRun keep only overflow and truncated-length recovery; no threshold compaction after a final answer.
  • Measurement ignores usage older than the latest compaction; without trustworthy usage it adds a system prompt + tool schema estimate.
  • Breaker: three consecutive automatic failures pause automatic compaction and stop measuring for the session. Cancellation and errors after a compaction was saved do not count. A successful compaction (incl. /compact), accepted CLM edit, model switch, or context-window change resumes it.
  • Unexpected errors while measuring/preparing maintenance fail open: recorded as step-compaction-failure, counted, request sent unchanged. Legacy and cancellation still propagate.

cli — restore the working indicator when compaction ends inside an active run (compaction now happens after turn_start).

docs — besides the trigger documentation, this branch updates the summary-serialization examples after #325 (thinking is omitted from summary input) and rewrites several design and lifecycle documents to describe StepCode's own behavior and follow-ups instead of comparisons with other agent products.

Commits:

  1. feat(agent-core): the hook, reviewable alone.
  2. feat(coding-agent): trigger policy v2.
  3. Merge of main (fix(compaction): align summary inputs and output budgets with upstream #325): summary requests no longer derive an output cap from reserveTokens; documentation conflicts resolved on that basis.
  4. fix(coding-agent): response-room ceiling for small windows.
  5. docs(compaction) and docs: documentation follow-ups described above.

Behavior and compatibility

  • Changed default trigger (with an output limit of 8K or more):

    window before after
    32K 50% 62.5% (20,480)
    64K ~74% 81.25% (53,248)
    128K ~87% 90%
    200K ~92% 90%
    1M ~98.4% 90% (earlier)
  • Escape paths. compaction.triggerPolicy = "legacy" restores the previous threshold, checkpoints (incl. compaction after a final answer), and measurement. An explicit compaction.reserveTokens smaller than the window still caps the v2 threshold at contextWindow - reserveTokens, so users who tuned it keep that bound. A lower triggerRatio or tokenLimit can always trigger earlier; a higher ratio cannot exceed the small-window ceiling.

  • Summary output. Since fix(compaction): align summary inputs and output budgets with upstream #325, summary requests use the provider's normal output sizing; reserveTokens only participates in trigger decisions.

  • Timing / events. Mid-run threshold compaction now emits compaction_start/compaction_end after turn_start (previously before). Event shapes and reason values are unchanged. Steering typed during a follow-up compaction is delivered to the same request.

  • Public interface. New optional beforeModelRequest on agent-core; new CONTEXT_SAFETY_TOKENS export from providers; existing consumers unaffected. AgentSession chains any previously installed hook and merges partial updates with the compacted context.

  • Persisted data. No session format change. Unexpected maintenance faults add step-compaction-failure custom entries (existing type).

Validation

  • pnpm run check — pass after the merge and small-window fix. git diff --check and check:public-boundary — pass.
  • packages/coding-agent (after merge): trigger policy, session trigger, end-to-end simulation, auto-CLM, live context, and the fix(compaction): align summary inputs and output budgets with upstream #325 compaction regressions — 206 passed, 2 skipped.
  • packages/providers: openai-completions-thinking-token-budget, faux-provider — 34 passed.
  • Earlier full runs on macOS (darwin arm64), Node 24, before the merge: packages/agent-core 545 passed / 1 skipped; apps/cli 749 passed; packages/coding-agent 5098 passed / 58 skipped, 6 failures in test/completion-check-cli.test.ts that also fail on a clean main in that environment (the test overrides HOME, which breaks a $HOME-relative local git wrapper). CI is the authoritative full run.
  • New tests (faux providers only, no real model calls):
    • agent-core/test/agent-loop.test.ts: hook per request with full outgoing context, continuation phase, steering queued during the hook reaches the same request with one-at-a-time preserved.
    • coding-agent/test/compaction-trigger-policy.test.ts: threshold table for 8K–1M windows, small-output models, the response-room invariant at the threshold, per-model / tokenLimit / legacy reserve, validation + diagnostics.
    • coding-agent/test/suite/agent-session-compaction-trigger.test.ts (12): mid-loop compaction, no compaction after a final answer, custom ratio, breaker pause/resume, post-commit errors not counted, chained hook partial update, steering during pre-run compaction, agent_start steering, same-id model refresh, fail-open measurement faults (native + CLM).
    • coding-agent/test/suite/compaction-trigger-simulation.test.ts (9 end-to-end scenarios with a scripted faux model): long tool loop, multi-prompt chat, huge paste, steering during compaction, summarizer outage → pause → /compact resume, switch to a smaller-window model, per-model ratio vs tokenLimit, legacy, disabled.
    • apps/cli/test/session-events-working-tracker.test.ts: working indicator restored only while streaming.

Limitations

  • Default ratio 0.9 and the 8192-token response reserve are not yet calibrated against Step's production models; per-model triggerRatio is the knob if some model shows more truncation.
  • Not in scope: unifying the two existing reserveTokens defaults (16384 in settings vs 24576 in DEFAULT_COMPACTION_SETTINGS); background/idle pre-compaction.
  • The breaker does not reset on branch navigation; failures before compaction_start (e.g. summarizer auth resolution) keep their existing silent behavior.
  • Interactive TUI behavior verified through unit tests of the event handler, not a manual terminal session.

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.
… 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.
Bring in the summary-budget change from main. Summary requests no longer
derive an output cap from reserveTokens, so reserveTokens now only
participates in trigger decisions: the legacy policy threshold, and an
explicit cap on the v2 threshold. Resolve the settings and compaction
integrity documentation on that basis and drop comments that still
described reserveTokens as a summary output budget.
…mall 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.
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.
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.
@liuedcson
liuedcson merged commit 9fe7c1c into main Oct 30, 2026
2 checks passed
@liuedcson
liuedcson deleted the feat/compaction-trigger-timing branch October 30, 2026 08:11
@liuedcson
liuedcson restored the feat/compaction-trigger-timing branch October 31, 2026 03:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant