package agent import ( "context" "errors" "fmt" "os" "strings" "unicode/utf8" "git.gopher.ink/mirrors2/AutoZero/llm" "git.gopher.ink/mirrors2/AutoZero/session" "git.gopher.ink/mirrors2/AutoZero/spill" "git.gopher.ink/mirrors2/AutoZero/textx" ) // defaultImageBudgetBytes 默认图片字节预算(网关常见单请求上限 5MB,留余量给文本)。 const defaultImageBudgetBytes = int64(3500 << 10) // 本文件实现两级上下文压缩管道(借鉴 ccb src/services/compact/ 的分层思路): // 1. 廉价层:超长 tool result 在视图层截断(transcript 保留全文); // 2. 昂贵层:token 粗估超过窗口阈值时,用一次独立的 LLM 调用生成结构化 // 摘要,替换工作集。压缩发生在视图层——Session transcript 永远完整, // --continue 重载后如仍超限会再次触发压缩。 // compactionSystem 是首次压缩的结构化交接文档提示词。它比"一段摘要"更重: // 下一回合/下一段历史要能靠这份文档接着干,所以把目标、约束、已完成/进行中/ // 被阻塞、下一步与相关文件分开写,避免"State"一段把一切糊在一起。 const compactionSystem = `You are a context compaction assistant for a coding agent. Create a structured checkpoint that lets a fresh agent continue the work seamlessly. Output only the checkpoint in Markdown, using exactly these sections (omit a section only if it is genuinely not applicable): ## Objective - one or two sentences: what the user is trying to accomplish ## Requirements - constraints, preferences, requirements, and scope boundaries stated by the user ## Decisions - decisions already made and why ## Work State ### Completed - goals that have been completed ### Active - goals currently being worked on ### Blocked - anything blocking progress, and why ## Next Move 1. ordered next actions ## Relevant Files - paths (at most 15, most important first) with a one-line reason ## Important Context - facts the next agent cannot continue without and cannot easily find Rules: - Use terse single-line bullets, not prose paragraphs or nested lists. - Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers. - Carry forward only user questions or requests that remain unanswered. - Preserve consequential workflow state: uncommitted, committed, pushed, under review, merged. - Do not mention the summary or compaction process. - Do not continue the task or call tools.` // compactionUpdateSystem 是第二次及以后压缩:在已有 checkpoint 上合并更新, // 而不是把整段历史(含旧摘要)重新摘要一遍。旧摘要越滚越失真就是从这里治的。 const compactionUpdateSystem = `You are a context compaction assistant for a coding agent. Update the existing checkpoint using the newer conversation. Newer history always wins. Output only the updated checkpoint in Markdown, using exactly the same sections: ## Objective ## Requirements ## Decisions ## Work State (### Completed / ### Active / ### Blocked) ## Next Move ## Relevant Files ## Important Context Rules: - Preserve previous requirements, decisions, and unanswered questions unless newer history clearly supersedes or resolves them. - Move finished work out of Active; remove resolved blockers and answered questions. - Keep exact file paths, symbols, commands, error strings, URLs, and identifiers. - Do not mention the checkpoint or compaction process, and do not reproduce wrapper tags. - Do not continue the task or call tools.` // abandonedSystem 给"被回退掉的那条分支"做摘要。口径与 compactionSystem 刻意相反: // 常规压缩要的是"接着干",这里要的是"别接着干"——摘掉的那条路里全是未完成动作, // 原样交给模型,下一轮它就会把那笔活重新捡起来。 const abandonedSystem = `You are summarizing an ABANDONED branch of a coding agent's session, as background context. The user rewound the conversation: everything below did happen, then the user rolled it back and chose to go a different way. Report, in terse factual bullets: 1. What that branch was trying to do 2. What it actually established — findings, file contents read, error messages, facts learned (this is the valuable part) 3. What it changed on disk, if anything 4. Why it stopped, if that is visible Do NOT list next steps, TODOs, or anything to continue. Nothing below should be re-executed: it is history the user rejected. Be dense and factual. No greetings, no commentary.` // ErrAbandonedTooSmall 表示摘掉的那段太短,为它烧一次摘要调用不值。 var ErrAbandonedTooSmall = errors.New("abandoned branch too small to summarize") // abandonedMinTokens 是"值得总结"的地板线(估算口径与 estimateTokens 一致)。 // 三五行对话直接丢掉,比多花一次请求更划算。 const abandonedMinTokens = 1500 // abandonedNoteCap 是最多并存几条被放弃的分支:反复回退时旧的先掉, // 不然工作集会被一串越来越长的"背景"拖死。 const abandonedNoteCap = 3 // SummarizeAbandoned 把一次对话回退摘掉的那段压成一条背景说明,经 AbandonedSection // 注入 system 后缀。transcript 不动——摘掉的消息仍在会话文件里(append-only 游标), // 只是不再进模型视野;这条摘要让它们以"已经放弃"的身份回来。 // // 调用方是 /rewind apply:它拿得到 RetireTo 之前的那段消息。 func (a *Agent) SummarizeAbandoned(ctx context.Context, dropped []llm.Message) (CompactionResult, error) { res := CompactionResult{Summarized: len(dropped), TokensBefore: estimateTokens(dropped, a.sysNow())} if len(dropped) < 2 || res.TokensBefore < abandonedMinTokens { return res, ErrAbandonedTooSmall } summary, err := a.summarizeWithBudget(ctx, dropped, abandonedSystem) if err != nil { res.Err = err.Error() return res, err } a.abandonedMu.Lock() a.abandonedNotes = append(a.abandonedNotes, summary) if n := len(a.abandonedNotes); n > abandonedNoteCap { a.abandonedNotes = a.abandonedNotes[n-abandonedNoteCap:] } stored := len(a.abandonedNotes) a.abandonedMu.Unlock() res.TokensAfter = len(a.AbandonedSection()) / 3 fmt.Fprintf(a.Opt.Stderr, "[context] 回退摘掉的 %d 条已压成背景说明(~%d → ~%d tokens),并存着 %d 条\n", res.Summarized, res.TokensBefore, res.TokensAfter, stored) return res, nil } // AbandonedSection 把"已被放弃的分支"渲染成 system 后缀的一段;没有就返回空串。 // // 为什么走 system 后缀而不是往消息列表里塞一条:计划与记忆早就住在这里,理由是 // "每轮重算、躲过压缩"。塞进消息列表的那一版被 autocompact 卷进过摘要——常规压缩 // 的收尾话术是"根据这份摘要接着干",于是"别接着干"这条说明被改写成了待办, // 用户刚放弃的分支原地复活。后缀不参与压缩,也不会让 compactFrom 的索引漂移。 // // 锁必须是自己那把:这段经 sysNow 被调用,而 sysNow 在 contextView/CompactNow 里 // 是**持着 compactMu** 跑的——跟着拿 compactMu 就是自己等自己(真死过一次)。 func (a *Agent) AbandonedSection() string { a.abandonedMu.Lock() notes := append([]string(nil), a.abandonedNotes...) a.abandonedMu.Unlock() if len(notes) == 0 { return "" } var b strings.Builder b.WriteString("\n\n# 已被用户回退掉的分支(背景,不是待办)\n\n" + "下面这些是之前那条路上发生过、但**用户已经撤销**的事。只当资料看:" + "不要执行其中未完成的动作,不要重做它做过的事,除非用户重新开口要求。\n") for i, n := range notes { fmt.Fprintf(&b, "\n## 放弃的分支 %d\n%s\n", i+1, n) } return b.String() } // ClearAbandonedNotes 撤掉这些背景说明:/rewind undo 把摘掉的消息原样接回来了, // 再留一份"它被放弃了"的摘要就是自相矛盾。 func (a *Agent) ClearAbandonedNotes() { a.abandonedMu.Lock() a.abandonedNotes = nil a.abandonedMu.Unlock() } // AbandonedNotes 是当前还留着几条"已放弃分支"说明(界面与测试用来核对)。 func (a *Agent) AbandonedNotes() int { a.abandonedMu.Lock() defer a.abandonedMu.Unlock() return len(a.abandonedNotes) } // compactBase 返回压缩管线的工作集输入:被回退分支的背景说明 + 压缩缓存 + 压缩点之后的新消息。 // 调用方必须持有 a.compactMu。 func (a *Agent) compactBase() []llm.Message { base := a.Session.Messages if a.compacted != nil { if a.compactFrom > len(a.Session.Messages) { a.compactFrom = len(a.Session.Messages) // 防御:外部改过会话 } merged := make([]llm.Message, 0, len(a.compacted)+len(a.Session.Messages)-a.compactFrom) merged = append(merged, a.compacted...) base = append(merged, a.Session.Messages[a.compactFrom:]...) } return base } // contextView 返回发给模型的工作集视图:压缩缓存 + 压缩点之后的新消息, // 并对超长工具结果做廉价截断。持锁覆盖自动压缩全程,与手动 CompactNow 串行化。 func (a *Agent) contextView(ctx context.Context) ([]llm.Message, error) { a.compactMu.Lock() defer a.compactMu.Unlock() a.validateCheckpointLocked() base := truncateToolResultsSpill(a.compactBase(), a.MaxToolResultChars, a.spillResult) base = evictOldImages(base, a.MaxImages, a.ImageBudgetBytes) if a.shouldCompact(base) { compacted, err := a.compactAndInstall(ctx, base) if err != nil { return base, nil // 失败已经记下来并上报给 UI 了,这里只负责继续跑 } return compacted, nil } return base, nil } // compactAndInstall 跑一次摘要调用,成功后把结果装成工作集缓存。调用方持 compactMu。 // // 两条路径共用这一个出口:阈值到了的常规压缩,以及"请求过大自救"的强制压缩 // (forceCompactForSize)。它们对 UI 的义务一样——成功要让人看到压了多少, // 失败要立刻说清楚(否则用户接着看到的是一句看不懂的网关错误)。 // 失败时记 compactGaveUp:本回合别再每个 step 撞一次注定失败的摘要请求。 func (a *Agent) compactAndInstall(ctx context.Context, view []llm.Message) ([]llm.Message, error) { return a.compactAndInstallWithTarget(ctx, view, 0) } func (a *Agent) compactAndInstallWithTarget(ctx context.Context, view []llm.Message, target int) ([]llm.Message, error) { res := CompactionResult{TokensBefore: a.observedTokens(view)} compacted, err := a.autocompactWithTarget(ctx, view, target) if err != nil { a.compactGaveUp = true res.Err = err.Error() fmt.Fprintf(a.Opt.Stderr, "[context] autocompact failed, continuing without compaction: %v\n", err) a.reportCompact(res) return nil, err } a.compacted = compacted a.compactFrom = len(a.Session.Messages) a.saveCheckpointLocked() a.resetInputUsage() a.compactGaveUp = false res.TokensAfter = a.observedTokens(compacted) res.Summarized, res.Kept = compactSplit(view) // 自动压缩原本对 UI 完全静默(只写 stderr);这里把结果报给宿主,让它落成 // 用户可见的一等事件——结构化的一份给要算数字的宿主,人话那一句给界面。 a.reportCompact(res) return compacted, nil } // 两档判据,按证据强弱分开: // // 强证据(HTTP 400 + param=messages / invalid_request / upstream_request_rejected // /context|length|token 字样)——这是网关在点名说"这条请求本身有问题",与上下文占 // 多少无关。只要求"有东西可压"(oversizedMinTokens),否则把 context_window 配大 // 一点(1M 的模型本该如此)就会让自救永远不触发,而真实限制其实在几十万 token 以下。 // 弱证据(空补全、坏流)——绝大多数就是网关抖动,只有在上下文本来就大到占窗口 // oversizedContextRatio 以上时才怀疑体积。 const ( oversizedContextRatio = 0.6 oversizedMinTokens = 4000 ) // requestLooksOversized 判断一次失败是不是"请求太大被网关打回"。 func (a *Agent) requestLooksOversized(err error, view []llm.Message) bool { if err == nil || a.ContextWindow <= 0 { return false } obs := a.observedTokens(view) msg := strings.ToLower(err.Error()) strongShape := strings.Contains(msg, "http 400") || strings.Contains(msg, "invalid_request") || strings.Contains(msg, "upstream_request_rejected") || strings.Contains(msg, "400002") if strongShape { return obs >= oversizedMinTokens && (strings.Contains(msg, "message") || strings.Contains(msg, "context") || strings.Contains(msg, "token") || strings.Contains(msg, "length") || strings.Contains(msg, "too large") || strings.Contains(msg, "invalid")) } if obs < int(oversizedContextRatio*float64(a.ContextWindow)) { return false } return errors.Is(err, llm.ErrEmptyCompletion) || errors.Is(err, llm.ErrTruncatedStream) } // forceCompactForSize 无视阈值与本回合的放弃标记,强制压一次(调用方随后重发本步)。 // 这是上下文管理的最后一道自救:估算口径是 bytes/3,与网关真实 token 数必然有偏差, // 真撞墙的时候以"先压下来再重发"收尾,比把一句 400 原样丢给用户好。 func (a *Agent) forceCompactForSize(ctx context.Context, view []llm.Message, shrink float64) error { a.compactMu.Lock() defer a.compactMu.Unlock() // 只有一条 checkpoint 消息也允许再压:更新 prompt 会拿旧 checkpoint + 空 // 新历史继续收缩。没有 checkpoint 时才要求至少两条消息。 if len(view) < 2 && a.checkpoint == "" { return errors.New("context too small to compact") } target := 0 if shrink > 0 { target = int(float64(a.observedTokens(view)) * shrink) if target < 1000 { target = 1000 } } fmt.Fprintf(a.Opt.Stderr, "[context] 请求疑似过大,强制压缩后重发本步(目标 ~%d tokens)\n", target) _, err := a.compactAndInstallWithTarget(ctx, view, target) return err } // CompactionResult 汇报一次压缩的结果。Err 非空表示**自动**压缩失败了(手动 // CompactNow 直接把 error 返给调用方),UI 据此把"这一轮没压下去、接下来很可能 // 因为上下文过大而失败"说给人听,而不是留一句看不懂的网关错误。 type CompactionResult struct { Summarized int // 被压成摘要的消息条数 Kept int // 保留原文的最近消息条数 TokensBefore int // 压缩前的 token 粗估 TokensAfter int // 压缩后的 token 粗估 Err string } // ErrContextTooSmall 表示上下文低于 MinCompactTokens,手动压缩没有必要。 var ErrContextTooSmall = errors.New("context too small to compact") // Text 把一次压缩念成一句人话。三个宿主(TUI 系统块、WebUI 的 compact 帧、 // 渠道)共用这一份口径,不再各写各的措辞。 // // 失败那句必须比成功更长:用户接下来看到的是"网关报错/空回复",真正的原因却是 // 上下文已经大到连压缩自己都发不出去——不把话说完,人就只会去怪模型。 func (r CompactionResult) Text() string { if r.Err == "" { return fmt.Sprintf("✂ 已自动压缩历史:%d 条消息进摘要、保留最近 %d 条原文(~%d → ~%d tokens)。会话文件未改动。", r.Summarized, r.Kept, r.TokensBefore, r.TokensAfter) } return fmt.Sprintf("✂ 上下文压缩失败:%s\n"+ " 这一轮先带着完整历史继续跑,接下来很可能因为上下文过大而报错或空回复。\n"+ " 可以:/compact 手动再试一次;或把 context_window 调小让它更早压缩;或开新会话继续。", textx.ClipRunes(r.Err, 160)) } // CompactNow 手动触发一次上下文压缩(/compact 命令入口),无视自动压缩阈值。 // 与 Run 循环的自动压缩共享同一条管线与锁:若一个回合正在压缩,这里会等它完成 // 后基于最新状态重算。transcript 永远不动,压缩只发生在工作集视图层。 func (a *Agent) CompactNow(ctx context.Context) (CompactionResult, error) { a.compactMu.Lock() defer a.compactMu.Unlock() a.validateCheckpointLocked() view := truncateToolResultsSpill(a.compactBase(), a.MaxToolResultChars, a.spillResult) view = evictOldImages(view, a.MaxImages, a.ImageBudgetBytes) res := CompactionResult{TokensBefore: a.observedTokens(view)} if res.TokensBefore < a.MinCompactTokens { return res, fmt.Errorf("%w: est. %d tokens", ErrContextTooSmall, res.TokensBefore) } compacted, err := a.autocompact(ctx, view) if err != nil { return res, err } a.compacted = compacted a.compactFrom = len(a.Session.Messages) a.saveCheckpointLocked() a.resetInputUsage() res.TokensAfter = a.observedTokens(compacted) res.Summarized, res.Kept = compactSplit(view) return res, nil } // compactSplit 复算 autocompact 的切点:多少条进了摘要、多少条保留原文。 // 与 autocompact 的分支逻辑一一对应。 func compactSplit(msgs []llm.Message) (summarized, kept int) { switch { case len(msgs) <= compactKeepRecent: return len(msgs), 0 } cut := compactCut(msgs, compactKeepRecent) if cut >= len(msgs) { return len(msgs), 0 } return cut, len(msgs) - cut } // shouldCompact 判断是否达到压缩阈值。 func (a *Agent) shouldCompact(msgs []llm.Message) bool { if len(msgs) < 2 { return false // 空对话 } if a.compacted != nil && a.compactFrom >= len(a.Session.Messages) { return false // 刚压缩过、还没有新消息,防止原地反复压缩 } if a.compactGaveUp { return false // 本回合已经试过且失败:别再每个 step 撞一次(Run 开头清零) } // 优先用网关回报的真实 prompt token;没有真实数据时才退回 bytes/3。 obs := a.observedTokens(msgs) if obs < a.MinCompactTokens { return false } return obs >= int(a.CompactAt*float64(a.ContextWindow)) } // compactKeepRecent 压缩时保留原文的最近消息条数:当前文件路径、上一步 // 报错这类高价值细节不进摘要(对齐 pi-mono/Claude Code 的 keepRecent 思路)。 const compactKeepRecent = 10 // compactCut 计算压缩切点:切点前压成摘要、切点后保留原文。 // 不变量(pi-mono/OpenClaw/Claude Code 共有):切点不能落在工具回合中间—— // 否则摘要段以悬空的 tool_calls 结尾、保留段以孤儿 tool result 开头, // 模型会看到半截回合。安全切点是 user 消息或不带 tool_calls 的 assistant 消息。 func compactCut(msgs []llm.Message, keep int) int { cut := len(msgs) - keep if cut < 0 { cut = 0 } for cut < len(msgs) { m := msgs[cut] if m.Role == llm.RoleTool { cut++ // 孤儿 tool result:并入摘要段 continue } if m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0 { cut++ // 带未完成调用的 assistant:连同其工具结果一起并入摘要段 for cut < len(msgs) && msgs[cut].Role == llm.RoleTool { cut++ } continue } break } return cut } // summaryRetries 是摘要调用的尝试次数(含首次)。每失败一次把输入预算减半: // 失败最常见的原因就是"这份要摘要的历史本身超过了网关肯收的大小",缩小是唯一 // 能自救的方向;而一次注定失败的调用重复 30 次(每个 step 一次)只会更糟。 var summaryRetries = 3 // autocompact 用一次独立的 LLM 调用把较旧消息压成结构化 checkpoint, // 最近 compactKeepRecent 条保留原文;消息不足保留条数时退化为全量摘要。 func (a *Agent) autocompact(ctx context.Context, msgs []llm.Message) ([]llm.Message, error) { return a.autocompactWithTarget(ctx, msgs, 0) } // autocompactWithTarget 是带输出预算提示的版本:target > 0 时要求模型把 // checkpoint 控制在约 target token 内(请求过大自救的收缩阶梯用)。 func (a *Agent) autocompactWithTarget(ctx context.Context, msgs []llm.Message, target int) ([]llm.Message, error) { var toSummarize, toKeep []llm.Message switch { case len(msgs) <= compactKeepRecent: toSummarize = msgs // 少于保留条数:全量摘要(旧行为兜底) default: cut := compactCut(msgs, compactKeepRecent) if cut >= len(msgs) { toSummarize = msgs // 剩余全是工具回合(罕见):全量摘要 } else { toSummarize, toKeep = msgs[:cut], msgs[cut:] } } // 已有 checkpoint 时只把"它还没覆盖的新消息"交给模型更新,避免每次把旧摘要 // 连同整段历史重新摘要一遍——那样摘要会随次数递增而失真、变贵。 prior := a.checkpoint newMsgs := toSummarize priorPresent := false if prior != "" && len(newMsgs) > 0 && isCheckpointMessage(newMsgs[0]) { newMsgs = newMsgs[1:] priorPresent = true } var summary string var err error if prior != "" { summary, err = a.summarizeWithPrompt(ctx, compactionUpdateSystem, func(budget int) string { return buildCheckpointUpdateInput(prior, newMsgs, budget, target) }) } else { summary, err = a.summarizeWithPrompt(ctx, compactionSystem, func(budget int) string { return buildCheckpointInitialInput(newMsgs, budget, target) }) } if err != nil { return nil, err } // 计算新 checkpoint 覆盖到原 transcript 的第几条,并记下边界指纹。 covered := a.checkpointCovered if a.compacted == nil { covered = 0 } added := len(toSummarize) if priorPresent { added-- } covered += added if a.Session != nil && covered > len(a.Session.Messages) { covered = len(a.Session.Messages) } if covered < 0 { covered = 0 } hash := "" if a.Session != nil && covered > 0 { hash = session.MessageFingerprint(a.Session.Messages[covered-1]) } a.checkpoint = summary a.checkpointCovered = covered a.checkpointHash = hash out := make([]llm.Message, 0, len(toKeep)+1) out = append(out, checkpointMessage(summary)) out = append(out, toKeep...) fmt.Fprintf(a.Opt.Stderr, "[context] compacted %d messages into checkpoint, kept %d recent verbatim\n", len(toSummarize), len(toKeep)) return out, nil } // buildCheckpointInitialInput 是首次压缩的输入:历史 + 输出预算提示。 func buildCheckpointInitialInput(msgs []llm.Message, budget, target int) string { var b strings.Builder b.WriteString(serializeForSummary(msgs, 8000, budget)) b.WriteString("\n\nProduce the structured checkpoint now.") if target > 0 { fmt.Fprintf(&b, "\nKeep it under about %d tokens.", target) } return b.String() } // buildCheckpointUpdateInput 是原地更新的输入:旧 checkpoint + 新历史。 // 预算先扣掉旧 checkpoint 与信封,剩下的留给新历史。 func buildCheckpointUpdateInput(prior string, newMsgs []llm.Message, budget, target int) string { var b strings.Builder b.WriteString("Existing checkpoint:\n\n") b.WriteString(prior) b.WriteString("\n\nNewer conversation:\n\n") remaining := budget if remaining > 0 { remaining -= b.Len() if remaining < 256 { remaining = 256 } } if len(newMsgs) == 0 { b.WriteString("(No new messages: rewrite the existing checkpoint more concisely, preserving requirements, decisions, and open questions.)\n") } else { b.WriteString(serializeForSummary(newMsgs, 8000, remaining)) } b.WriteString("\n\nReturn only the updated checkpoint Markdown.") if target > 0 { fmt.Fprintf(&b, "\nKeep it under about %d tokens.", target) } return b.String() } // summarizeWithBudget 把消息平铺后交给摘要调用,失败时缩小预算重试。 func (a *Agent) summarizeWithBudget(ctx context.Context, msgs []llm.Message, system string) (string, error) { return a.summarizeWithPrompt(ctx, system, func(budget int) string { return serializeForSummary(msgs, 8000, budget) }) } // summarizeWithPrompt 跑一次独立的摘要调用;build 按当前预算生成输入,失败就 // 把预算减半重试。减半是唯一能自救的方向:摘要请求被拒最常见的原因就是 // "要摘要的这份历史本身超过了网关肯收的大小"。 func (a *Agent) summarizeWithPrompt(ctx context.Context, system string, build func(budget int) string) (string, error) { budget := a.summaryBudgetBytes() var lastErr error for attempt := 1; attempt <= summaryRetries; attempt++ { if ctx.Err() != nil { return "", ctx.Err() // 用户已经喊停:别再发请求了 } if attempt > 1 { if budget <= 0 { return "", lastErr // 没配预算(不限大小)时,缩小重试无从下手 } budget /= 2 fmt.Fprintf(a.Opt.Stderr, "[context] 摘要请求失败,预算缩到 %d 字节重试 %d/%d:%v\n", budget, attempt, summaryRetries, lastErr) } input := []llm.Message{{Role: llm.RoleUser, Content: build(budget)}} final, err := a.Provider.Stream(ctx, system, input, nil, llm.StreamOptions{}, nil) if err == nil && strings.TrimSpace(final.Content) == "" { err = errors.New("model returned an empty summary") } if err != nil { lastErr = err continue } return strings.TrimSpace(final.Content), nil } return "", lastErr } // summaryBudgetBytes 是摘要请求自身的字节预算:压缩阈值的一半。 // // 为什么要预算:摘要输入以前只有"每条 8000 字节"的单条上限,整份随会话线性膨胀。 // 等上下文大到该压缩的时候,摘要请求自己也撞上了同一道网关限制——压缩失败、带着完整 // 历史继续跑、真实调用同样失败、用户打「继续」再来一遍,是个**单向门**(真实事故: // 会话涨到 14 万 tokens,10 个回合死了 6 个,一次压缩都没发生)。 // 阈值的一半意味着:只要真实调用还发得出去,摘要请求就一定发得出去。 // estimateTokens 的口径是 bytes/3,所以 token 预算换算成字节要乘 3。 func (a *Agent) summaryBudgetBytes() int { threshold := int(a.CompactAt * float64(a.ContextWindow)) if threshold <= 0 { return 0 // 0 = 不限(保持旧行为;未配置窗口的入口不该被我的换算悄悄改变) } return threshold * 3 / 2 } // spillResult 把超长工具结果写入 ResultDir 并返回路径;未配置目录时不做任何事 // (返回空路径,截断提示退回"重跑"话术)。 func (a *Agent) spillResult(callID, full string) (string, error) { if a.ResultDir == "" { return "", errNoResultDir } // 加命名空间前缀:并行子 agent 的 tool_call ID 常相同,避免文件互相复用。 return spill.Save(a.ResultDir, a.spillNS+callID, full) } var errNoResultDir = errors.New("no result dir configured") // truncateToolResults 返回截断超长 tool result 后的消息视图(无截断时原样返回), // 不落盘。spill 变体的薄包装,保留原签名供既有调用方与测试使用。 func truncateToolResults(msgs []llm.Message, max int) []llm.Message { return truncateToolResultsSpill(msgs, max, nil) } // truncateToolResultsSpill 在截断前把完整原文交给 spillFn(可空)落盘;成功则 // 在提示里带上可读路径(模型可用 read 取回全文),失败则退回原始的"重跑"提示。 // // 采用首尾保留:头部与尾部各留一半预算,中间插入省略量提示——命令的报错、退出码 // 与汇总通常在尾部,只留头会丢掉关键信息(对齐 opencode/Pi 的做法)。transcript 不动。 func truncateToolResultsSpill(msgs []llm.Message, max int, spillFn func(callID, full string) (string, error)) []llm.Message { if max <= 0 { return msgs } need := false for _, m := range msgs { if m.Role == llm.RoleTool && len(m.Content) > max { need = true break } } if !need { return msgs } out := make([]llm.Message, len(msgs)) copy(out, msgs) for i := range out { m := &out[i] if m.Role != llm.RoleTool || len(m.Content) <= max { continue } orig := m.Content // 预留省略提示自身占用的空间;256 足够容纳一段即用路径。 budget := max - 256 if budget < 0 { budget = 0 } half := budget / 2 head := cutUTF8Head(orig, half) tail := cutUTF8Tail(orig, half) omitted := len(orig) - len(head) - len(tail) note := "rerun the tool with a narrower query if needed" if spillFn != nil { if path, err := spillFn(m.ToolCallID, orig); err == nil && path != "" { note = "full output saved to " + path + " — use the read tool" } } m.Content = head + fmt.Sprintf("\n...[truncated %d chars; %s]\n", omitted, note) + tail } return out } // cutUTF8Head 取前 n 字节,且不切断 UTF-8 字符。 func cutUTF8Head(s string, n int) string { if n <= 0 { return "" } if len(s) <= n { return s } for n > 0 && !utf8.RuneStart(s[n]) { n-- } return s[:n] } // cutUTF8Tail 取后 n 字节,且不切断 UTF-8 字符。 func cutUTF8Tail(s string, n int) string { if n <= 0 { return "" } if len(s) <= n { return s } start := len(s) - n for start < len(s) && !utf8.RuneStart(s[start]) { start++ } return s[start:] } // evictOldImages 只保留最近的图片附件,双重限制:张数上限 maxKeep 与 // base64 估算字节预算 maxBytes(网关有单请求字节上限,如 5MB;只限张数 // 不限字节时几张 Retina 截图就能超限)。驱逐发生在视图层,transcript 不动; // 被驱逐处的文本追加说明,模型需要时可重新截图。 func evictOldImages(msgs []llm.Message, maxKeep int, maxBytes int64) []llm.Message { if maxKeep <= 0 || len(msgs) == 0 { return msgs } type ref struct { mi, ii int size int64 } var refs []ref for mi := range msgs { for ii := range msgs[mi].Images { refs = append(refs, ref{mi, ii, estimateImageBytes(msgs[mi].Images[ii].Path)}) } } total := len(refs) if total == 0 { return msgs } // 从最新往前保留,直到任一限制触顶(至少保留最新一张) keep := 0 var bytes int64 for i := total - 1; i >= 0; i-- { if keep+1 > maxKeep { break } if maxBytes > 0 && keep > 0 && bytes+refs[i].size > maxBytes { break } keep++ bytes += refs[i].size } if keep == total { return msgs // 预算内:零拷贝 } keepFrom := refs[total-keep] // 第 keep 新的图片(保留边界) out := make([]llm.Message, len(msgs)) copy(out, msgs) global := 0 for i := range out { n := len(out[i].Images) if n == 0 { continue } firstKept := global // 本消息第一张图的全局序号 global += n if firstKept+n <= total-keep { // 整条消息的图都被驱逐 out[i].Images = nil out[i].Content += " [older screenshots evicted to save context]" continue } if firstKept >= total-keep { // 整条保留 continue } // 部分保留:保留较新的尾部 keptInMsg := firstKept + n - (total - keep) out[i].Images = out[i].Images[n-keptInMsg:] } _ = keepFrom return out } // estimateImageBytes 估算图片进请求体的字节数(base64 膨胀 4/3)。 // 文件不可读时按 0 计(不占预算)。 func estimateImageBytes(path string) int64 { st, err := os.Stat(path) if err != nil { return 0 } return st.Size() * 4 / 3 } // estimateTokens 粗估 token 数。ASCII 约 4 字符/token、CJK 约 1 字/token, // 统一用 字节/3 折中:CJK(3 字节/字)≈1 token,ASCII 略高估——偏保守,早压缩好过爆窗口。 func estimateTokens(msgs []llm.Message, system string) int { n := len(system) / 3 for _, m := range msgs { n += len(m.Content)/3 + len(m.ToolCallID)/3 + 8 for _, tc := range m.ToolCalls { n += len(tc.Input)/3 + 8 } n += len(m.Images) * llm.ImageEstimateTokens } return n } // summaryHeader 与 omitNote 是摘要输入的信封文案。它们也计入预算,所以提前算好。 const ( summaryHeader = "Conversation to summarize:\n\n" omitNoteFmt = "[… 中间 %d 条更早的消息因预算省略 …]\n" ) // summaryBlock 把一条消息平铺成摘要输入里的一段。 func summaryBlock(m llm.Message, perMsgCap int) string { var b strings.Builder content := m.Content if len(content) > perMsgCap { // 与下面 tool_use 的 input 同一件事:这是发给模型的字节预算, // 预算本身按字节算没问题,但切点必须落在字符边界上。 content = cutUTF8Head(content, perMsgCap) + "…[truncated]" } switch { case m.Role == llm.RoleTool: fmt.Fprintf(&b, "[tool result for %s]: %s\n", m.ToolCallID, content) case len(m.ToolCalls) > 0: fmt.Fprintf(&b, "[assistant]: %s\n", content) for _, tc := range m.ToolCalls { input := tc.Input if len(input) > 2000 { // 这段进摘要提示词发给模型:2000 是字节预算,但必须退到字符边界, // 否则尾部的半个中文既读不通,也可能让请求直接 400。 input = cutUTF8Head(input, 2000) + "…" } fmt.Fprintf(&b, " -> tool_use id=%s name=%s input=%s\n", tc.ID, tc.Name, input) } default: fmt.Fprintf(&b, "[%s]: %s\n", m.Role, content) } return b.String() } // serializeForSummary 把消息历史平铺为摘要输入:每条限 perMsgCap 字节, // 整份限 totalBudget 字节(<=0 = 不限)。 // // 整份预算是这道管线能不能救自己的关键。以前只有单条上限,摘要请求的体积随 // 会话**线性增长**——等上下文大到该压缩的时候,压缩请求自己也发不出去了, // 于是"压缩失败 → 带着完整历史继续跑 → 真实调用也失败 → 用户打继续 → 再来一遍", // 是个单向门(真实事故:会话涨到 14 万 tokens,10 个回合死 6 个,一次压缩都没发生)。 // // 超预算时保留**第一条**(用户的原始诉求,交接文档最需要的就是它)和最近的若干条, // 中间略过的用一行说明顶上——而不是悄悄少说一段,那会让摘要看起来"完整地"覆盖全程。 func serializeForSummary(msgs []llm.Message, perMsgCap, totalBudget int) string { if len(msgs) == 0 { return summaryHeader } blocks := make([]string, len(msgs)) for i, m := range msgs { blocks[i] = summaryBlock(m, perMsgCap) } if totalBudget <= 0 { var all strings.Builder all.WriteString(summaryHeader) for _, blk := range blocks { all.WriteString(blk) } return all.String() } room := totalBudget - len(summaryHeader) if room < 32 { room = 32 // 预算再小也留得下一条消息的头,否则摘要输入会是空的 } used := 0 first := blocks[0] if len(first) > room { first = cutUTF8Head(first, room) + "…[truncated]\n" } used += len(first) // 从最近一条往回收,收不下的那段就是被省略的中间部分。 start := len(blocks) // 保留段的起点(不含第一条) for i := len(blocks) - 1; i >= 1; i-- { cost := len(blocks[i]) if i > 1 { cost += len(fmt.Sprintf(omitNoteFmt, i-1)) // 预留省略说明的位置 } if used+cost > room { break } used += len(blocks[i]) start = i } var b strings.Builder b.WriteString(summaryHeader) b.WriteString(first) if start >= len(blocks) { if len(blocks) > 1 { fmt.Fprintf(&b, omitNoteFmt, len(blocks)-1) } return b.String() } if start > 1 { fmt.Fprintf(&b, omitNoteFmt, start-1) } for _, blk := range blocks[start:] { b.WriteString(blk) } return b.String() } // ContextUsage 返回当前工作集的上下文占用估算(UI 显示进度与压缩预警用)。 // 与 shouldCompact 同口径:压缩缓存 + 新消息的 token 粗估(含 system 提示)。 // 用 TryLock 避免与压缩全程互斥——压缩(一次 LLM 摘要调用)期间 UI 轮询 // 拿不到锁,返回 used=-1 表示暂不可知,调用方沿用上一次的值即可。 func (a *Agent) ContextUsage() (used, window int, compactAt float64) { if !a.compactMu.TryLock() { return -1, a.ContextWindow, a.CompactAt } defer a.compactMu.Unlock() a.validateCheckpointLocked() view := truncateToolResultsSpill(a.compactBase(), a.MaxToolResultChars, a.spillResult) view = evictOldImages(view, a.MaxImages, a.ImageBudgetBytes) return a.observedTokens(view), a.ContextWindow, a.CompactAt }