From 0117192d50ee1e7bf2bbb741c7610a65a3f758a3 Mon Sep 17 00:00:00 2001 From: Xiaoping Liao <106010272+kyletser@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:15:00 +0800 Subject: [PATCH 1/2] feat: recover from prompt-too-long errors by compacting and retrying When the model API rejects the request as prompt too long (HTTP 400), the agent loop now deterministically snip-compacts the transcript (no extra model call) and retries, up to PTL_MAX_RETRIES times. Previously the whole turn aborted on such errors, wasting the work done so far. - Add ModelRequestError carrying the HTTP status, thrown by the Anthropic adapter on non-2xx responses. - Add isPromptTooLongError() matching Anthropic-compatible 400 wording. - Add requestWithPromptTooLongRecovery() helper and wire it into the agent loop request path; if nothing can be freed, the original error is rethrown untouched. Part of the model-aware context management roadmap (#1). --- src/agent-loop.ts | 28 ++++- src/anthropic-adapter.ts | 6 +- src/prompt-too-long.ts | 62 ++++++++++ src/utils/errors.ts | 36 ++++++ test/prompt-too-long.test.ts | 220 +++++++++++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 4 deletions(-) create mode 100644 src/prompt-too-long.ts create mode 100644 test/prompt-too-long.test.ts diff --git a/src/agent-loop.ts b/src/agent-loop.ts index 1713832..f562e8d 100644 --- a/src/agent-loop.ts +++ b/src/agent-loop.ts @@ -20,6 +20,7 @@ import { snipCompactConversation, type SnipCompactResult, } from './compact/snipCompact.js' +import { requestWithPromptTooLongRecovery } from './prompt-too-long.js' import { computeContextStats } from './utils/token-estimator.js' import { applyToolResultBudget, @@ -238,10 +239,31 @@ export async function runAgentTurn(args: { } } - const next = await args.model.next(modelMessages, { - tools: args.tools.list(), - signal: args.signal, + const requestResult = await requestWithPromptTooLongRecovery({ + model: args.model, + modelName, + messages: modelMessages, + options: { + tools: args.tools.list(), + signal: args.signal, + }, + onCompacted: async (compacted) => { + messages = compacted.messages + modelMessages = compacted.messages + snippedThisTurn = true + await args.onSnipCompact?.(compacted) + const stats = computeContextStats(compacted.messages, modelName) + args.onContextStats?.(stats) + }, }) + const next = requestResult.step + + if (requestResult.messages !== modelMessages) { + modelMessages = requestResult.messages + messages = requestResult.messages + latestStats = computeContextStats(messages, modelName) + args.onContextStats?.(latestStats) + } if (next.type === 'assistant') { const isEmpty = isEmptyAssistantResponse(next.content) diff --git a/src/anthropic-adapter.ts b/src/anthropic-adapter.ts index 042dc9f..67b58b6 100644 --- a/src/anthropic-adapter.ts +++ b/src/anthropic-adapter.ts @@ -12,6 +12,7 @@ import type { RuntimeConfig } from './config.js' import { resolveMaxOutputTokens } from './utils/context.js' import { buildAnthropicSnipBoundaryText } from './compact/snipCompact.js' import { abortableDelay, throwIfAborted } from './abort.js' +import { ModelRequestError } from './utils/errors.js' const DEFAULT_MAX_RETRIES = 4 const BASE_RETRY_DELAY_MS = 500 @@ -373,7 +374,10 @@ export class AnthropicModelAdapter implements ModelAdapter { } if (!response.ok) { - throw new Error(extractErrorMessage(data, response.status)) + throw new ModelRequestError( + extractErrorMessage(data, response.status), + response.status, + ) } const toolCalls: ToolCall[] = [] diff --git a/src/prompt-too-long.ts b/src/prompt-too-long.ts new file mode 100644 index 0000000..85a4857 --- /dev/null +++ b/src/prompt-too-long.ts @@ -0,0 +1,62 @@ +import type { AgentStep, ChatMessage, ModelAdapter, ModelRequestOptions } from './types.js' +import { isPromptTooLongError } from './utils/errors.js' +import { LIMITS } from './compact/constants.js' +import { + snipCompactConversation, + type SnipCompactResult, +} from './compact/snipCompact.js' +import { computeContextStats } from './utils/token-estimator.js' + +export type PromptTooLongRecoveryResult = { + step: AgentStep + messages: ChatMessage[] +} + +export type PromptTooLongRecoveryArgs = { + model: ModelAdapter + modelName: string + messages: ChatMessage[] + options?: ModelRequestOptions + onCompacted?: (result: SnipCompactResult) => void | Promise +} + +/** + * Call `model.next` and recover from "prompt too long" 400 errors by + * deterministically compacting the transcript (snip compact, no extra model + * call) and retrying, up to `LIMITS.PTL_MAX_RETRIES` times. + * + * Returns the final step together with the messages that were actually sent, + * so the caller can keep its transcript in sync. Non-prompt-too-long errors + * and errors where nothing can be freed are rethrown untouched. + */ +export async function requestWithPromptTooLongRecovery( + args: PromptTooLongRecoveryArgs, +): Promise { + let messages = args.messages + + for (let attempt = 0; ; attempt += 1) { + try { + const step = await args.model.next(messages, args.options) + return { step, messages } + } catch (error) { + if (!isPromptTooLongError(error) || attempt >= LIMITS.PTL_MAX_RETRIES) { + throw error + } + + const stats = computeContextStats(messages, args.modelName) + const snip = await snipCompactConversation({ + messages, + contextStats: stats, + modelContextWindow: stats.effectiveInput, + }) + + if (!snip.didSnip || snip.messages === messages) { + // Nothing left to free deterministically — surface the original error. + throw error + } + + messages = snip.messages + await args.onCompacted?.(snip) + } + } +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 819fd21..bcafd22 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -24,3 +24,39 @@ export function getErrorCode(error: unknown): string | null { export function isEnoentError(error: unknown): boolean { return getErrorCode(error) === 'ENOENT' } + +/** + * Error thrown by a model adapter when the upstream API responds with a + * non-2xx status. Carries the HTTP status so callers can distinguish + * recoverable conditions (e.g. prompt too long, 400) from hard failures. + */ +export class ModelRequestError extends Error { + readonly status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ModelRequestError' + this.status = status + } +} + +const PROMPT_TOO_LONG_PATTERNS = [ + /prompt is too long/i, + /too long.*prompt/i, + /maximum context length/i, + /context.*exceeded/i, + /input.*too long/i, +] + +/** + * Detect a "prompt too long" style error: a 400 from the model API whose + * message matches the well-known wording used by Anthropic-compatible + * endpoints. Used to trigger automatic compaction and retry. + */ +export function isPromptTooLongError(error: unknown): boolean { + if (!(error instanceof ModelRequestError) || error.status !== 400) { + return false + } + const message = error.message + return PROMPT_TOO_LONG_PATTERNS.some(pattern => pattern.test(message)) +} diff --git a/test/prompt-too-long.test.ts b/test/prompt-too-long.test.ts new file mode 100644 index 0000000..1556ef7 --- /dev/null +++ b/test/prompt-too-long.test.ts @@ -0,0 +1,220 @@ +import { describe, it, beforeEach, afterEach } from 'node:test' +import assert from 'node:assert/strict' +import type { AgentStep, ChatMessage, ModelAdapter } from '../src/types.js' +import { ModelRequestError } from '../src/utils/errors.js' +import { requestWithPromptTooLongRecovery } from '../src/prompt-too-long.js' +import { runAgentTurn } from '../src/agent-loop.js' +import { ToolRegistry } from '../src/tool.js' +import type { PermissionManager } from '../src/permissions.js' + +function makeMessages(count: number): ChatMessage[] { + const messages: ChatMessage[] = [ + { role: 'system', content: 'You are a helpful assistant.' }, + ] + for (let i = 0; i < count; i++) { + messages.push({ role: 'user', content: `User message ${i}`.repeat(400) }) + messages.push({ + role: 'assistant', + content: `Assistant response ${i}`.repeat(600), + }) + if (i % 2 === 0) { + messages.push({ + role: 'assistant_tool_call', + toolUseId: `tool-${i}`, + toolName: 'read_file', + input: { path: `file-${i}.txt` }, + } as ChatMessage) + messages.push({ + role: 'tool_result', + toolUseId: `tool-${i}`, + toolName: 'read_file', + content: `file content ${i} `.repeat(900), + isError: false, + } as ChatMessage) + } + } + return messages +} + +const okStep: AgentStep = { type: 'assistant', content: 'ok' } + +function makeAdapter( + calls: Array<{ messages: ChatMessage[]; step: AgentStep | Error }>, +): { adapter: ModelAdapter; seen: ChatMessage[][] } { + const seen: ChatMessage[][] = [] + const adapter: ModelAdapter = { + async next(messages) { + seen.push(messages) + const entry = calls[seen.length - 1]! + if (entry.step instanceof Error) { + throw entry.step + } + return entry.step + }, + } + return { adapter, seen } +} + +describe('requestWithPromptTooLongRecovery', () => { + let messages: ChatMessage[] + + beforeEach(() => { + messages = makeMessages(12) + }) + + afterEach(() => {}) + + it('returns the step directly when the request succeeds', async () => { + const { adapter, seen } = makeAdapter([ + { messages, step: okStep }, + ]) + const result = await requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages, + }) + assert.equal(result.step, okStep) + assert.equal(seen.length, 1) + assert.equal(result.messages, messages) + }) + + it('compacts and retries once on a prompt-too-long error', async () => { + const ptl = new ModelRequestError('prompt is too long: 123 tokens > 100', 400) + const { adapter, seen } = makeAdapter([ + { messages, step: ptl }, + { messages, step: okStep }, + ]) + const result = await requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages, + }) + assert.equal(seen.length, 2, 'should call the model twice') + assert.equal(result.step, okStep) + // second attempt must use compacted (different) messages + assert.notEqual(result.messages, messages) + assert.ok(result.messages.length < messages.length) + }) + + it('awaits the full snip callback before retrying', async () => { + const ptl = new ModelRequestError('prompt is too long', 400) + let calls = 0 + let callbackFinished = false + const adapter: ModelAdapter = { + async next() { + calls += 1 + if (calls === 1) throw ptl + assert.equal(callbackFinished, true) + return okStep + }, + } + + await requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages, + async onCompacted(result) { + await Promise.resolve() + assert.equal(result.didSnip, true) + assert.ok(result.boundaryMessage) + assert.ok(result.removedMessageIds.length > 0) + callbackFinished = true + }, + }) + + assert.equal(calls, 2) + }) + + it('routes recovery snips through the agent-loop persistence callback', async () => { + const ptl = new ModelRequestError('prompt is too long', 400) + let calls = 0 + let persistedSnips = 0 + const adapter: ModelAdapter = { + async next() { + calls += 1 + if (calls === 1) throw ptl + if (calls === 2) { + assert.equal(persistedSnips, 1) + return { type: 'assistant', content: 'still working', kind: 'progress' } + } + return { type: 'assistant', content: 'done', kind: 'final' } + }, + } + + await runAgentTurn({ + model: adapter, + tools: new ToolRegistry([]), + messages, + cwd: process.cwd(), + permissions: {} as PermissionManager, + modelName: '', + maxSteps: 3, + async onSnipCompact(result) { + await Promise.resolve() + assert.equal(result.didSnip, true) + assert.ok(result.boundaryMessage) + persistedSnips += 1 + }, + }) + + assert.equal(calls, 3) + assert.equal(persistedSnips, 1) + }) + + it('rethrows non-prompt-too-long errors without retrying', async () => { + const err = new ModelRequestError('server exploded', 500) + const { adapter, seen } = makeAdapter([ + { messages, step: err }, + ]) + await assert.rejects( + requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages, + }), + (e: unknown) => e === err, + ) + assert.equal(seen.length, 1) + }) + + it('rethrows when a retried request still fails and nothing more can be freed', async () => { + const ptl = new ModelRequestError('prompt is too long', 400) + const { adapter, seen } = makeAdapter([ + { messages, step: ptl }, + { messages, step: ptl }, + ]) + await assert.rejects( + requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages, + }), + (e: unknown) => e === ptl, + ) + // 1 initial attempt + 1 compacted retry; after the first compaction the + // transcript drops below the snip threshold, so no further retry happens. + assert.equal(seen.length, 2) + }) + + it('rethrows the original error when compaction cannot free anything', async () => { + // A tiny transcript that snip-compact leaves untouched + const tiny: ChatMessage[] = [ + { role: 'system', content: 'hi' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'world' }, + ] + const ptl = new ModelRequestError('prompt is too long', 400) + const { adapter, seen } = makeAdapter([ + { messages: tiny, step: ptl }, + ]) + await assert.rejects( + requestWithPromptTooLongRecovery({ + model: adapter, + modelName: 'test-model', + messages: tiny, + }), + (e: unknown) => e === ptl, + ) + assert.equal(seen.length, 1, 'no retry when nothing can be freed') + }) +}) From 7d71c41fe9fceff85db0e98afd08e2a19cbee30d Mon Sep 17 00:00:00 2001 From: Xiaoping Liao <106010272+kyletser@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:29:45 +0800 Subject: [PATCH 2/2] docs: explain prompt-too-long recovery limits --- USAGE.md | 1 + USAGE_ZH.md | 1 + 2 files changed, 2 insertions(+) diff --git a/USAGE.md b/USAGE.md index be4c1f8..6149d78 100644 --- a/USAGE.md +++ b/USAGE.md @@ -252,6 +252,7 @@ MiniCode now treats long-running conversations as a first-class workflow: - `/compact` performs manual context compression using snip compact or context collapse and records a compact boundary in the session log. - Automatic compaction can summarize or snip older turns once utilization gets high, using either **snip compact** (deterministic middle-history removal that protects edits and errors) or **context collapse** (projection-layer summarization of conversation spans). - After compaction, retained pre-compact usage is marked stale so an old provider total is not mistaken for the current context size. +- If the Anthropic-compatible endpoint returns a recognized HTTP 400 prompt-too-long error, MiniCode attempts deterministic snip compaction and retries up to twice, without an extra summarization request. If no history can be safely trimmed or retries are exhausted, the request error is reported. Plan and runtime context remain request-only, and cancellation still stops the retry. - Oversized tool results are written to `~/.mini-code/tool-results/` and replaced in the visible context with a preview and the full-output path. A single result over `50_000` characters is persisted, and batches are reduced toward a `200_000` character visible budget. Session storage and context compression work together: `loadSession` resumes from the latest compact boundary, while `loadTranscript` can still rebuild the visible transcript from the JSONL event log. diff --git a/USAGE_ZH.md b/USAGE_ZH.md index 41ddd3e..bbd8493 100644 --- a/USAGE_ZH.md +++ b/USAGE_ZH.md @@ -251,6 +251,7 @@ MiniCode 现在把长会话作为一等工作流处理: - `/compact` 会手动压缩上下文(使用裁剪压缩或上下文折叠),并在会话日志中写入 compact boundary。 - 当上下文利用率过高时,自动压缩会自动触发,使用**裁剪压缩**(snip compact:确定性移除中段历史,保护编辑和出错轮次)或**上下文折叠**(context collapse:投影层摘要对话片段)来为后续对话腾出空间。 - 压缩后,保留下来的压缩前 usage 会被标记为 stale,避免把旧 provider 总量误认为当前上下文大小。 +- Anthropic 兼容接口返回可识别的 HTTP 400 提示词过长错误时,MiniCode 会尝试确定性裁剪并最多重试两次,不额外调用模型生成摘要。如果无法安全裁剪或重试耗尽,则报告请求错误。Plan 和运行时上下文仅注入请求,不写入历史;取消操作仍可停止重试。 - 超大工具结果会写入 `~/.mini-code/tool-results/`,并在可见上下文里替换成预览和完整输出路径。单个结果超过 `50_000` 字符会落盘;一批工具结果会被压到约 `200_000` 字符的可见预算内。 会话存储和上下文压缩会一起工作:`loadSession` 会从最近的 compact boundary 之后恢复,而 `loadTranscript` 仍然可以从 JSONL 事件日志重建可见 transcript。