Configuration

September 10, 2026 · View on GitHub

English | 中文

billion-context-pi works out of the box with no configuration — it reads your model's context window automatically and applies sensible defaults. This document is the complete reference for the optional JSON configuration file (acp.json) and the environment variables that let you tune behavior.

Configuration is layered: environment variables take the highest precedence, followed by the project config file, then the global config file, and finally the built-in defaults.


Config file locations

Settings are read from JSON files named acp.json. The global file applies to every project; a project file overrides the global one on a per-field basis (individual keys you do not set in the project file still fall back to the global value).

ScopePathApplies to
Global~/.pi/acp.jsonAll projects on this machine
Project<project>/.pi/acp.jsonThe current project only (overrides global per-field)

Precedence: Environment variable > Project file > Global file > Built-in default.

Files are loaded at session start. Missing files, malformed JSON, and unknown keys are silently ignored — the extension never fails to start because of a config issue. Only the documented keys are read; everything else is discarded.


Quick start

Create ~/.pi/acp.json (or <project>/.pi/acp.json) and drop in whichever keys you want to change. Every field below is optional — omit a key to keep its default.

{
  "debug": false,
  "autoUpdate": true,
  "modelContextLimit": 200000,
  "outputHeadroomMaxPct": 0.25,
  "toolBashDefaultTimeout": 60,
  "toolOutputMaxBytes": 200000,

  "throttleRetry": {
    "enabled": true,
    "maxRetries": 10
  },

  "delegate": {
    "enabled": true,
    "displayUsage": "separate"
  },

  "compress": {
    "maxContextLimit": "75%",
    "emergencyThresholdPercent": "95%",
    "nudgeGrowthTokens": 50000,
    "reasoning": { "drop": true, "threshold": 2048 }
  }
}

A minimal config enabling only debug logging:

{
  "debug": true
}

An advanced config overriding the kernel's compression prompt rules (requires the risk acknowledgement). Set only the fields you want to change; the rest inherit the kernel defaults:

{
  "prompts": {
    "compressPhilosophy": "My compression philosophy...",
    "howToCompressRules": "My tier-1 rules...",
    "tier2DistillRules": "My tier-2 distillation rules...",
    "tier3CondenseRules": "My tier-3 condensation rules..."
  },
  "acknowledgePromptsRisk": true
}

Parameter Reference

Status legend

StatusMeaning
🟢 ACTIVEFully supported, documented, and recommended for use.

All keys below are currently ACTIVE.

Summary

Top-level keys

KeyTypeDefaultStatusDescription
enabledbooleantrue🟢 ACTIVEMaster switch. false turns the whole adapter off (no tools, no system prompt, no context transform) — for models too small to handle ACP. Requires a Pi restart.
debugbooleanfalse🟢 ACTIVEEnable verbose debug-level events in the log.
autoUpdatebooleantrue🟢 ACTIVECheck npm for a newer version on startup and auto-install it.
modelContextLimitnumber(auto)🟢 ACTIVEOverride the context limit (in tokens).
outputHeadroomMaxPctnumber | string0.25🟢 ACTIVECap on the output-headroom reservation, as a fraction of the context window.
toolBashDefaultTimeoutnumber60🟢 ACTIVEDefault bash tool timeout in seconds when the model omits it.
toolOutputMaxBytesnumber200000🟢 ACTIVEHard byte cap on tool result text.
throttleRetryboolean | objecttrue🟢 ACTIVEAuto-retry provider token rate-limit errors with progressive backoff.
repetitionGuardboolean | objecttrue🟢 ACTIVEBreak infinite loops of byte-identical tool calls (warn at 3 consecutive, block + abort at 5).
degenerationGuardboolean | objecttrue🟢 ACTIVECollapse degenerate single-codepoint runs (e.g. 4655×「【」) in assistant text/thinking of the outgoing view and inject a one-shot recovery notice — breaks the abort loop where pi replays degenerated thinking back to the provider on every request (#351).

Delegate keys

KeyTypeDefaultStatusDescription
delegate.enabledbooleantrue🟢 ACTIVEEnable the acp_delegate tools and their system-prompt section.
delegate.displayUsagestring"separate"🟢 ACTIVEControls how delegate sub-agent token usage is reported.
delegate.maxDepthnumber2🟢 ACTIVEMax nesting depth for acp_delegate (main session = depth 0; a session at this depth is a leaf and cannot delegate again). Set 1 so delegates never nest.
delegate.syncTimeoutMinutesnumber5🟢 ACTIVEHard timeout for synchronous acp_delegate calls, in minutes. 0 / null disables it.
delegate.idleTimeoutMinutesnumber5🟢 ACTIVEIdle watchdog for async delegate children — force-finish after this many minutes without output. 0 / null disables it.
delegate.asyncTimeoutMinutesnumber30🟢 ACTIVEAbsolute hard limit for async delegate children, in minutes. 0 / null disables it.
delegate.maxConcurrentnumberunlimited🟢 ACTIVEMax background (async) delegates running at once; extra launches queue FIFO and start as slots free. 1 = forced serial. Overridden by PI_ACP_DELEGATE_MAX_CONCURRENT.
delegate.thinkingLevelstring(unset)🟢 ACTIVEGlobal default thinking level for delegates (per-call > role > global > Pi default).
delegate.agentsobject(unset)🟢 ACTIVEPer-role default model + thinking level, keyed by role name.

Provider throttle retry keys

KeyTypeDefaultStatusDescription
throttleRetry.enabledbooleantrue🟢 ACTIVEEnable auto-retry of provider token rate-limit errors.
throttleRetry.maxRetriesnumber10🟢 ACTIVETotal budget of ACP-driven retries per error episode.
throttleRetry.baseDelayMsnumber60000🟢 ACTIVEDelay before the first paced kick.
throttleRetry.maxDelayMsnumber300000🟢 ACTIVECap for paced kick delays.
throttleRetry.backoffModestring"exponential"🟢 ACTIVEDelay progression: "exponential"$ ( \times 2 \text{per} \text{kick}) \text{or} $"fixed".

Repetition guard keys

KeyTypeDefaultStatusDescription
repetitionGuard.enabledbooleantrue🟢 ACTIVEEnable the repetition breaker. false disables it entirely.
repetitionGuard.warnnumber3🟢 ACTIVEConsecutive byte-identical calls before a strong warning is appended to the tool result.
repetitionGuard.abortnumber5🟢 ACTIVEConsecutive byte-identical calls before the call is blocked (not executed) and the turn is aborted. Must exceed warn.
degenerationGuard.enabledbooleantrue🟢 ACTIVEEnable the degenerate-repeat guard. false disables it entirely.
degenerationGuard.minRunnumber200🟢 ACTIVEMinimum length of a single-codepoint run before it is treated as degeneration and collapsed. Values below 8 are raised to 8.

Compression keys

KeyTypeDefaultStatusDescription
compress.maxContextLimitnumber | string"75%"🟢 ACTIVEContext threshold that triggers forced compression nudges.
compress.emergencyThresholdPercentnumber | string"95%"🟢 ACTIVEContext threshold that triggers emergency truncation.
compress.nudgeGrowthTokensnumber50000🟢 ACTIVEToken growth step for soft compression nudges.
compress.reasoningobject{ "drop": true, "threshold": 2048 }🟢 ACTIVEDrop oversized thinking from historical compress calls (request-time; persisted history untouched).

Prompts keys

KeyTypeDefaultStatusDescription
promptsobject(kernel defaults)🟢 ACTIVEOverride acp-kernel's 4 load-bearing compression prompt rules. Each set field replaces the default verbatim.
acknowledgePromptsRiskbooleanfalse🟢 ACTIVEMust be true for prompts overrides to take effect; otherwise overrides are dropped and defaults are used.

Environment variables

VariableEffect
ACP_AUTO_UPDATESet to 0 / false to disable auto-update (overrides autoUpdate).
ACP_MODEL_CONTEXT_LIMITOverride the context limit (takes highest precedence).
ACP_DEBUGSet to 1 / true to enable debug logging.
ACP_LOG_FILEOverride the log file path (default ~/.pi/acp.log).
PI_ACP_DELEGATE_MAX_DEPTHOverride delegate.maxDepth.
PI_ACP_DELEGATE_SYNC_TIMEOUT_MINUTESOverride delegate.syncTimeoutMinutes; 0 disables the sync hard timeout.
PI_ACP_DELEGATE_IDLE_TIMEOUT_MINUTESOverride delegate.idleTimeoutMinutes; 0 disables the idle watchdog.
PI_ACP_DELEGATE_ASYNC_TIMEOUT_MINUTESOverride delegate.asyncTimeoutMinutes; 0 disables the async hard limit.

Only the documented keys are read from acp.json. Other tuning knobs (preserveRecentMessages, protectedTools) are code-level and not user-overridable. The three compression thresholds form a three-tier escalation: growth-driven soft nudges → forced nudges at compress.maxContextLimit → emergency truncation at compress.emergencyThresholdPercent.


General

enabled

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: Master switch for the entire adapter. Set to false to turn ACP off completely: no compress/decompress/search_context/acp_status tools, no ACP system prompt, no context transformation, and no suppression of Pi's built-in auto-compaction — Pi's native context management runs instead. Intended for small local models (e.g. quantized 27B) that cannot reliably drive ACP's compression loop. Checked once at extension load, so it requires restarting Pi after editing acp.json. Project-local acp.json overrides the global one.

debug

  • Type: boolean
  • Default: false
  • Status: 🟢 ACTIVE
  • Description: Enable verbose debug-level events in the log file (default ~/.pi/acp.log). The always-on log (session/turn/compress/delegate lifecycle events, all errors and warnings) is written regardless of this setting; debug only adds extra diagnostics such as full field dumps and per-turn internals. Also enabled by the environment variable ACP_DEBUG=1 (or ACP_DEBUG=true).

autoUpdate

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: On Pi startup, check the npm registry for a newer version of billion-context-pi and auto-install it. Set to false to avoid all startup network calls. Can also be disabled via the ACP_AUTO_UPDATE environment variable (ACP_AUTO_UPDATE=0 or ACP_AUTO_UPDATE=false), which overrides this setting.
    • Read-only install location: when the copy's install prefix is not writable (e.g. a root-owned npm i -g global prefix), auto-update stops retrying that location after the first EACCES/permission failure and shows a one-time hint to run npm i -g billion-context-pi (or remove the global copy if you rely on pi's bundled install) instead of looping. The check throttle and the stop-retry marker are keyed per install location, so a healthy copy never suppresses a failing one's checks.
    • Two parallel mechanisms: this extension-side auto-update is independent of pi's own core update banner — both can appear, and disabling one does not disable the other.

modelContextLimit

  • Type: number
  • Default: (auto) — the model's contextWindow read live each turn
  • Status: 🟢 ACTIVE
  • Description: Override the context limit, in tokens. By default the limit is read from the active model's ctx.model.contextWindow on every turn, so it stays correct when you switch models. Set an explicit value for deterministic test runs or headless/non-interactive sessions where the model metadata may be unavailable. The ACP_MODEL_CONTEXT_LIMIT environment variable takes precedence over this value.

outputHeadroomMaxPct

  • Type: number | string (ratio or percent string)
  • Default: 0.25
  • Status: 🟢 ACTIVE
  • Description: Caps the output-headroom reservation as a fraction of the context window: reserved = min(model.maxTokens, pct × window). The reservation keeps the kernel's nudge/truncate bands below (window − reserved) so a long reply cannot push input + output past the window on APIs that count output against the window (all except Anthropic Messages, which enforces its input limit independently and is exempt). Without a cap, models whose registered max output is a large share of the window (e.g. 131072 on a 262144 window) lose most of their input budget — the 75% force-compress band then fires at roughly a third of the full window. The 0.25 default bounds that loss while still guaranteeing any single-turn reply up to 25% of the window fits at the 95% emergency threshold; longer replies overflow once and are recovered by the overflow self-heal on the next turn. Accepts a ratio (0.25) or percent string ("25%"). Set 0 to disable the reservation entirely; 1 (or greater) restores the legacy full-capability reservation.

toolBashDefaultTimeout

  • Type: number
  • Default: 60
  • Status: 🟢 ACTIVE
  • Description: The number of seconds injected into the bash tool when the model omits the timeout parameter. Pi has no built-in default timeout of its own, so without this guard a command the model forgets to time out can hang for thousands of seconds. On timeout the model is guided to re-run the command with a larger timeout. Set to 0 to disable this guard and restore Pi's unbounded behavior.

toolOutputMaxBytes

  • Type: number
  • Default: 200000
  • Status: 🟢 ACTIVE
  • Description: A hard byte cap (~200 KB, roughly 5000 lines) applied to tool result text via the tool_result hook. It stops runaway output that Pi's own caps cannot catch (for example, from tools Pi does not cap). When the cap fires, the oversized text is head-truncated with a notice telling the model how to see the full output. Set lower (e.g. 8192) for a tighter context budget, or set to 0 to disable the cap entirely.

Delegate

The delegate sub-object controls the acp_delegate sub-agent tool family (acp_delegate, acp_delegate_wait, acp_delegate_cancel) and how their token usage is reported.

Backward compatibility: For convenience, delegate accepts both an object and a boolean shorthand:

  • delegate: true is treated as delegate: { enabled: true }.
  • The legacy flat top-level displayUsage key is still accepted as an alias for delegate.displayUsage. Prefer the nested delegate.displayUsage form.

delegate.enabled

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: Enable the acp_delegate tools (acp_delegate, acp_delegate_wait, acp_delegate_cancel) and the system-prompt section that describes them. Set to false to skip registering them entirely — for example, if you use a different sub-agent extension, or when running headless where async result injection adds no value.

delegate.displayUsage

  • Type: string enum "merged" | "separate"
  • Default: "separate"
  • Status: 🟢 ACTIVE
  • Description: Controls how delegate sub-agent token usage is reported back to the main session. "separate" (default) tracks delegate tokens in a separate accumulator — the main session totals stay clean and delegate usage shows as its own block in acp_status (excluded from main totals). "merged" folds delegate token usage into the tool-result usage field so it is counted as part of the main session totals. Only meaningful when delegate.enabled is true.

delegate.maxDepth

  • Type: integer ≥ 1
  • Default: 2
  • Status: 🟢 ACTIVE
  • Description: Maximum nesting depth for acp_delegate. Depth counts how far a session sits below the main session (main = 0); a session may only spawn a delegate while its own depth is below this limit, so a session at the limit becomes a leaf and cannot delegate again. The default 2 allows main → delegate → sub-delegate; set 1 for an orchestrator / leaf-worker pattern where delegates never nest further. The resolved limit is propagated to children via the internal PI_ACP_DELEGATE_MAX_DEPTH environment variable, so it binds the whole delegation tree even if a child loads a different project acp.json. Invalid values (non-integer, < 1) fall back to the default with a warning log. Environment override: PI_ACP_DELEGATE_MAX_DEPTH (takes precedence over this key).

delegate.syncTimeoutMinutes

  • Type: number (minutes, fractional allowed) or 0 / null
  • Default: 5
  • Status: 🟢 ACTIVE
  • Description: Hard timeout for synchronous acp_delegate calls — the child process is killed (SIGTERM) if it has not finished within this window. Set 0 (or null) to run synchronous delegates without a hard timeout. Fractional minutes are accepted (e.g. 0.5 = 30s). Invalid values fall back to the default with a warning log. Environment override: PI_ACP_DELEGATE_SYNC_TIMEOUT_MINUTES (0 disables).

delegate.idleTimeoutMinutes

  • Type: number (minutes, fractional allowed) or 0 / null
  • Default: 5
  • Status: 🟢 ACTIVE
  • Description: Idle watchdog for async delegate children: if a child produces no output for this long, it is considered hung and force-finished. This is the primary defense against a stuck child holding its stdout pipe open. Set 0 (or null) to disable it — ACP logs a prominent warning when you do; acp_delegate_cancel remains available as a manual escape hatch. Fractional minutes are accepted. Invalid values fall back to the default with a warning log. Environment override: PI_ACP_DELEGATE_IDLE_TIMEOUT_MINUTES (0 disables).

delegate.asyncTimeoutMinutes

  • Type: number (minutes, fractional allowed) or 0 / null
  • Default: 30
  • Status: 🟢 ACTIVE
  • Description: Absolute hard limit for asynchronous delegate children, regardless of activity. Set 0 (or null) to run long tasks without an absolute cap — the idle watchdog still applies unless separately disabled. Fractional minutes are accepted. Invalid values fall back to the default with a warning log. Environment override: PI_ACP_DELEGATE_ASYNC_TIMEOUT_MINUTES (0 disables).

delegate.maxConcurrent

  • Type: number (integer ≥ 1)
  • Default: unlimited (no concurrency cap)
  • Status: 🟢 ACTIVE
  • Environment override: PI_ACP_DELEGATE_MAX_CONCURRENT (takes precedence over this key)
  • Description: Caps how many background (async: true) delegates run at the same time. When the limit is reached, further launches are held in a FIFO queue and start automatically as soon as a slot frees, so nothing is dropped — they just wait their turn. Set 1 to force strictly serial execution (useful on low-power machines where parallel sub-agents contend for CPU and time out). Sync (async: false) calls always run immediately and are not affected by this cap. Invalid values (non-integers or < 1) fall back to unlimited with a warning rather than failing the session. Only meaningful when delegate.enabled is true.

delegate.thinkingLevel

  • Type: string enum "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"
  • Default: (unset — each child uses Pi's own default)
  • Status: 🟢 ACTIVE
  • Description: Global default thinking level applied to every delegate when neither the per-call thinkingLevel nor the role's own thinkingLevel (see delegate.agents) is set. Without any value at all levels, no --thinking flag is passed and each child runs on Pi's own default. An invalid value is ignored with a warning logged (it never fails the run). A per-call acp_delegate({ thinkingLevel }) always wins over this global.

delegate.agents

  • Type: object — map of role name → { model?, thinkingLevel? }
  • Default: (unset — all roles inherit the parent model + Pi defaults)
  • Status: 🟢 ACTIVE
  • Description: Per-role defaults so long-lived automation can pin a cheaper or more capable model and thinking level per delegate role without the main agent having to fill them in on every call. Keys are role names (reviewer, researcher, worker, planner, oracle, or any custom role). Each value may set:
    • model ("provider/id") — this role's default model. Resolution priority: per-call model > this role's model > parent agent's current model. A value that isn't a valid "provider/id" is ignored. If the configured model doesn't exist in the live registry, the child falls back to the parent model and a warning is logged — it never fails.
    • thinkingLevel — this role's default thinking level (same enum as delegate.thinkingLevel). Priority: per-call > role > global.
{
  "delegate": {
    "thinkingLevel": "low",
    "agents": {
      "reviewer": { "model": "opencode-go/deepseek-v4-flash", "thinkingLevel": "high" },
      "worker":   { "model": "anthropic/claude-sonnet-4-5" },
      "oracle":   { "model": "openai/gpt-5", "thinkingLevel": "xhigh" }
    }
  }
}

Provider Throttle Retry

The throttleRetry key controls auto-retry of provider-side token rate-limit errors — e.g. AWS Bedrock's per-minute token-throughput quota, whose standard error text is "Too many tokens, please wait before trying again." When the relay streams that error as content with a non-standard finish_reason, it surfaces to Pi as Provider finish_reason: error_finish and fails the turn immediately: Pi's built-in retry does not recognize the signature, and it must not be treated as context overflow.

How it works:

  1. When a turn ends in a recognized throttle error, ACP rewrites the error so Pi's native retry re-runs the same turn (no duplicate user message, the error is kept out of the LLM context, native TUI retry indicator). Pi's native budget is small and fast (3 attempts, 2s base).
  2. When a run still ends in a throttle error and ACP's budget allows, ACP waits a progressive delay (default: 60s, 120s, 240s, … capped at 5 minutes), then sends one auto-marked user message (starts with [ACP:provider-throttle]) that resumes the interrupted step.
  3. The model is instructed to resume where it left off (system-prompt note). Sending new input during a wait cancels the pending retry. When the budget is exhausted, the error is surfaced to you unchanged.

Not retried (deliberately left to Pi's own behavior or to fail-fast): real context-overflow errors (prompt is too long, …), quota/billing exhaustion (quota exceeded, billing, …), and generic 429s.

Strict pacing (optional): By default ACP lets Pi's native fast retries run first and then paces on its own. If you want only ACP's paced kicks (e.g. a very tight tokens/minute quota), additionally set Pi's own retry off via "retry": { "enabled": false } in ~/.pi/settings.json.

throttleRetry

  • Type: boolean | object
  • Default: true (object form with all defaults)
  • Status: 🟢 ACTIVE
  • Description: Enable/disable auto-retry of provider token rate-limit errors and tune its budget. throttleRetry: false disables the feature entirely. Object form (any subset):
{
  "throttleRetry": {
    "enabled": true,
    "maxRetries": 10,
    "baseDelayMs": 60000,
    "maxDelayMs": 300000,
    "backoffMode": "exponential"
  }
}

throttleRetry.enabled

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: Turn the feature on/off. false (or top-level throttleRetry: false) restores the original fail-fast behavior.

throttleRetry.maxRetries

  • Type: number (integer ≥ 1)
  • Default: 10
  • Status: 🟢 ACTIVE
  • Description: Total budget of ACP-driven retries for one error episode (a run ending in the same throttle error, plus the paced kicks it triggers). Any successful non-error response — or a new user message — starts a fresh episode. Exhausted → the error is surfaced to you as-is.

throttleRetry.baseDelayMs

  • Type: number (milliseconds)
  • Default: 60000
  • Status: 🟢 ACTIVE
  • Description: Delay before the first paced kick — sized for Bedrock's per-minute rolling quota window. maxDelayMs is forced to at least this value.

throttleRetry.maxDelayMs

  • Type: number (milliseconds)
  • Default: 300000
  • Status: 🟢 ACTIVE
  • Description: Cap for paced kick delays in "exponential" mode (60s → 120s → 240s → 300s → 300s … with defaults). Ignored in "fixed" mode, which always uses baseDelayMs.

throttleRetry.backoffMode

  • Type: string enum "exponential" | "fixed"
  • Default: "exponential"
  • Status: 🟢 ACTIVE
  • Description: Delay progression between paced kicks: "exponential" doubles the delay per kick (capped at maxDelayMs); "fixed" repeats baseDelayMs every kick.

Tool Call Repetition Guard

The repetitionGuard key breaks infinite loops of byte-identical tool calls. Small greedy-decoding models can get stuck re-emitting the exact same (tool call → tool result) pair turn after turn — e.g. calling acp_status {"scope":"uncompressed","view":"ranges"} dozens of times with identical arguments while context grows each round. Token-level penalties cannot break this, because it is a sequence-level attractor (the repetition crosses turn boundaries), not a within-sequence token repetition.

The guard fingerprints each tool call as sha1(toolName + canonical-JSON(args)), where the JSON serialization sorts object keys so that only the arguments matter — key order and result content are ignored. It tracks the length of the current run of consecutive identical calls per session:

  • At warn consecutive identical calls, a strong warning is appended to the matching tool result telling the model to stop repeating the call.
  • At abort consecutive identical calls, the call is blocked (not executed), the turn is aborted, and a terminal notification is shown.

Any change to the arguments (or a switch to a different tool) resets the counter, as does a real user message (extension-sent messages do not reset it).

repetitionGuard

  • Type: boolean | object

  • Default: true

  • Status: 🟢 ACTIVE

  • Description: Enable/disable the repetition breaker and tune its thresholds. repetitionGuard: false disables it entirely. Object form (any subset):

    {
      "repetitionGuard": {
        "enabled": true,
        "warn": 3,
        "abort": 5
      }
    }
    

repetitionGuard.enabled

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: Turn the feature on/off. false (or top-level repetitionGuard: false) disables all repetition detection.

repetitionGuard.warn

  • Type: number
  • Default: 3
  • Status: 🟢 ACTIVE
  • Description: Number of consecutive byte-identical calls before a warning is appended to the tool result. Must be at least 1.

repetitionGuard.abort

  • Type: number
  • Default: 5
  • Status: 🟢 ACTIVE
  • Description: Number of consecutive byte-identical calls before the call is blocked and the turn is aborted. Must be greater than warn; if misconfigured lower, it is clamped up to warn + 1.

Degeneration Guard

The degenerationGuard key handles character-level degeneration: a model occasionally gets stuck repeating one single codepoint — observed in the wild as a thinking block ending in 4655 consecutive 「【」, escalating over turns until the turn aborts and the session dies (#351). Unlike repetitionGuard (byte-identical tool-call loops), this is a token-level attractor inside generated text/thinking itself.

Why the adapter must act: pi replays prior assistant thinking back to the provider on every subsequent request (openai-completions sends it as reasoning_content, or as plain text when the model requires thinking-as-text), and an aborted turn's partial message persists in the session log. A degenerated tail therefore rides along on every later prompt, where the model sees its own previous output ending in thousands of repeated characters — a continuation bias that re-triggers the same degeneration, aborts the next turn too, and leaves the session with no recovery path.

On every context event the guard scans assistant text/thinking blocks in the outgoing view:

  • Runs of one codepoint ≥ minRun are collapsed into a short marker (【【【… [4655× identical chars cut — degenerate repeat]) — up to 3 copies of the character are kept so the context stays legible. The pass is pure, idempotent and fail-safe; persisted history is never modified.
  • While the most recent assistant message is degenerated, a one-shot [ACP recovery notice] user message is appended telling the model that the repeated segment carries no information and to resume from its last valid step. It is position-based self-limiting: once the model produces a fresh turn the old message is no longer last and the notice disappears — no persistent state, no accumulation.
  • A terminal notification echoes the collapse once per session + run signature.

Tool-call arguments are never rewritten (rewriting them would desync the model's view from the call that actually executed). Detection runs on the persisted originals, not the outgoing view: thinking-only aborted turns never reach the outgoing view (empty assistant text would 400 on OpenAI-compatible providers), yet they are still "the previous turn" for the model's continuation, so the notice fires there too.

degenerationGuard

  • Type: boolean | object

  • Default: true

  • Status: 🟢 ACTIVE

  • Description: Enable/disable the degenerate-repeat guard and tune its threshold. degenerationGuard: false disables it entirely. Object form (any subset):

    {
      "degenerationGuard": {
        "enabled": true,
        "minRun": 200
      }
    }
    

degenerationGuard.enabled

  • Type: boolean
  • Default: true
  • Status: 🟢 ACTIVE
  • Description: Turn the feature on/off. false (or top-level degenerationGuard: false) disables all degeneration detection and the recovery notice.

degenerationGuard.minRun

  • Type: number
  • Default: 200
  • Status: 🟢 ACTIVE
  • Description: Minimum length of a single-codepoint run (counted in codepoints, surrogate-pair safe) before it is treated as degeneration. Legitimate runs in coding sessions (markdown hrules, dotted leaders) stay well below this; observed pre-degeneration drift maxed at ~60 before the catastrophic 4655 run. Values below 8 are raised to 8 (keeps the collapse marker itself re-scan safe); invalid values fall back to 200 with a logged warning.

Compression Tuning

The compress sub-object groups the three thresholds that form a three-tier escalation for context management. They control when the model is nudged to compress and when large outputs are forcibly truncated to keep the session alive. Lower thresholds mean the extension compresses earlier and more aggressively.

The flow is:

  1. Growth-driven soft nudges (0–75%) — governed by compress.nudgeGrowthTokens.
  2. Forced nudges (75–95%) — once usage crosses compress.maxContextLimit, nudges fire regardless of the growth gate. These are lossless.
  3. Emergency truncation (95%+) — once usage crosses compress.emergencyThresholdPercent, large tool outputs are truncated to prevent context overflow. This is lossy.

compress.maxContextLimit

  • Type: number | string
  • Default: 0.75 (or "75%")
  • Status: 🟢 ACTIVE
  • Description: The context-usage threshold that triggers forced compression nudges. Once usage reaches this level, nudges fire on every turn, bypassing the growth-gate and cadence checks that normally throttle them. Accepts a ratio (0.75) or a percent string ("75%"). A lower value makes the extension compress earlier and more aggressively. Maps to the kernel setting nudge.maxContextLimitPct.

compress.emergencyThresholdPercent

  • Type: number | string
  • Default: 0.95 (or "95%")
  • Status: 🟢 ACTIVE
  • Description: The context-usage threshold that triggers emergency truncation of large tool outputs to keep the session alive when context is nearly full. Accepts a ratio (0.95) or a percent string ("95%"). This value must be greater than or equal to compress.maxContextLimit, otherwise the escalation order breaks. Maps to the kernel settings nudge.emergencyThresholdPct and truncate.threshold.

compress.nudgeGrowthTokens

  • Type: number
  • Default: 50000
  • Status: 🟢 ACTIVE
  • Description: The token-growth threshold that controls the cadence of soft compression nudges. A soft nudge fires roughly every time this many tokens of new compressible content accumulate. A lower value means the model is nudged to compress more often; a higher value means less frequent nudges. This only governs growth-driven nudges — once usage crosses compress.maxContextLimit, forced nudges take over regardless of this setting. Maps to the kernel settings nudge.growthFloor and nudge.growthCap.
  • Same-turn re-inject: within one user turn a nudge injects at most once, but once the context has since grown by a full growth floor (mirroring the kernel's anti-thrashing cadence: max(minGrowthFloor, minGrowthRatio × adaptiveGrowth) — 22.5K tokens with defaults) a fresh reminder re-injects in the same turn (issue #269: a model that ignored a 78% nudge used to stay silent until the 95% emergency truncation). After a successful compress the growth baseline re-anchors to the new (smaller) scale, so post-compress regrowth into the pressure band is not held against the pre-compress peak.

compress.reasoning

  • Type: object{ "drop": boolean, "threshold": number }

  • Default: { "drop": true, "threshold": 2048 }

  • Status: 🟢 ACTIVE

  • Description: Config for dropping oversized reasoning (thinking) parts from historical compress tool calls — exact semantic alignment with opencode-acp #377. compress calls are hard-exempt from compression (their tool results anchor the block summaries), so their thinking rides along every request as an unreclaimable context floor. A request-time pass removes thinking parts from a message only when all gates hold:

    1. Closed turn — the message is strictly before the last genuine user message; the active round is never touched (some providers require replaying the active round's thinking).
    2. Selector — the message carries a toolCall part with name compress (only compress; other protected tools would need their own explicit config).
    3. Size — the message's total reasoning length (chars, summed across parts of that message, never across messages) strictly exceeds threshold. 0 drops any non-empty reasoning.

    Persisted history is never modified — the pass only rewrites the outgoing view, rebuilt fresh from the session log on every request. Pure, idempotent, fail-safe (any error leaves messages untouched). Merged field-wise (drop, threshold separately) across the three levels of compress.providers.

    Fields:

    • drop (boolean, default true) — master switch; false disables the pass (kill-switch).
    • threshold (number, chars, default 2048) — single-thinking size gate.

    Providers whose thinking items are opaque and must round-trip unmodified (e.g. OpenAI encrypted reasoning) can opt out per-provider:

    { "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } }
    

    Strict-echo thinking upstreams (auto-disabled). A few thinking-mode providers reject a rebuilt request with HTTP 400 (The \reasoning_content` ... must be passed back to the API) once a closed-round assistant message loses its reasoning. The adapter detects **DeepSeek** statically — the model's baseUrlor provider name containsdeepseek(case-insensitive) — and forcesdrop: falsefor that model automatically, overriding an explicitdrop: truefor safety. This is cost-free for non-thinking DeepSeek models, which emit nothinkingparts to drop. Strict-echo providers **not** on adeepseek` host — GLM-thinking, QwQ, self-hosted DeepSeek mirrors — are deliberately not auto-detected (that would disable the pass for their non-thinking models); use the per-provider override above for those. Twin fixes: proxy-side billion-context#690 and kernel-side fold atomicity acp-kernel#245 (shipped in acp-kernel 0.0.63); tracked in #361.

compress.providers — per-provider & per-model overrides

  • Type: object — a map of provider name → { ...<compress fields>, models: { modelId → <compress fields> } }
  • Default: (unset — global compress.* applies to all models)
  • Status: 🟢 ACTIVE
  • Description: Narrows the global thresholds for a specific Pi provider and/or a specific model, resolved live each turn from the active model. The three levels cascade per-field, deepest-wins: model > provider > global. A field left undefined at a deeper level does not clear a shallower value — only a field you actually set overrides. Unknown providers/models fall back to the global thresholds.

The provider key is the Pi provider name (e.g. "anthropic", "openai", "zhipu") — the same name used in models.json and pi --provider. The model key is the model id (ctx.model.id). The adapter sits at Pi's model layer and never sees the upstream URL, so it matches providers by name, not by URL prefix like the billion-context proxy does.

{
  "compress": {
    "maxContextLimit": "75%",
    "emergencyThresholdPercent": "95%",
    "nudgeGrowthTokens": 50000,
    "providers": {
      "anthropic": {
        "maxContextLimit": "80%",
        "models": {
          "claude-sonnet-4-5": { "maxContextLimit": "70%", "nudgeGrowthTokens": 30000 }
        }
      }
    }
  }
}

On anthropic / claude-sonnet-4-5 the effective thresholds become maxContextLimit=70%, nudgeGrowthTokens=30000, and emergencyThresholdPercent=95% (inherited from global).


Prompts Customization

The prompts object overrides acp-kernel's load-bearing compression prompt rules — the verbatim instructions the model receives about how to write summaries (keep full file paths, function signatures, decisions and rationale; drop verbose logs, etc.). These four fields are embedded into the system prompt and the compression nudge text:

FieldWhat it governs
compressPhilosophyThe two failure modes to avoid (over-/under-compression) and the single test for when to compress.
howToCompressRulesTier-1 rules: what to KEEP verbatim vs DROP, and the summary priority order.
tier2DistillRulesTier-2 distillation rules (decisions/outcomes only).
tier3CondenseRulesTier-3 ultra-condensation rules (bare facts).

⚠️ Quality risk. These rules are tuned for retrieval quality. Replacing them with looser text can silently degrade summaries — lost paths, signatures, and decisions lead to worse reconstruction later. The acknowledgePromptsRisk gate exists to make this an explicit, deliberate choice.

prompts

  • Type: object (partial — omit fields to keep their defaults)

  • Default: (kernel defaults) — the verbatim rules shipped with acp-kernel

  • Status: 🟢 ACTIVE

  • Description: Override one or more of the four compression prompt fields. Each field you set replaces the kernel default verbatim; fields you omit are inherited unchanged. Non-string values are silently dropped (only deliberate string overrides apply). Requires acknowledgePromptsRisk: true — without it, every override is dropped and the defaults are used, with a warning logged. Example:

    {
      "prompts": {
        "compressPhilosophy": "Compress aggressively; prefer signal over completeness.",
        "howToCompressRules": "Keep file paths + signatures verbatim. Drop verbose logs.",
        "tier2DistillRules": "Decisions and outcomes only; drop process and paths.",
        "tier3CondenseRules": "One line per block: bare facts only."
      },
      "acknowledgePromptsRisk": true
    }
    

acknowledgePromptsRisk

  • Type: boolean
  • Default: false
  • Status: 🟢 ACTIVE
  • Description: The safety gate for prompts overrides. Set to true to acknowledge that replacing the kernel's tuned compression rules may reduce summary quality, and to make your prompts overrides take effect. When false (or omitted), all prompts overrides are ignored and the kernel defaults are used. If resolvePrompts rejects your override (for example a malformed value that still passes the type check), the extension falls back to the defaults and logs a prompts-resolve-failed warning rather than failing to start.

Environment Variables

Environment variables take precedence over the JSON config files. They are useful for one-off overrides, CI runs, and headless sessions where you want to avoid editing config files.

ACP_AUTO_UPDATE

  • Type: string flag
  • Default: (unset — auto-update follows the autoUpdate config)
  • Status: 🟢 ACTIVE
  • Description: Set to 0 or false to disable auto-update (same effect as "autoUpdate": false). Leave unset to honor the config. This is the recommended way to disable startup network calls in locked-down environments without modifying acp.json.

ACP_MODEL_CONTEXT_LIMIT

  • Type: integer (tokens)
  • Default: (unset — limit follows modelContextLimit, then the live model context window)
  • Status: 🟢 ACTIVE
  • Description: Override the context limit, in tokens. Takes the highest precedence — overrides the modelContextLimit config value. Useful for forcing a specific limit in test harnesses and headless runs where model metadata is unavailable or unreliable.

ACP_DEBUG

  • Type: string flag
  • Default: (unset — debug logging follows the debug config)
  • Status: 🟢 ACTIVE
  • Description: Set to 1 or true to enable debug-level logging. Equivalent to setting "debug": true in the config, but applied without editing a file. The always-on lifecycle/error/warning events are written regardless.

ACP_LOG_FILE

  • Type: string (file path)
  • Default: ~/.pi/acp.log
  • Status: 🟢 ACTIVE
  • Description: Override the path to the log file. By default, structured logs are written to ~/.pi/acp.log (the file rotates to ~/.pi/acp.log.old at 10 MB). Point this at a different location to keep per-project or per-run logs separate.

PI_ACP_DELEGATE_MAX_DEPTH

  • Type: integer ≥ 1
  • Default: (unset — follows delegate.maxDepth, then 2)
  • Status: 🟢 ACTIVE
  • Description: Override the maximum delegate nesting depth for one session without editing config. Takes precedence over delegate.maxDepth. The resolved value is what gets propagated down the delegation tree. Do not set this manually mid-tree: it is also the internal variable ACP uses to pass the effective limit into child processes.

PI_ACP_DELEGATE_SYNC_TIMEOUT_MINUTES

  • Type: number (minutes) or 0
  • Default: (unset — follows delegate.syncTimeoutMinutes, then 5)
  • Status: 🟢 ACTIVE
  • Description: Override the synchronous acp_delegate hard timeout. Set 0 to disable the sync hard timeout for one session. Takes precedence over delegate.syncTimeoutMinutes.

PI_ACP_DELEGATE_IDLE_TIMEOUT_MINUTES

  • Type: number (minutes) or 0
  • Default: (unset — follows delegate.idleTimeoutMinutes, then 5)
  • Status: 🟢 ACTIVE
  • Description: Override the async idle watchdog window. Set 0 to disable the idle watchdog for one session (ACP logs a warning; acp_delegate_cancel remains as a manual escape hatch). Takes precedence over delegate.idleTimeoutMinutes.

PI_ACP_DELEGATE_ASYNC_TIMEOUT_MINUTES

  • Type: number (minutes) or 0
  • Default: (unset — follows delegate.asyncTimeoutMinutes, then 30)
  • Status: 🟢 ACTIVE
  • Description: Override the absolute hard limit for async delegate children. Set 0 to run long tasks without an absolute cap (the idle watchdog still applies unless disabled). Takes precedence over delegate.asyncTimeoutMinutes.

PI_ACP_DELEGATE_MAX_CONCURRENT

  • Type: integer (≥ 1)
  • Default: (unset — cap follows delegate.maxConcurrent, then unlimited)
  • Status: 🟢 ACTIVE
  • Description: Override the background (async) delegate concurrency cap. Takes precedence over delegate.maxConcurrent. Set to 1 for forced serial execution. Invalid values fall back to the next source (then unlimited) with a warning rather than failing the session.