Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_working_context.go
AetherNo2332 58f663bbcc fix(cloud-agent): 续轮交接 - 用有界结构化事实帧替代跨轮散文收束
续轮(POST /agent/runs 带 parentId)过去只把「上一轮已结束(failed)。…已提交生成任务:
a, b」这段散文塞进下一轮历史:新轮看不到上一轮真实改过哪些节点,也无法可靠区分「这是
服务端交接」还是「用户自己写的一句话」。

- 与轮内既有事实帧(cloud_agent_context_frame.go 的 task_repository 帧)同构,把跨轮交接
  也改成有界结构化帧:source=server、kind=run_handoff,带 parentRunId / status /
  failureReason / submittedTaskIds / canvasChanges / omittedCanvasChanges / authority。
  画布改动取自上一轮真实 canvas_updated 事件,最多 12 组(operation、去重后的
  nodeId/title、actionCount),超出只记 omittedCanvasChanges,不静默丢。
- 身份改由 providerTextMessage.AgentContextSource 结构体字段承载,canonical 装配时原样
  透出;isCloudAgentContinuationMessage 要求「来源元数据 + 【运行状态】前缀 + JSON
  source=server/kind=run_handoff」三者同时成立,用户逐字复制帧 JSON 也拿不到该身份
  (此前按内容前缀猜,用户自己写一句「上一轮已结束(…)」就会被认成交接)。
- 旧格式兼容:升级前落库的散文交接没有来源字段,改按「紧跟 assistant 回复 + 旧版文案
  前缀」识别,只用于历史裁剪与轮次计数;canonical 身份仍只认结构体字段。
- cloudAgentLegacyHistory 重建时把落库的 agentContextSource 一起搬,身份不在重建口丢失。
- 帧按字节封顶:单个 nodeId 截 64 字符、整帧 8 KiB,超限按最近改动优先裁剪并累计
  omittedCanvasChanges;失败原因过既有脱敏口径 cloudAgentSafeUserMessage。
- 不搬运 dev 专有内容:不引入事件读取上限常量(上游本就返回全量事件),硬闸口径按上游
  的 64 KiB 注释,也不引入 `dropped := 0` / `_ = dropped` 这类死代码。
- docs/content/docs/backend/http-api.mdx 补帧字段与「非授权」口径。

帧只被 isCloudAgentContinuationMessage 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的
是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放
写入、重复提交付费任务或扩大权限的授权依据。
2026-09-22 01:32:21 +08:00

143 lines
4.5 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"
"strings"
)
const (
cloudAgentHistoryKeepRounds = 10
cloudAgentHistoryMaxBytes = 64000
)
func isCloudAgentContinuationMessage(message providerTextMessage) bool {
if message.Role != "user" || message.AgentContextSource != "continuation" {
return false
}
content := strings.TrimSpace(message.Content)
if !strings.HasPrefix(content, cloudAgentRuntimeContextMarker) {
return false
}
var frame struct {
Source string `json:"source"`
Kind string `json:"kind"`
}
if err := json.Unmarshal([]byte(strings.TrimPrefix(content, cloudAgentRuntimeContextMarker)), &frame); err != nil {
return false
}
return frame.Source == "server" && frame.Kind == cloudAgentContinuationKind
}
// cloudAgentLegacyRuntimeFramePrefixes 是我们自己产出的旧版交接文案前缀。
// 只用于"这份历史怎么裁"的判断,不用于给消息盖身份(见下)。
var cloudAgentLegacyRuntimeFramePrefixes = []string{"上一轮已结束(", "上一轮真实执行记录"}
// isCloudAgentLegacyRuntimeFrame 识别"部署前落库、没有来源字段"的旧版交接消息。
//
// `agentContextSource` 是后加的字段:升级后旧会话里那些 "上一轮已结束(…)" 一律失配,
// 会被算成真人轮次,于是"最近 10 轮"的窗口被幽灵轮次占满、更早的真人轮次被提前裁掉。
func isCloudAgentLegacyRuntimeFrame(message providerTextMessage) bool {
if message.Role != "user" || message.AgentContextSource != "" {
return false
}
content := strings.TrimSpace(message.Content)
for _, prefix := range cloudAgentLegacyRuntimeFramePrefixes {
if strings.HasPrefix(content, prefix) {
return true
}
}
return false
}
// isCloudAgentRuntimeFrame 判断 history[index] 是不是"服务端生成的运行时交接帧"。
//
// 判据:结构化身份(新数据)**或**"紧跟上一轮 assistant 回复 + 旧版交接文案"(旧数据)。
// 位置这一条不能单独用:压缩收尾后历史来自检查点,真人轮次也可能紧跟在 assistant 之后,
// 只按位置判会把真人轮次也当成帧、提前裁掉。
//
// 注意:这里只影响裁剪与轮次计数;**canonical 的来源标记仍然只认结构体字段**,
// 用户自己写一段 "上一轮已结束(…)" 不会因此获得 continuation 身份(只是那一轮不计入轮数)。
func isCloudAgentRuntimeFrame(history []providerTextMessage, index int) bool {
if index < 0 || index >= len(history) {
return false
}
message := history[index]
if message.Role != "user" {
return false
}
if isCloudAgentContinuationMessage(message) {
return true
}
return index > 0 && history[index-1].Role == "assistant" && isCloudAgentLegacyRuntimeFrame(message)
}
func cloudAgentHistoryUserInstructionCount(history []providerTextMessage) int {
count := 0
for index := range history {
if history[index].Role == "user" && !isCloudAgentRuntimeFrame(history, index) {
count++
}
}
return count
}
func cloudAgentHistoryJSONSize(history []providerTextMessage) int {
raw, err := json.Marshal(history)
if err != nil {
return 0
}
return len(raw)
}
func dropOldestCloudAgentHistoryRound(history []providerTextMessage) []providerTextMessage {
if len(history) == 0 {
return history
}
if isCloudAgentRuntimeFrame(history, 0) || history[0].Role != "user" {
return history[1:]
}
index := 1
for index < len(history) {
if history[index].Role == "user" && !isCloudAgentRuntimeFrame(history, index) {
break
}
index++
}
return history[index:]
}
// trimCloudAgentTextHistory 只把最近若干轮用户对话留给模型。
// 个人记忆、更早轮次和上一轮工具流水不进工作上下文;超字节上限时从最旧一轮往下丢。
func trimCloudAgentTextHistory(history []providerTextMessage, keepRounds, maxBytes int) []providerTextMessage {
if keepRounds <= 0 {
keepRounds = cloudAgentHistoryKeepRounds
}
if maxBytes <= 0 {
maxBytes = cloudAgentHistoryMaxBytes
}
if len(history) == 0 {
return history
}
start := 0
seen := 0
for index := len(history) - 1; index >= 0; index-- {
if history[index].Role != "user" || isCloudAgentRuntimeFrame(history, index) {
continue
}
seen++
if seen == keepRounds {
start = index
break
}
}
trimmed := append([]providerTextMessage{}, history[start:]...)
for cloudAgentHistoryJSONSize(trimmed) > maxBytes && cloudAgentHistoryUserInstructionCount(trimmed) > 1 {
next := dropOldestCloudAgentHistoryRound(trimmed)
if len(next) >= len(trimmed) {
break
}
trimmed = next
}
return trimmed
}