Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_context_compaction.go

518 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
"infinite-canvas/backend/internal/agentcontext"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// cloudAgentContextCompactionOperation 是"压缩历史"这次独立模型调用的任务操作名。
// 它必须与步进调用(cloud_agent_step)区分开:压缩不是本轮的一步,不计入步数上限,
// 失败也不会让整轮判死。
const cloudAgentContextCompactionOperation = "cloud_agent_context_compaction"
const (
// 压缩次数是异常循环护栏,不应成为正常长会话的闸门。
cloudAgentMaxCompactionsPerRun = 64
// cloudAgentContextKeepPairs 是压缩后原样保留的最近对话对数:检查点负责事实,最近两对
// 负责"用户最后说了什么、模型最后答了什么"的语感与指代。
cloudAgentContextKeepPairs = 2
)
// cloudAgentContextCompaction 是"暂停步进循环去压历史"的状态面:
// Status 为 requested(已请求、等调度起压缩任务)/ running(压缩任务已发出)。
type cloudAgentContextCompaction struct {
Status string `json:"status"`
SourceBytes int `json:"sourceBytes"`
TurnCount int `json:"turnCount"`
// Resume 表示这次是"中途暂停压缩":压完继续本轮的步进,而不是收尾结束本轮。
Resume bool `json:"resume,omitempty"`
}
// cloudAgentCompactionReading 是这次压缩判据的读数。它只用于触发事件与排查,
// 不是对外的事件契约(压力读数另有其事件,不在本特性范围内)。
type cloudAgentCompactionReading struct {
ProjectedTokens int `json:"projectedTokens"`
UsableInputTokens int `json:"usableInputTokens"`
CompactAtTokens int `json:"compactAtTokens"`
PressureRatio float64 `json:"pressureRatio"`
BudgetSource string `json:"budgetSource"`
}
// cloudAgentCompactionReadingFor 把"下一步预计输入 token"接到上游已有的预算算式上。
//
// 阈值不在这里另定:上游 cloudAgentContextBudgetFor 已经把压缩线算成模型窗口的 80%
// (CompactAtTokens),就地正文卸载用的就是它;语义压缩必须共用同一条线,否则会出现
// "显示还没到线、后台已经开始压"。
//
// 返回 configured=false 表示这次调用问不到真实模型窗口(cloudAgentContextBudgetForRequest
// 退回了首部默认预算,Source 为 default):此时 token 线没有意义,调用方改用字节/条数兜底。
//
// 注意这条降级的代价:字节/条数兜底与窗口大小无关,所以**渠道没填 contextWindowTokens 时,
// 大窗口模型会远早于"装不下"就触发压缩**——每次压缩都要多花一次模型调用(额外计费)并把
// 细节换成摘要。部署时应为画布 Agent 使用的渠道模型/逻辑模型填写真实窗口(见 PR 风险说明)。
func cloudAgentCompactionReadingFor(budget cloudAgentContextBudget, projectedTokens int) (cloudAgentCompactionReading, bool) {
reading := cloudAgentCompactionReading{
ProjectedTokens: projectedTokens,
UsableInputTokens: budget.InputBudgetTokens,
CompactAtTokens: budget.CompactAtTokens,
BudgetSource: budget.Source,
}
if budget.InputBudgetTokens > 0 {
reading.PressureRatio = math.Round(float64(projectedTokens)/float64(budget.ContextWindowTokens)*10000) / 10000
}
if !budget.Configured || budget.Source == "" || budget.Source == "default" {
return reading, false
}
return reading, true
}
// cloudAgentContextShouldCompact 是"没配模型上下文上限"时的兜底判据:条数与字节任一到线即压缩。
// 阈值沿用上游既有的跨轮历史闸门(cloudAgentHistoryKeepRounds / cloudAgentHistoryMaxBytes),
// 不另造一套数字——同一份历史不能出现"跨轮已经裁掉、轮内却还认为没超"的两套口径。
func cloudAgentContextShouldCompact(historyMessages, encodedBytes int) bool {
return agentcontext.ShouldCompact(historyMessages, encodedBytes, cloudAgentHistoryKeepRounds*2, cloudAgentHistoryMaxBytes)
}
// cloudAgentCompactionSourceFacts 量一份"被压材料"的读数(字节数 + 用户轮次):
// 两种判据都要把它写进触发事件与压缩态,排查时才说得清压掉了多少历史。
func cloudAgentCompactionSourceFacts(state *cloudAgentRuntime) (int, int) {
raw, err := json.Marshal(state.Canonical.Messages)
if err != nil {
raw = nil
}
return len(raw), cloudAgentConversationTurnCount(state.Canonical.Messages)
}
// cloudAgentCompactionEventPayload 是 context_compaction_requested 的载荷:
// 带上触发读数,排查时才能说清"到了多少、按哪个口径"。
func cloudAgentCompactionEventPayload(reading cloudAgentCompactionReading, hasReading bool, sourceBytes, turnCount int) map[string]any {
payload := map[string]any{"reason": "threshold", "sourceBytes": sourceBytes, "turnCount": turnCount}
if !hasReading {
payload["basis"] = "bytes"
return payload
}
payload["basis"] = "tokens"
payload["projectedTokens"] = reading.ProjectedTokens
payload["usableInputTokens"] = reading.UsableInputTokens
payload["compactAtTokens"] = reading.CompactAtTokens
payload["pressureRatio"] = reading.PressureRatio
payload["budgetSource"] = reading.BudgetSource
return payload
}
// cloudAgentRequestCompaction 在"下一步预计输入达到压缩线"时【暂停步进循环】:
// 先把历史压成结构化检查点,压完再用压缩后的上下文继续本轮,而不是带着接近满的上下文
// 再发一次请求、更不是直接判死。返回 true 表示已请求压缩,调用方应落库并结束这一步。
//
// 触发者只有两处:token 线(渠道/逻辑模型配了上下文窗口,取其中较小者)与字节/条数兜底。
func (s *Service) cloudAgentRequestCompaction(run *model.CloudAgentExecution, state *cloudAgentRuntime, budget cloudAgentContextBudget, projectedTokens int) (bool, error) {
if run == nil || state == nil || state.ContextCompaction != nil {
return false, nil
}
// 还有在跑的模型/媒体任务时先不评估:本轮的下一步本来就要等它回来。
if state.ActiveTaskID != "" {
return false, nil
}
// 次数上限:压完仍然超阈值时不能无限"暂停 → 压缩 → 再暂停"。
if state.ContextCompactionCount >= cloudAgentMaxCompactionsPerRun {
return false, nil
}
reading, hasReading := cloudAgentCompactionReadingFor(budget, projectedTokens)
sourceBytes, turnCount := cloudAgentCompactionSourceFacts(state)
needed := false
if hasReading {
needed = reading.ProjectedTokens >= reading.CompactAtTokens
} else {
needed = cloudAgentContextShouldCompact(len(state.Canonical.Messages), sourceBytes)
}
if !needed {
return false, nil
}
state.ContextCompaction = &cloudAgentContextCompaction{Status: "requested", SourceBytes: sourceBytes, TurnCount: turnCount, Resume: true}
state.event(run.ID, "context_compaction_requested", cloudAgentCompactionEventPayload(reading, hasReading, sourceBytes, turnCount))
return true, s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, state)
})
}
// cloudAgentConversationTurnCount 数的是"用户轮次",且不把检查点摘要算成一轮:
// 摘要是模型写的历史,不是用户原话,把它算进去会让下一轮的轮次口径凭空变大。
func cloudAgentConversationTurnCount(messages []map[string]any) int {
count := 0
for _, message := range messages {
if stringField(message, "role") != "user" {
continue
}
content := stringField(message, "content")
if stringField(message, cloudAgentContextSourceKey) == "checkpoint" {
if checkpoint, err := agentcontext.ParseFrame(content); err == nil {
if checkpoint.CompactedTurnCount > count {
count = checkpoint.CompactedTurnCount
}
continue
}
}
count++
}
return count
}
// cloudAgentContextFacts 把本轮事件收敛成"可摘要的服务端事实"。
// 只保留有执行含义的事件类型,并丢掉技能读取的正文回执:压缩模型需要知道"做过什么、
// 什么还没回来",不需要重新读一遍工具输出。
func cloudAgentContextFacts(events []CloudAgentEvent) []map[string]any {
start := max(0, len(events)-200)
facts := make([]map[string]any, 0, len(events)-start)
for _, event := range events[start:] {
switch event.Type {
case "tool_completed", "tool_failed", "generation_task_created", "approval_decided", "run_failed", "canvas_updated":
default:
continue
}
if stringValue(event.Payload["toolName"]) == "skills_load" {
continue
}
fact := map[string]any{"event": event.Type, "seq": event.Seq}
for _, key := range []string{"toolName", "callId", "nodeId", "nodeIds", "referenceNodeIds", "taskId", "title", "summary", "status", "decision", "phase", "taskSubmitted", "reason", "operation"} {
if value, ok := event.Payload[key]; ok {
fact[key] = value
}
}
if result, ok := event.Payload["result"].(map[string]any); ok {
for _, key := range []string{"nodeId", "nodeIds", "referenceNodeIds", "taskId", "title", "summary", "status", "phase", "taskSubmitted"} {
if value, exists := result[key]; exists {
fact[key] = value
}
}
}
if event.Type == "canvas_updated" {
// The durable canvas event stores changes under actions, not top-level
// nodeIds. Keep only bounded identities, never the full canvasPatch.
ids := make([]string, 0, 3)
for _, action := range creationMaps(event.Payload["actions"]) {
if id := stringValue(action["nodeId"]); id != "" && !cloudAgentContainsString(ids, id) {
ids = append(ids, id)
if len(ids) == 3 {
break
}
}
}
if len(ids) > 0 {
fact["nodeIds"] = ids
}
if event.Payload["requiresRefresh"] == true {
fact["requiresRefresh"] = true
}
}
facts = append(facts, fact)
}
return facts
}
func cloudAgentJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(raw)
}
func cloudAgentContextCompactionPrompt(state *cloudAgentRuntime) string {
turnCount := cloudAgentConversationTurnCount(state.Canonical.Messages)
if state.ContextCompaction != nil && state.ContextCompaction.TurnCount > turnCount {
turnCount = state.ContextCompaction.TurnCount
}
return agentcontext.BuildPrompt(agentcontext.Source{
ConversationJSON: cloudAgentJSON(state.Canonical.Messages),
OperationsJSON: cloudAgentJSON(cloudAgentContextFacts(state.Events)),
CreativeJSON: cloudAgentJSON(state.CreativeAnchor),
PreferencesJSON: cloudAgentJSON(state.Profile.Layers),
DecisionsJSON: cloudAgentJSON(state.Decisions),
TurnCount: turnCount,
})
}
// cloudAgentFallbackCheckpoint 是压缩失败时的**服务端保底检查点**:不依赖模型输出,
// 只把服务端已有的事实(用户要求、创作锚点、偏好、决策、事件流水)整理成同一份契约。
// 有它才能保证"压缩调用失败"只是少了一份更好的摘要,而不是整轮没有上下文可用。
func cloudAgentFallbackCheckpoint(state *cloudAgentRuntime) agentcontext.Checkpoint {
checkpoint := agentcontext.Checkpoint{Version: agentcontext.Version}
if state.ContextCompaction != nil {
checkpoint.CompactedTurnCount = state.ContextCompaction.TurnCount
}
var history []string
for _, message := range state.Canonical.Messages {
role := stringField(message, "role")
content := strings.TrimSpace(stringField(message, "content"))
if stringField(message, cloudAgentContextSourceKey) == "checkpoint" {
if previous, err := agentcontext.ParseFrame(content); err == nil {
checkpoint = previous
continue
}
}
if (role == "user" || role == "assistant") && content != "" {
history = append(history, role+": "+truncateRunes(content, 1800))
}
}
if len(history) > 8 {
history = history[len(history)-8:]
}
currentHistory := strings.Join(history, "\n")
checkpoint.HistorySummary = truncateRunes(strings.TrimSpace(checkpoint.HistorySummary+"\n"+currentHistory), 10000)
checkpoint.ScriptDesign = truncateRunes(strings.TrimSpace(checkpoint.ScriptDesign+"\n"+state.CreativeAnchor.UserPrompt+"\n"+currentHistory), 10000)
checkpoint.CurrentWork = truncateRunes(state.Request.Prompt, 3000)
checkpoint.NextStep = "继续当前工作;执行前重新读取画布和任务状态,并优先处理未完成任务。"
// 锚点不再携带权限与"可自行决定"清单(用户消息定义目标),这里只把跨步必须记住的
// 视觉事实带进摘要:否则压缩后模型会重新声称"没有视觉识别证据"并重复看图。
for _, asset := range state.CreativeAnchor.ReferenceAssets {
if asset.VisualIdentity != "inspected" {
continue
}
checkpoint.Decisions = append(checkpoint.Decisions, fmt.Sprintf("已查看过画面 %s(%s),无需重复看图", asset.NodeID, asset.Type))
}
checkpoint.Constraints = append(checkpoint.Constraints, fmt.Sprintf("权限模式:%s;本轮预算上限:%.4f credits", state.Request.PermissionMode, state.Request.Budget.MaxCredits))
for _, layer := range state.Profile.Layers {
if content := strings.TrimSpace(layer.Content); content != "" {
checkpoint.UserPreferences = append(checkpoint.UserPreferences, layer.Scope+": "+truncateRunes(content, 2400))
}
}
keys := make([]string, 0, len(state.Decisions))
for key := range state.Decisions {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
checkpoint.Decisions = append(checkpoint.Decisions, key+": "+truncateRunes(state.Decisions[key], 1200))
}
for _, fact := range cloudAgentContextFacts(state.Events) {
encoded := truncateRunes(cloudAgentJSON(fact), 1200)
checkpoint.OperationHistory = append(checkpoint.OperationHistory, encoded)
if fact["event"] == "generation_task_created" || stringValue(fact["status"]) == "running" {
checkpoint.PendingTasks = append(checkpoint.PendingTasks, encoded)
}
}
if len(checkpoint.OperationHistory) > 24 {
checkpoint.OperationHistory = checkpoint.OperationHistory[len(checkpoint.OperationHistory)-24:]
}
if len(checkpoint.PendingTasks) > 12 {
checkpoint.PendingTasks = checkpoint.PendingTasks[len(checkpoint.PendingTasks)-12:]
}
return checkpoint
}
// cloudAgentBoundCheckpoint 把检查点压进固定预算:它是"省上下文"的手段,自己不能变成
// 新的超预算输入。只截断、不新增事实。
func cloudAgentBoundCheckpoint(checkpoint agentcontext.Checkpoint) agentcontext.Checkpoint {
checkpoint.HistorySummary = truncateRunes(checkpoint.HistorySummary, 3000)
checkpoint.ScriptDesign = truncateRunes(checkpoint.ScriptDesign, 4000)
checkpoint.CurrentWork = truncateRunes(checkpoint.CurrentWork, 800)
checkpoint.NextStep = truncateRunes(checkpoint.NextStep, 500)
bound := func(values []string, maxItems, maxRunes int) []string {
if len(values) > maxItems {
values = values[len(values)-maxItems:]
}
result := make([]string, 0, len(values))
for _, value := range values {
result = append(result, truncateRunes(value, maxRunes))
}
return result
}
checkpoint.OperationHistory = bound(checkpoint.OperationHistory, 10, 150)
checkpoint.PendingTasks = bound(checkpoint.PendingTasks, 6, 150)
checkpoint.Decisions = bound(checkpoint.Decisions, 8, 150)
checkpoint.Constraints = bound(checkpoint.Constraints, 8, 150)
checkpoint.UserPreferences = bound(checkpoint.UserPreferences, 6, 300)
return checkpoint
}
// cloudAgentCompleteTurnTail 取会话尾部最近 pairs 轮**完整**对话。
//
// 按轮次裁剪时必须成立的不变量(与上游既有口径一致):
// - 绝不以 assistant(tool_calls) 或 tool 回执开头:不制造"有调用没结果"的半截轮次;
// - 带工具调用的 assistant 一律不进这里——它的调用参数与结果不在纯文本对里,
// 留下来会让下一轮收到一个永远闭合不了的调用;
// - 检查点正文(<agent-context-checkpoint>)不算一轮,避免把摘要当成用户原话。
func cloudAgentCompleteTurnTail(messages []map[string]any, pairs int) []providerTextMessage {
complete := make([]providerTextMessage, 0, max(0, pairs)*2)
var pending *providerTextMessage
for _, message := range messages {
role, content := stringField(message, "role"), strings.TrimSpace(stringField(message, "content"))
if (role != "user" && role != "assistant") || content == "" || stringField(message, cloudAgentContextSourceKey) == "runtime" || stringField(message, cloudAgentContextSourceKey) == "continuation" {
continue
}
if stringField(message, cloudAgentContextSourceKey) == "checkpoint" {
if _, err := agentcontext.ParseFrame(content); err == nil {
continue
}
}
if role == "user" {
candidate := providerTextMessage{Role: role, Content: content}
pending = &candidate
continue
}
if _, hasCalls := message["tool_calls"]; hasCalls || pending == nil {
continue
}
complete = append(complete, *pending, providerTextMessage{Role: role, Content: content})
pending = nil
}
start := max(0, len(complete)-max(0, pairs)*2)
result := append([]providerTextMessage(nil), complete[start:]...)
// 尚未收到回答的最后一条用户要求也必须原样保留;否则压缩会把当前目标
// 只交给不可信的模型摘要,模型漏写时下一步便静默失去用户指令。
if pending != nil {
result = append(result, *pending)
}
return result
}
// cloudAgentRetainedTurnCount 数的是"压缩后仍原样留在上下文里的用户轮次"。
func cloudAgentRetainedTurnCount(retained []providerTextMessage) int {
count := 0
for _, message := range retained {
if message.Role == "user" {
count++
}
}
return count
}
// cloudAgentCheckpointHistory 组装压缩后的历史:检查点 + 回执 + 最近 N 对原文。
// 回执是必须的——它让检查点这段 user 消息后面紧跟 assistant,历史仍保持正常交替。
func cloudAgentCheckpointHistory(checkpoint agentcontext.Checkpoint, recent []providerTextMessage) ([]providerTextMessage, error) {
framed, err := agentcontext.Frame(checkpoint)
if err != nil {
return nil, err
}
history := []providerTextMessage{{Role: "user", Content: framed, AgentContextSource: "checkpoint"}, {Role: "assistant", Content: agentcontext.Acknowledgement}}
return append(history, recent...), nil
}
// enqueueCloudAgentContextCompaction 发出一次**独立的模型调用**做压缩:不流式、不带工具、
// 用同一个渠道(同一份记账口径),但它的结果只用于生成检查点,不进入面向用户的消息流。
func (s *Service) enqueueCloudAgentContextCompaction(run *model.CloudAgentExecution, state *cloudAgentRuntime) error {
prompt := cloudAgentContextCompactionPrompt(state)
input := map[string]any{
"mode": "text", "prompt": prompt,
"config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model), "systemPrompt": "只执行服务端上下文压缩合同。不要调用工具,不要生成面向用户的回复。"},
"textOptions": map[string]any{"stream": false, "thinking": false},
}
req := CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_text", Operation: cloudAgentContextCompactionOperation, Prompt: prompt, Model: state.Request.Model, LogicalModelID: state.Request.LogicalModelID, Input: input}
return s.enqueueCloudAgentTask(run, state, req, nil)
}
// advanceCloudAgentContextCompaction 收压缩任务的结果。任何失败都退到保底检查点:
// 压缩是"省上下文"的优化,不能因为一次模型调用失败就把整轮搞死。
func (s *Service) advanceCloudAgentContextCompaction(run *model.CloudAgentExecution, state *cloudAgentRuntime, task *model.Task) error {
if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning {
return nil
}
checkpoint := cloudAgentFallbackCheckpoint(state)
mode, reason := "fallback", "压缩模型任务未成功,已使用服务端保底检查点"
if task.Status == model.TaskStatusSucceeded {
var result struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(task.ResultJSON), &result); err == nil {
if parsed, parseErr := agentcontext.Parse(result.Text); parseErr == nil {
// JSON 合同只能证明形状,不能证明任务 ID、审批和执行结果是真的。
// 执行事实只取本轮服务端事件,不采纳模型写的 operation/pending 列表。
parsed.OperationHistory = checkpoint.OperationHistory
parsed.PendingTasks = checkpoint.PendingTasks
checkpoint, mode, reason = parsed, "model", ""
} else {
reason = "压缩模型输出不符合检查点合同,已使用服务端保底检查点"
}
} else {
reason = "压缩模型结果损坏,已使用服务端保底检查点"
}
}
if state.ContextCompaction != nil {
// 检查点里的轮次数以服务端自己的计数为准,模型写的数字不可信。
checkpoint.CompactedTurnCount = state.ContextCompaction.TurnCount
}
return s.persistCloudAgentContextCheckpoint(run, state, checkpoint, mode, reason)
}
func (s *Service) persistCloudAgentContextCheckpoint(run *model.CloudAgentExecution, state *cloudAgentRuntime, checkpoint agentcontext.Checkpoint, mode, reason string) error {
return s.writeCloudAgentContextCheckpoint(run, state, checkpoint, mode, reason, false)
}
// finalizeCloudAgentInterruptedCompaction 收拾"停在压缩上"的终态轮次。
//
// 压缩是「暂停步进 → 压完继续」的中途动作:本轮在压缩期间被取消(或已经失败)时,
// 调度器不会再推进这一轮,于是 ContextCompaction 会永远挂在 requested/running 上
// (界面一直显示"正在压缩"),那份检查点也永远落不了盘。这里按服务端保底检查点收口。
func (s *Service) finalizeCloudAgentInterruptedCompaction(run *model.CloudAgentExecution, state *cloudAgentRuntime, reason string) error {
if run == nil || state == nil || state.ContextCompaction == nil {
return nil
}
checkpoint := cloudAgentFallbackCheckpoint(state)
checkpoint.CompactedTurnCount = state.ContextCompaction.TurnCount
return s.writeCloudAgentContextCheckpoint(run, state, checkpoint, "fallback", reason, true)
}
// writeCloudAgentContextCheckpoint 是落检查点的唯一实现。
//
// keepTerminal=true 用于「本轮已经是终态」的收尾:只落检查点、历史与事件,不改运行状态
// (否则一次失败轮的收尾会把 failed 写成 completed,等于凭空复活一轮)。
func (s *Service) writeCloudAgentContextCheckpoint(run *model.CloudAgentExecution, state *cloudAgentRuntime, checkpoint agentcontext.Checkpoint, mode, reason string, keepTerminal bool) error {
checkpoint = cloudAgentBoundCheckpoint(checkpoint)
turnsBefore := cloudAgentConversationTurnCount(state.Canonical.Messages)
recent := cloudAgentCompleteTurnTail(state.Canonical.Messages, cloudAgentContextKeepPairs)
history, err := cloudAgentCheckpointHistory(checkpoint, recent)
if err != nil {
return cloudAgentCheckpointFailure("compaction checkpoint history", err)
}
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
state.ContextCheckpoint = &checkpoint
state.TextHistory = history
state.HistoryIncludesCurrent = true
state.Canonical.Messages = make([]map[string]any, 0, len(history))
for _, message := range history {
entry := map[string]any{"role": message.Role, "content": message.Content}
// 压缩重建也必须保留来源标记,否则交接身份在压缩后就丢了(旧会话会被多算轮次)。
if message.AgentContextSource != "" {
entry[cloudAgentContextSourceKey] = message.AgentContextSource
}
state.Canonical.Messages = append(state.Canonical.Messages, entry)
}
// 消息整体被替换:写路径按 kind 逐条 upsert 并删除 sequence 超出新条数的尾部行,
// 因此"条数变少/全变"都能正确落库。
// 只读结果由 ToolReadResults 统一缓存;压缩后模型再次请求时,缓存层会恢复完整正文。
// 只有 profile 的旧版读取标记需要清掉,因为它不属于统一结果缓存。
state.ProfileReads = nil
state.ActiveTaskID, state.ActiveTextDraft = "", ""
// 中途暂停压缩(Resume):压完继续本轮的步进,并记一次次数上限。
// 收尾压缩(resume=false)本来就要结束本轮;keepTerminal=true 表示本轮已是终态
// (取消/失败的收尾),只落检查点与事件,绝不把 failed/cancelled 改写成 completed。
resume := state.ContextCompaction != nil && state.ContextCompaction.Resume
state.ContextCompaction = nil
if resume {
state.ContextCompactionCount++
} else if !keepTerminal {
current.Status = "completed"
}
payload := map[string]any{
"mode": mode, "resume": resume,
"compactedTurnCount": checkpoint.CompactedTurnCount,
"historyMessages": len(history),
// 被折进检查点的轮次数 = 压缩前的用户轮次 − 原样保留的最近轮次:
// 不能拿"压缩后再数一遍"来做差,检查点自己携带轮次数会把保留的那几轮重复计入。
"droppedTurns": max(0, turnsBefore-cloudAgentRetainedTurnCount(recent)),
}
if reason != "" {
payload["reason"] = reason
}
state.event(run.ID, "context_compacted", payload)
return cloudAgentSave(current, state)
})
}