Skip to content
Open
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
1 change: 1 addition & 0 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions USAGE_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
39 changes: 30 additions & 9 deletions src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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,
Expand Down Expand Up @@ -268,17 +269,37 @@ export async function runAgentTurnWithOutcome(args: AgentTurnArgs): Promise<Agen
}
}

if (args.plan) {
modelMessages = withPlanContext(modelMessages, args.plan.getSnapshot())
if (modelName) args.onContextStats?.(computeContextStats(modelMessages, modelName))
}

if (args.runtimeContext) modelMessages = withRuntimeContext(modelMessages, args.runtimeContext())
throwIfAborted(args.signal)
const next = await model.next(modelMessages, {
tools: args.tools.list(),
signal: args.signal,
const requestResult = await requestWithPromptTooLongRecovery({
model: {
next(requestMessages, options) {
if (args.plan) {
requestMessages = withPlanContext(requestMessages, args.plan.getSnapshot())
}
if (args.runtimeContext) {
requestMessages = withRuntimeContext(requestMessages, args.runtimeContext())
}
if (modelName) args.onContextStats?.(computeContextStats(requestMessages, modelName))
return model.next(requestMessages, options)
},
},
modelName,
messages: modelMessages,
options: {
tools: args.tools.list(),
signal: args.signal,
},
onCompacted: async (compacted) => {
throwIfAborted(args.signal)
messages = compacted.messages
modelMessages = compacted.messages
snippedThisTurn = true
await args.onSnipCompact?.(compacted)
latestStats = computeContextStats(compacted.messages, modelName)
args.onContextStats?.(latestStats)
},
})
const next = requestResult.step
throwIfAborted(args.signal)

if (next.type === 'assistant') {
Expand Down
6 changes: 5 additions & 1 deletion src/anthropic-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[] = []
Expand Down
62 changes: 62 additions & 0 deletions src/prompt-too-long.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

/**
* 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<PromptTooLongRecoveryResult> {
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)
}
}
}
36 changes: 36 additions & 0 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading