diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 14:58:40 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 14:58:40 +0900 |
| commit | e6e2d31b987043d0325a93d23295af0408b723ec (patch) | |
| tree | d3208766c1e8123b8ca81c7a7c5a91c2c269e536 | |
| parent | 519b79999d49156bbfaecc91a2d882fba2475fef (diff) | |
| download | dispatch-e6e2d31b987043d0325a93d23295af0408b723ec.tar.gz dispatch-e6e2d31b987043d0325a93d23295af0408b723ec.zip | |
feat: context window from model endpoints + percentage-based auto-compact
ModelInfo (kernel contract):
- Add contextWindow?: number field
OpenAI-stream listModels:
- Parse contextWindow from common field names (context_length,
context_window, max_context_length, max_tokens)
Transport-contract:
- ModelsResponse: add optional modelInfo map (model name → { contextWindow? })
- Add ModelMetadata type
- Rename CompactThresholdResponse → CompactPercentResponse
- Rename SetCompactThresholdRequest → SetCompactPercentRequest
Credential store:
- Add getModelInfo(modelName) method — resolves full ModelInfo
(including contextWindow) for a <credential>/<model> string
Transport-http:
- GET /models now includes modelInfo with contextWindow per model
- Rename compact-threshold endpoints → compact-percent
Session-orchestrator:
- Auto-compact now uses contextSize (not overcounted usage.inputTokens)
compared against contextWindow * (percent / 100)
- Default percent: 85 (was flat 350000)
- resolveModelInfo dep added to look up contextWindow
- Passes modelName from the settled turn to the compaction service
Conversation store:
- Rename getCompactThreshold/setCompactThreshold → getCompactPercent/setCompactPercent
- compactThresholdKey → compact-percent key
| -rw-r--r-- | backend-to-fe-handoff.md | 14 | ||||
| -rw-r--r-- | frontend-compaction-handoff.md | 36 | ||||
| -rw-r--r-- | packages/conversation-store/src/keys.ts | 2 | ||||
| -rw-r--r-- | packages/conversation-store/src/store.ts | 16 | ||||
| -rw-r--r-- | packages/credential-store/src/registry.ts | 26 | ||||
| -rw-r--r-- | packages/kernel/src/contracts/provider.ts | 2 | ||||
| -rw-r--r-- | packages/openai-stream/src/listModels.ts | 14 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/extension.ts | 8 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.test.ts | 16 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/orchestrator.ts | 41 | ||||
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 4 | ||||
| -rw-r--r-- | packages/transport-contract/src/index.ts | 21 | ||||
| -rw-r--r-- | packages/transport-http/src/app.test.ts | 26 | ||||
| -rw-r--r-- | packages/transport-http/src/app.ts | 32 | ||||
| -rw-r--r-- | packages/transport-http/src/extension.ts | 2 | ||||
| -rw-r--r-- | packages/transport-http/src/server.bun.test.ts | 7 | ||||
| -rw-r--r-- | tasks.md | 4 |
17 files changed, 184 insertions, 87 deletions
diff --git a/backend-to-fe-handoff.md b/backend-to-fe-handoff.md index 1e9ffb2..dcaaa51 100644 --- a/backend-to-fe-handoff.md +++ b/backend-to-fe-handoff.md @@ -45,7 +45,7 @@ after `runTurn` returns — backward compatible. |---|---|---| | `POST` | `/chat` | Stream a turn (NDJSON response, `X-Conversation-Id` header) | | `POST` | `/chat/warm` | Cache-warm probe | -| `GET` | `/models` | List available models | +| `GET` | `/models` | Model catalog (now includes `modelInfo` with `contextWindow` per model) | | `GET` | `/conversations` | List conversations (`?q=` prefix filter, `?status=active,idle` status filter) | | `GET` | `/conversations/:id` | Conversation history (`?sinceSeq=`, `?beforeSeq=`, `?limit=` windowing) | | `GET` | `/conversations/:id/metrics` | Per-turn metrics (tokens, timing) | @@ -55,8 +55,8 @@ after `runTurn` returns — backward compatible. | `GET` | `/conversations/:id/reasoning-effort` | Per-conversation reasoning effort | | `PUT` | `/conversations/:id/reasoning-effort` | Set reasoning effort | | `GET` | `/conversations/:id/lsp` | LSP server status | -| `GET` | `/conversations/:id/compact-threshold` | Auto-compact threshold (0=manual, null=default 350k) | -| `PUT` | `/conversations/:id/compact-threshold` | Set auto-compact threshold | +| `GET` | `/conversations/:id/compact-percent` | Auto-compact percent (0=manual, null=default 85%) | +| `PUT` | `/conversations/:id/compact-percent` | Set auto-compact percent | | `GET` | `/conversations/:id/title` | Read conversation title | | `PUT` | `/conversations/:id/title` | Set conversation title | | `POST` | `/conversations/:id/close` | Close tab (abort turn + mark `closed`) | @@ -119,13 +119,13 @@ interface CompactResponse { messagesKept: number; } -interface CompactThresholdResponse { +interface CompactPercentResponse { conversationId: string; - threshold: number; // 0 = manual; null = default 350000 + percent: number; // 0 = manual; null = default 85 } -interface SetCompactThresholdRequest { - threshold: number; +interface SetCompactPercentRequest { + percent: number; } ``` diff --git a/frontend-compaction-handoff.md b/frontend-compaction-handoff.md index 6979c29..572b25f 100644 --- a/frontend-compaction-handoff.md +++ b/frontend-compaction-handoff.md @@ -11,7 +11,7 @@ Creates a linked chain of archives you can walk backward. Two modes: - **Manual**: `POST /conversations/:id/compact` — triggers immediately. - **Automatic**: after each turn settles, the backend checks if the last turn's - input tokens exceeded the per-conversation `compactThreshold` (default 350000). + input tokens exceeded the per-conversation `compactThreshold` (default 85). If so, compaction runs automatically (fire-and-forget, non-blocking). ## How compaction works — non-destructive, chained @@ -84,13 +84,13 @@ export interface CompactResponse { readonly messagesKept: number; } -export interface CompactThresholdResponse { +export interface CompactPercentResponse { readonly conversationId: string; - readonly threshold: number; // 0 = manual only; null = default 350000 + readonly percent: number; // 0 = manual only; null = default 85 } -export interface SetCompactThresholdRequest { - readonly threshold: number; +export interface SetCompactPercentRequest { + readonly percent: number; } ``` @@ -107,24 +107,24 @@ Triggers compaction on demand. Optional JSON body: The conversation ID in the response is the same as the request — the ID doesn't change. The FE should reload the conversation history. -409: `{ error: string }` — conversation is generating, too short, threshold not exceeded, etc. +409: `{ error: string }` — conversation is generating, too short, percent not exceeded, etc. 503: compaction service not available. -## `GET /conversations/:id/compact-threshold` — read threshold +## `GET /conversations/:id/compact-percent` — read percent -200: `CompactThresholdResponse { conversationId, threshold }` -- `threshold: 0` — auto-compact explicitly disabled (manual only). -- `threshold: null` (not stored) — **default: 350000** (350k tokens). The FE - should display 350000 as the default value in the settings UI. +200: `CompactPercentResponse { conversationId, percent }` +- `percent: 0` — auto-compact explicitly disabled (manual only). +- `percent: null` (not stored) — **default: 85** (85% tokens). The FE + should display 85 as the default value in the settings UI. - Any positive number — auto-compact triggers when the last turn's input tokens exceed this value. -## `PUT /conversations/:id/compact-threshold` — set threshold +## `PUT /conversations/:id/compact-percent` — set percent -Body: `SetCompactThresholdRequest { threshold: number }` +Body: `SetCompactPercentRequest { percent: number }` - `0` explicitly disables auto-compact. -- Any positive number sets the trigger threshold. -- To "reset to default", set it to 350000. +- Any positive number sets the trigger percent. +- To "reset to default", set it to 85. ## `conversation.compacted` WS message @@ -141,9 +141,9 @@ No tab switching needed — the ID is the same. Show a loading indicator while waiting. On success, reload the conversation history (same ID — just re-fetch). -2. **Settings UI** for compact threshold: `PUT /conversations/:id/compact-threshold` - with `{ threshold: number }`. A number input (0 = manual only, default 350000). - Read the current value via `GET /conversations/:id/compact-threshold`. +2. **Settings UI** for compact percent: `PUT /conversations/:id/compact-percent` + with `{ percent: number }`. A number input (0 = manual only, default 85). + Read the current value via `GET /conversations/:id/compact-percent`. 3. **Handle `conversation.compacted` WS messages**: reload the conversation history via `GET /conversations/:id` (same ID, no tab switch). diff --git a/packages/conversation-store/src/keys.ts b/packages/conversation-store/src/keys.ts index a609f82..18c683f 100644 --- a/packages/conversation-store/src/keys.ts +++ b/packages/conversation-store/src/keys.ts @@ -55,7 +55,7 @@ export function reasoningEffortKey(conversationId: string): string { } export function compactThresholdKey(conversationId: string): string { - return `conv:${conversationId}:compact-threshold`; + return `conv:${conversationId}:compact-percent`; } export function metaKey(conversationId: string): string { diff --git a/packages/conversation-store/src/store.ts b/packages/conversation-store/src/store.ts index 700be1e..8c287ae 100644 --- a/packages/conversation-store/src/store.ts +++ b/packages/conversation-store/src/store.ts @@ -105,10 +105,10 @@ export interface ConversationStore { * non-destructively before replacing it with a summary. */ readonly forkHistory: (sourceId: string, targetId: string) => Promise<void>; - /** Get the compact threshold (token count, 0 = manual only), or null if unset. */ - readonly getCompactThreshold: (conversationId: string) => Promise<number | null>; - /** Set the compact threshold (token count, 0 = manual only). */ - readonly setCompactThreshold: (conversationId: string, threshold: number) => Promise<void>; + /** Get the compact percent (0-100, 0 = manual only), or null if unset. */ + readonly getCompactPercent: (conversationId: string) => Promise<number | null>; + /** Set the compact percent (0-100, 0 = manual only). */ + readonly setCompactPercent: (conversationId: string, percent: number) => Promise<void>; /** * Set the `compactedFrom` field on a conversation's metadata, pointing to * the archive conversation that holds the pre-compaction history. @@ -620,17 +620,17 @@ export function createConversationStore( if (effort !== null) await storage.set(reasoningEffortKey(targetId), effort); }, - async getCompactThreshold(conversationId) { + async getCompactPercent(conversationId) { const raw = await storage.get(compactThresholdKey(conversationId)); if (raw === null) return null; const n = Number.parseInt(raw, 10); return Number.isNaN(n) ? null : n; }, - async setCompactThreshold(conversationId, threshold) { - await storage.set(compactThresholdKey(conversationId), String(threshold)); + async setCompactPercent(conversationId, percent) { + await storage.set(compactThresholdKey(conversationId), String(percent)); if (logger !== undefined) { - logger.debug("compact-threshold set", { conversationId, threshold }); + logger.debug("compact-percent set", { conversationId, percent }); } }, diff --git a/packages/credential-store/src/registry.ts b/packages/credential-store/src/registry.ts index c1ba771..89d8084 100644 --- a/packages/credential-store/src/registry.ts +++ b/packages/credential-store/src/registry.ts @@ -1,4 +1,4 @@ -import type { ProviderContract } from "@dispatch/kernel"; +import type { ModelInfo, ProviderContract } from "@dispatch/kernel"; export interface Credential { readonly name: string; @@ -24,6 +24,13 @@ export interface CredentialStore { * missing or has no listModels. */ listCatalog(): Promise<readonly string[]>; + + /** + * Returns the full `ModelInfo` for a `<credentialName>/<model>` string, or + * undefined if unknown. Caches the result of `listModels` per credential. + * Used to look up `contextWindow` for auto-compaction. + */ + getModelInfo(modelName: string): Promise<ModelInfo | undefined>; } export interface CredentialStoreDeps { @@ -76,5 +83,22 @@ export function createCredentialStore(deps: CredentialStoreDeps): CredentialStor return results; }, + + async getModelInfo(modelName: string): Promise<ModelInfo | undefined> { + const slashIndex = modelName.indexOf("/"); + if (slashIndex === -1) return undefined; + const credentialName = modelName.slice(0, slashIndex); + const modelId = modelName.slice(slashIndex + 1); + if (!modelId) return undefined; + + const providerId = credentialMap.get(credentialName); + if (providerId === undefined) return undefined; + + const provider = deps.getProvider(providerId); + if (provider?.listModels === undefined) return undefined; + + const models = await provider.listModels(); + return models.find((m) => m.id === modelId); + }, }; } diff --git a/packages/kernel/src/contracts/provider.ts b/packages/kernel/src/contracts/provider.ts index 7f920c5..52d853b 100644 --- a/packages/kernel/src/contracts/provider.ts +++ b/packages/kernel/src/contracts/provider.ts @@ -112,6 +112,8 @@ export interface ProviderStreamOptions { export interface ModelInfo { readonly id: string; readonly displayName?: string; + /** The model's max context window in tokens (e.g. 200000). Optional — providers that don't report it leave it undefined. */ + readonly contextWindow?: number; } /** diff --git a/packages/openai-stream/src/listModels.ts b/packages/openai-stream/src/listModels.ts index 3f783f0..8a76c67 100644 --- a/packages/openai-stream/src/listModels.ts +++ b/packages/openai-stream/src/listModels.ts @@ -13,6 +13,10 @@ import type { FetchLike } from "@dispatch/trace-replay"; interface OpenAIModelEntry { readonly id: string; + readonly context_length?: number; + readonly context_window?: number; + readonly max_context_length?: number; + readonly max_tokens?: number; } interface OpenAIModelListResponse { @@ -21,10 +25,18 @@ interface OpenAIModelListResponse { /** * Pure mapping: raw OpenAI-compatible model list → ModelInfo[]. + * Extracts `contextWindow` from common field names (providers vary). * Extracted for direct unit testing with no I/O. */ export function parseModelList(data: readonly OpenAIModelEntry[]): readonly ModelInfo[] { - return data.map((entry) => ({ id: entry.id })); + return data.map((entry) => { + const contextWindow = + entry.context_length ?? entry.context_window ?? entry.max_context_length ?? entry.max_tokens; + return { + id: entry.id, + ...(contextWindow !== undefined ? { contextWindow } : {}), + }; + }); } export interface ListModelsConfig { diff --git a/packages/session-orchestrator/src/extension.ts b/packages/session-orchestrator/src/extension.ts index ae90902..fca8ddb 100644 --- a/packages/session-orchestrator/src/extension.ts +++ b/packages/session-orchestrator/src/extension.ts @@ -53,6 +53,10 @@ export function activate(host: HostAPI): void { const provider = host.getProviders().get(r.providerId); return provider ? { provider, model: r.model } : undefined; }, + resolveModelInfo: async (modelName: string) => { + const store = host.getService(credentialStoreHandle); + return store.getModelInfo(modelName); + }, applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), runTurn, logger: host.logger, @@ -116,6 +120,10 @@ export function activate(host: HostAPI): void { const provider = host.getProviders().get(r.providerId); return provider ? { provider, model: r.model } : undefined; }, + resolveModelInfo: async (modelName: string) => { + const store = host.getService(credentialStoreHandle); + return store.getModelInfo(modelName); + }, applyToolsFilter: (assembly) => host.applyFilters(toolsFilter, assembly), runTurn, logger: host.logger, diff --git a/packages/session-orchestrator/src/orchestrator.test.ts b/packages/session-orchestrator/src/orchestrator.test.ts index 012ac5c..ce134f4 100644 --- a/packages/session-orchestrator/src/orchestrator.test.ts +++ b/packages/session-orchestrator/src/orchestrator.test.ts @@ -91,10 +91,10 @@ function createInMemoryStore(): ConversationStore & { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -548,10 +548,10 @@ describe("turn-sealed event", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -621,10 +621,10 @@ describe("turn-sealed event", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -983,10 +983,10 @@ describe("turn metrics persistence", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; diff --git a/packages/session-orchestrator/src/orchestrator.ts b/packages/session-orchestrator/src/orchestrator.ts index 22a5c11..adf5680 100644 --- a/packages/session-orchestrator/src/orchestrator.ts +++ b/packages/session-orchestrator/src/orchestrator.ts @@ -6,6 +6,7 @@ import type { ConversationStatus, EventHookDescriptor, Logger, + ModelInfo, ProviderContract, ProviderEvent, ProviderStreamOptions, @@ -248,6 +249,12 @@ export interface SessionOrchestratorDeps { readonly resolveModel?: ( modelName: string, ) => { provider: ProviderContract; model: string } | undefined; + /** + * Resolve full `ModelInfo` (including `contextWindow`) for a model name. + * Used by the compaction service to calculate the auto-compact threshold + * as a percentage of the context window. + */ + readonly resolveModelInfo?: (modelName: string) => Promise<ModelInfo | undefined>; readonly runTurn: (input: RunTurnInput) => Promise<RunTurnResult>; /** * Lazily resolves the message-queue service (the steering queue), or @@ -519,7 +526,12 @@ export function createSessionOrchestrator( // starts fresh either way. const compaction = deps.resolveCompaction?.(); if (compaction !== undefined) { - void compaction.compact(conversationId, { auto: true }).catch(() => {}); + void compaction + .compact(conversationId, { + auto: true, + ...(payload.modelName !== undefined ? { modelName: payload.modelName } : {}), + }) + .catch(() => {}); } } }); @@ -730,7 +742,7 @@ export function createWarmService( } const DEFAULT_KEEP_LAST_N = 10; -const DEFAULT_COMPACT_THRESHOLD = 350000; +const DEFAULT_COMPACT_PERCENT = 85; const COMPACTION_SYSTEM_PROMPT = "You are a conversation summarizer. Summarize the following conversation concord concisely but comprehensively. " + @@ -772,17 +784,28 @@ export function createCompactionService( return { error: "conversation too short to compact" }; } - // Auto mode: check threshold (default 350k if not explicitly set; - // 0 explicitly disables). + // Auto mode: check if contextSize exceeds percent of contextWindow. if (opts?.auto === true) { - const stored = await deps.conversationStore.getCompactThreshold(conversationId); - const threshold = stored ?? DEFAULT_COMPACT_THRESHOLD; - if (threshold <= 0) return { error: "auto-compact disabled" }; + const stored = await deps.conversationStore.getCompactPercent(conversationId); + const percent = stored ?? DEFAULT_COMPACT_PERCENT; + if (percent <= 0) return { error: "auto-compact disabled" }; const metrics = await deps.conversationStore.loadMetrics(conversationId); const lastTurn = metrics[metrics.length - 1]; if (lastTurn === undefined) return { error: "no metrics" }; - const lastInputTokens = lastTurn.usage.inputTokens + (lastTurn.usage.cacheReadTokens ?? 0); - if (lastInputTokens < threshold) return { error: "threshold not exceeded" }; + const contextSize = lastTurn.contextSize; + if (contextSize === undefined) return { error: "no context size" }; + + // Resolve the model's context window. + const modelName = opts.modelName; + if (modelName === undefined || deps.resolveModelInfo === undefined) { + return { error: "cannot resolve model info" }; + } + const info = await deps.resolveModelInfo(modelName); + if (info?.contextWindow === undefined) { + return { error: "model context window unknown" }; + } + const threshold = Math.floor(info.contextWindow * (percent / 100)); + if (contextSize < threshold) return { error: "threshold not exceeded" }; } // Split: old messages to summarize + recent messages to keep. diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index 346f9c4..58de4a9 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -87,10 +87,10 @@ function createInMemoryStore(): ConversationStore & { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; diff --git a/packages/transport-contract/src/index.ts b/packages/transport-contract/src/index.ts index 5745672..02b48a0 100644 --- a/packages/transport-contract/src/index.ts +++ b/packages/transport-contract/src/index.ts @@ -85,11 +85,20 @@ export interface ChatRequest { /** * Response body for `GET /models` — the model catalog. * - * Each entry is a model name in `<credentialName>/<model>` form: exactly the - * string a client passes back as `ChatRequest.model`. + * Each entry in `models` is a model name in `<credentialName>/<model>` form: + * exactly the string a client passes back as `ChatRequest.model`. + * `modelInfo` is an optional map from the same `<credentialName>/<model>` key + * to model metadata (e.g. `contextWindow`). Additive — clients that only + * read `models` are unaffected. */ export interface ModelsResponse { readonly models: readonly string[]; + readonly modelInfo?: Readonly<Record<string, ModelMetadata>>; +} + +/** Per-model metadata returned alongside the model catalog. */ +export interface ModelMetadata { + readonly contextWindow?: number; } /** @@ -583,17 +592,17 @@ export interface CompactResponse { } /** - * Response for `GET /conversations/:id/compact-threshold` — the token count + * Response for `GET /conversations/:id/compact-percent` — the token count * at which automatic compaction triggers (0 = manual only). */ -export interface CompactThresholdResponse { +export interface CompactPercentResponse { readonly conversationId: string; readonly threshold: number; } /** - * Request body for `PUT /conversations/:id/compact-threshold`. + * Request body for `PUT /conversations/:id/compact-percent`. */ -export interface SetCompactThresholdRequest { +export interface SetCompactPercentRequest { readonly threshold: number; } diff --git a/packages/transport-http/src/app.test.ts b/packages/transport-http/src/app.test.ts index 674fd94..11f74f5 100644 --- a/packages/transport-http/src/app.test.ts +++ b/packages/transport-http/src/app.test.ts @@ -139,10 +139,10 @@ function createFakeConversationStore( }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -241,6 +241,9 @@ function createFakeCredentialStore(models: string[]): CredentialStore { resolve() { return undefined; }, + async getModelInfo() { + return undefined; + }, async listCatalog() { return models; }, @@ -252,6 +255,9 @@ function createThrowingCredentialStore(error: Error): CredentialStore { resolve() { return undefined; }, + async getModelInfo() { + return undefined; + }, async listCatalog() { throw error; }, @@ -876,10 +882,10 @@ describe("GET /conversations/:id", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -951,10 +957,10 @@ describe("GET /conversations/:id", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -1095,10 +1101,10 @@ describe("GET /conversations/:id/metrics", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -2072,10 +2078,10 @@ describe("PUT /conversations/:id/reasoning-effort", () => { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; diff --git a/packages/transport-http/src/app.ts b/packages/transport-http/src/app.ts index a2ee18c..f917846 100644 --- a/packages/transport-http/src/app.ts +++ b/packages/transport-http/src/app.ts @@ -1,8 +1,8 @@ import type { AgentEvent, HostAPI, Logger } from "@dispatch/kernel"; import type { CloseConversationResponse, + CompactPercentResponse, CompactResponse, - CompactThresholdResponse, ConversationHistoryResponse, ConversationListResponse, ConversationMetricsResponse, @@ -14,7 +14,7 @@ import type { OpenConversationResponse, QueueResponse, ReasoningEffortResponse, - SetCompactThresholdRequest, + SetCompactPercentRequest, ThroughputResponse, TitleResponse, WarmResponse, @@ -233,7 +233,17 @@ export function createApp(opts: CreateServerOptions): Hono { app.get("/models", async (c) => { try { const models = await opts.credentialStore.listCatalog(); - const body: ModelsResponse = { models }; + const modelInfo: Record<string, { contextWindow?: number }> = {}; + for (const modelName of models) { + const info = await opts.credentialStore.getModelInfo(modelName); + if (info?.contextWindow !== undefined) { + modelInfo[modelName] = { contextWindow: info.contextWindow }; + } + } + const body: ModelsResponse = { + models, + ...(Object.keys(modelInfo).length > 0 ? { modelInfo } : {}), + }; return c.json(body, 200); } catch (err) { log.error("models: failed to retrieve catalog", { err }); @@ -739,14 +749,14 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json(response, 200); }); - app.get("/conversations/:id/compact-threshold", async (c) => { + app.get("/conversations/:id/compact-percent", async (c) => { const conversationId = c.req.param("id"); - const threshold = (await opts.conversationStore.getCompactThreshold(conversationId)) ?? 0; - const response: CompactThresholdResponse = { conversationId, threshold }; + const threshold = (await opts.conversationStore.getCompactPercent(conversationId)) ?? 0; + const response: CompactPercentResponse = { conversationId, threshold }; return c.json(response, 200); }); - app.put("/conversations/:id/compact-threshold", async (c) => { + app.put("/conversations/:id/compact-percent", async (c) => { const conversationId = c.req.param("id"); let body: unknown; try { @@ -754,7 +764,7 @@ export function createApp(opts: CreateServerOptions): Hono { } catch { return c.json({ error: "Invalid JSON body" }, 400); } - const parsed = body as SetCompactThresholdRequest; + const parsed = body as SetCompactPercentRequest; if ( typeof parsed.threshold !== "number" || !Number.isFinite(parsed.threshold) || @@ -763,9 +773,9 @@ export function createApp(opts: CreateServerOptions): Hono { return c.json({ error: "threshold must be a non-negative number" }, 400); } const threshold = Math.floor(parsed.threshold); - await opts.conversationStore.setCompactThreshold(conversationId, threshold); - log.info("conversations: compact-threshold set", { conversationId, threshold }); - const response: CompactThresholdResponse = { conversationId, threshold }; + await opts.conversationStore.setCompactPercent(conversationId, threshold); + log.info("conversations: compact-percent set", { conversationId, threshold }); + const response: CompactPercentResponse = { conversationId, threshold }; return c.json(response, 200); }); diff --git a/packages/transport-http/src/extension.ts b/packages/transport-http/src/extension.ts index f7af615..36314e6 100644 --- a/packages/transport-http/src/extension.ts +++ b/packages/transport-http/src/extension.ts @@ -32,7 +32,7 @@ export const manifest: Manifest = { "/conversations/:id", "/conversations/:id/close", "/conversations/:id/compact", - "/conversations/:id/compact-threshold", + "/conversations/:id/compact-percent", "/conversations/:id/cwd", "/conversations/:id/last", "/conversations/:id/lsp", diff --git a/packages/transport-http/src/server.bun.test.ts b/packages/transport-http/src/server.bun.test.ts index fa519af..86ae6bc 100644 --- a/packages/transport-http/src/server.bun.test.ts +++ b/packages/transport-http/src/server.bun.test.ts @@ -66,10 +66,10 @@ function fakeConversationStore(): ConversationStore { }, async setConversationStatus() {}, async replaceHistory() {}, - async getCompactThreshold() { + async getCompactPercent() { return null; }, - async setCompactThreshold() {}, + async setCompactPercent() {}, async forkHistory() {}, async setCompactedFrom() {}, }; @@ -104,6 +104,9 @@ function fakeCredentialStore(): CredentialStore { resolve() { return undefined; }, + async getModelInfo() { + return undefined; + }, async listCatalog() { return []; }, @@ -589,8 +589,8 @@ conversation tab. Short-ID prefix resolution (4+ chars → full ID via `GET /con history with `[system: summary] + recent N` (ID stays the same so messaging is unaffected). `compactedFrom` chains backward: A → Y → X. Manual via `POST /conversations/:id/compact`; automatic after turn settles if - `compactThreshold` (default 350k) is exceeded. `GET/PUT - /conversations/:id/compact-threshold` for the setting. `conversation.compacted` + `compactThreshold` (default 85%) is exceeded. `GET/PUT + /conversations/:id/compact-percent` for the setting. `conversation.compacted` WS broadcast. CLI `dispatch compact <id>`. FE handoff: `frontend-compaction-handoff.md`. |
