llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
902 lines
35 KiB
Go
902 lines
35 KiB
Go
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
|
||
}
|