Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions devlog/2026-08-16_model-switch-limit-catalog/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# REQ - Fix wrong context-level math after model switch (issue #312)

- Task ID: `2026-08-16_model-switch-limit-catalog`
- Home Repo: `opencode-acp`
- Created: 2026-08-16
- Status: Done
- Priority: P1
- Owner: ranxianglei
- References: [issue #312](https://github.com/ranxianglei/opencode-acp/issues/312)

## 1. Background & Problem Statement

- **Context**: `state.modelContextLimit` is written exclusively by the
`experimental.chat.system.transform` hook (lib/hooks.ts). Every percentage
threshold derives from it: `compress.emergencyThresholdPercent`,
`compress.min/maxContextLimit` in `"N%"` form, adaptive `nudgeGrowthTokens`
(5% of limit clamped [6K, 50K]), GC tier thresholds, and
`buildCompressedBlockGuidance` context hints.
- **Root cause** (host-source-confirmed, sst/opencode dev HEAD): within one LLM
request the host fires `experimental.chat.messages.transform` FIRST
(packages/opencode/src/session/prompt.ts:1255, before `handle.process`) and
`experimental.chat.system.transform` SECOND
(packages/opencode/src/session/llm/request.ts:69-73, inside `prepare()` under
`handle.process`). The messages hook runs all nudge/threshold math BEFORE the
system hook writes the new model's limit.
- **Current behavior (symptom)**: switch mid-session from a 200K model to a 1M
model with `emergencyThresholdPercent: "50%"` → the first request(s) after the
switch compute the threshold as 50% × 200K = 100K. At ~260K actual context the
user is told the context is at "emergency" while it is really at 26% of the 1M
window. Conversely, switching 1M → 200K misses a real emergency until the
system hook catches up on the following request.
- **Expected behavior**: threshold math on the FIRST request after a model
switch uses the new model's context window.
- **Impact**: premature (or missed) emergency compression nudges, distorted
adaptive nudge growth, wrong GC tier evaluation for one request after every
model switch.

## 2. Reproduction

- **Minimal reproduction steps**:
1) Use a session on a 200K-context model; let `state.modelContextLimit = 200000`.
2) Set `compress.emergencyThresholdPercent: "50%"`.
3) Switch the model to a 1M-context model; send a message bringing context to ~260K.
4) First request after the switch: emergency nudge "Context limit reached" fires
(260K ≥ 0.5 × 200K stale limit) even though usage is 26% of 1M.
- **Relevant configuration**: `compress.emergencyThresholdPercent: "50%"`; any
model switch between different context windows (user report: 20w → 100w).

## 3. Constraints & Non-Goals

- **Non-Goals**: changing host dispatch order; persisting per-model limits to
disk; changing `modelContextLimit` semantics for same-model requests.
- **Constraints**: no `any` (repo lint rule); no new dependencies; tests must
pass under `node --import tsx --test tests/*.test.ts`.

## 4. Acceptance Criteria

- [x] First request after a model switch resolves the new model's context limit
before any threshold math runs.
- [x] Unknown model (no catalog entry) falls back to previous behavior (stale
limit for one request, corrected by system hook later in that request).
- [x] Catalog populated both live (per request via system hook) and at plugin
init (GET /config/providers).
- [x] All existing tests pass (988 pass, 0 fail) + new regression tests.

## 5. Proposed Approach

`SessionStateRegistry` gains a `${providerID}/${modelID}` → context-limit
catalog. The system hook records every observed model's limit into it
(statelessly, before the session-state guard). The messages hook, right after
resolving the session state, reconciles `state.modelContextLimit` from the
catalog entry for the model named on the request's last user message. At plugin
init the catalog is seeded best-effort from `client.config.providers()`
(returns all providers + models with `limit.context`).
57 changes: 57 additions & 0 deletions devlog/2026-08-16_model-switch-limit-catalog/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# WORKLOG - Fix wrong context-level math after model switch (issue #312)

- Task ID: `2026-08-16_model-switch-limit-catalog`
- Home Repo: `opencode-acp`
- Status: Done
- Updated: 2026-08-16

## 1. Summary

- **What was done**: `SessionStateRegistry` now keeps a `${providerID}/${modelID}` → context-limit catalog. The system hook records every observed model's limit into it; the messages hook reconciles `state.modelContextLimit` from the catalog (keyed by the model on the request's user message) before any threshold math; plugin init seeds the catalog from `GET /config/providers`.
- **Why**: Within one LLM request the host fires `messages.transform` BEFORE `system.transform`, and only the latter wrote `modelContextLimit` — so the first request after a model switch computed every percentage against the previous model's window (issue #312: 50% emergency fired at 26% after 200K → 1M).
- **Behavior / compatibility changes**: Yes — threshold math now uses the new model's limit on the first request after a switch. Unknown models keep the previous fallback (stale limit for one request). No persisted-state, config-schema, or exported-API changes.
- **Risk level**: Low

## 2. Change Log

### Key Files

- `lib/state/state.ts` — `SessionStateRegistry.modelLimits: Map<string, number>` + `recordModelLimit(providerId, modelId, limit)` + `resolveModelLimit(providerId, modelId)` + `hydrateModelLimitsFromClient(client): Promise<number>` (best-effort, never throws).
- `lib/hooks.ts` —
- `createSystemPromptHandler`: records `input.model.limit.context` into the catalog keyed by `providerID`/`id`, placed BEFORE the `registry.get` guard so it works even when the session state has not been created; system-hook input type widened to include optional `model.id` / `model.providerID`.
- `createChatMessageTransformHandler`: after `registry.getOrCreate`, reconciles `state.modelContextLimit` from `registry.resolveModelLimit` for the model on `lastUserMessage.info.model` (undefined-safe; unknown model keeps the previous value).
- `index.ts` — fire-and-forget `registry.hydrateModelLimitsFromClient(ctx.client).catch(() => {})` at init, so the FIRST switch in an instance resolves even for models never used before.
- `tests/registry-stub.ts` — `createTestRegistry` now exposes the same `recordModelLimit`/`resolveModelLimit` surface backed by a local `Map`.
- `tests/model-switch-limits.test.ts` — new: 7 regression tests.

## 3. Design & Implementation Notes

- **Why a catalog instead of just persisting the last limit**: the state carries ONE `modelContextLimit`; a switch needs the NEW model's limit before the system hook runs. A per-model map recorded from every system.transform call (plus an init-time seed from the host's provider catalog) resolves the lookup synchronously in the messages hook with no extra round-trip on the hot path.
- **Unknown-model fallback**: if the catalog misses (e.g. hydration failed and the model was never used in this instance), reconciliation is a no-op — exactly the pre-fix behavior, and the system hook still corrects the state later in the same request. This keeps the fix purely additive.
- **`hydrateModelLimitsFromClient` shape**: `client.config.providers()` returns `{ data: { providers: [{ id, models: { [modelId]: { limit: { context } } } }] } }` (SDK `ConfigProvidersResponses` / host `ConfigProvidersResult`). All field access is `unknown`-guarded; any failure returns 0.
- **Hot-path cost**: one `Map.get` per messages.transform. The hydration is one HTTP call per plugin init.

## 4. Testing & Verification

### Build & Test Commands

```sh
npx tsc --noEmit # clean
node --import tsx --test tests/model-switch-limits.test.ts # 7/7 pass
node --import tsx --test tests/*.test.ts # 988 pass, 0 fail
```

### Test Scenarios (tests/model-switch-limits.test.ts)

1. **200K → 1M switch, 260K tokens, `"50%"` emergency**: reconciled limit = 1M; no "Context limit reached" nudge; no nudge baseline recorded. (The issue #312 scenario.)
2. **Unknown model in catalog**: limit stays 200K; emergency fires at 260K — documents the pre-fix fallback path.
3. **1M → 200K switch, 150K tokens**: reconciled limit = 200K; emergency DOES fire (75% ≥ 50%) — stale 1M limit would have missed it.
4. **system.transform records into catalog even when session state is absent** (`sessionID: "never-seen"`).
5. **Catalog rejects invalid entries** (undefined ids, limit ≤ 0) and unknown lookups.
6. **`hydrateModelLimitsFromClient` seeds from a mocked `/config/providers` payload** (2 valid + 1 broken model).
7. **`hydrateModelLimitsFromClient` tolerates missing and throwing clients** (returns 0).

## 5. Follow-ups

- None blocking. Potential (not done, out of scope): debounced re-hydration if
providers change at runtime.
7 changes: 7 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ const server: Plugin = (async (ctx) => {
// logger.info("Secure mode detected, configured client authentication")
}

// [FIX #312] Seed the model-limit catalog so the FIRST request after a
// model switch resolves the new model's context window (the per-request
// system.transform refresh only fills entries for models already used in
// this instance). Fire-and-forget: failures fall back to per-request
// refresh, which is the pre-fix behavior.
registry.hydrateModelLimitsFromClient(ctx.client).catch(() => {})

logger.info("DCP initialized")

startAutoUpdate(ctx, config.autoUpdate)
Expand Down
35 changes: 34 additions & 1 deletion lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,24 @@ export function createSystemPromptHandler(
return async (
input: {
sessionID?: string
model: { limit: { context: number; input?: number; output?: number } }
model: {
id?: string
providerID?: string
limit: { context: number; input?: number; output?: number }
}
},
output: { system: string[] },
) => {
// [FIX #312] Record the live limit for this model BEFORE the state
// guard below: the catalog is stateless and must keep accepting
// entries even when the session state has not been created yet, so
// the messages hook can reconcile a model switch on its next call.
registry.recordModelLimit(
input.model?.providerID,
input.model?.id,
input.model?.limit?.context,
)

// messages.transform creates the session state before this fires; if
// absent (internal-agent early-return), there is nothing to attribute.
const state = input.sessionID ? registry.get(input.sessionID) : undefined
Expand Down Expand Up @@ -158,6 +172,25 @@ export function createChatMessageTransformHandler(
messages,
config,
)

// [FIX #312] system.transform (the only writer of
// state.modelContextLimit) fires AFTER messages.transform within
// one request, so on the first request after a model switch the
// value still reflects the previous model. Reconcile it from the
// catalog entry for the model named on this request's user message
// before any consumer (filters, GC, nudge thresholds) reads it.
// Unknown model → keep the previous value; the system hook
// refreshes it later in this same request anyway.
const requestModel = (
lastUserMessage.info as { model?: { providerID?: string; modelID?: string } }
).model
const requestModelLimit = registry.resolveModelLimit(
requestModel?.providerID,
requestModel?.modelID,
)
if (requestModelLimit !== undefined) {
state.modelContextLimit = requestModelLimit
}
await updatePerTurnState(state, logger, messages)
}

Expand Down
65 changes: 65 additions & 0 deletions lib/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,73 @@ export class SessionStateRegistry {
pendingByCallId: new Map<string, PendingCompressionDuration>(),
}

// [FIX #312] Catalog of per-model context limits, keyed `${providerID}/${modelID}`.
// Within one LLM request the host fires experimental.chat.messages.transform
// BEFORE experimental.chat.system.transform (sst/opencode: session/prompt.ts
// triggers messages.transform, then llm/request.ts triggers system.transform
// during handle.process). state.modelContextLimit is written only by the
// system hook, so on the first request after a model switch every percentage
// threshold (emergencyThresholdPercent, min/maxContextLimit "%", adaptive
// nudge growth, GC tiers) is still computed against the PREVIOUS model's
// limit. This catalog lets the messages hook reconcile against the model
// named on the request's user message instead of waiting one turn.
// Entries are recorded live by the system hook every request and seeded once
// at plugin init from the host's /config/providers catalog.
private readonly modelLimits = new Map<string, number>()

constructor(private readonly logger: Logger) {}

recordModelLimit(
providerId: string | undefined,
modelId: string | undefined,
limit: number | undefined,
): void {
if (!providerId || !modelId || typeof limit !== "number" || limit <= 0) return
this.modelLimits.set(`${providerId}/${modelId}`, limit)
}

resolveModelLimit(
providerId: string | undefined,
modelId: string | undefined,
): number | undefined {
if (!providerId || !modelId) return undefined
return this.modelLimits.get(`${providerId}/${modelId}`)
}

/**
* Best-effort one-time seed from the host's provider catalog
* (`client.config.providers()` → GET /config/providers). Never throws;
* returns the number of model-limit entries recorded.
*/
async hydrateModelLimitsFromClient(client: unknown): Promise<number> {
try {
const config = client as { config?: { providers?: () => Promise<{ data?: unknown }> } }
const result = await config.config?.providers?.()
const payload = result as { data?: { providers?: unknown } } | undefined
const providers = payload?.data?.providers
if (!Array.isArray(providers)) return 0
let recorded = 0
for (const provider of providers) {
const { id, models } = (provider ?? {}) as {
id?: unknown
models?: Record<string, unknown>
}
if (typeof id !== "string" || !models) continue
for (const [modelId, model] of Object.entries(models)) {
const limit = (model as { limit?: { context?: unknown } } | null)?.limit
const context = limit?.context
if (typeof context === "number" && context > 0) {
this.modelLimits.set(`${id}/${modelId}`, context)
recorded++
}
}
}
return recorded
} catch {
return 0
}
}

get(sessionId: string): SessionState | undefined {
return this.states.get(sessionId)
}
Expand Down
Loading
Loading