518 lines
25 KiB
Go
518 lines
25 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strings"
|
||
|
||
"yingce/backend/internal/agentcontext"
|
||
"yingce/backend/internal/model"
|
||
"yingce/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)
|
||
})
|
||
}
|