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 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的
是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放
写入、重复提交付费任务或扩大权限的授权依据。
This commit is contained in:
AetherNo2332
2026-09-22 01:32:17 +08:00
parent 6f45240599
commit 9d0bce6bb5
8 changed files with 395 additions and 34 deletions

View File

@@ -11,17 +11,70 @@ const (
)
func isCloudAgentContinuationMessage(message providerTextMessage) bool {
if message.Role != "user" {
if message.Role != "user" || message.AgentContextSource != "continuation" {
return false
}
content := strings.TrimSpace(message.Content)
return strings.HasPrefix(content, "上一轮真实执行记录") || strings.HasPrefix(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 _, message := range history {
if message.Role == "user" && !isCloudAgentContinuationMessage(message) {
for index := range history {
if history[index].Role == "user" && !isCloudAgentRuntimeFrame(history, index) {
count++
}
}
@@ -40,12 +93,12 @@ func dropOldestCloudAgentHistoryRound(history []providerTextMessage) []providerT
if len(history) == 0 {
return history
}
if isCloudAgentContinuationMessage(history[0]) || history[0].Role != "user" {
if isCloudAgentRuntimeFrame(history, 0) || history[0].Role != "user" {
return history[1:]
}
index := 1
for index < len(history) {
if history[index].Role == "user" && !isCloudAgentContinuationMessage(history[index]) {
if history[index].Role == "user" && !isCloudAgentRuntimeFrame(history, index) {
break
}
index++
@@ -68,7 +121,7 @@ func trimCloudAgentTextHistory(history []providerTextMessage, keepRounds, maxByt
start := 0
seen := 0
for index := len(history) - 1; index >= 0; index-- {
if history[index].Role != "user" || isCloudAgentContinuationMessage(history[index]) {
if history[index].Role != "user" || isCloudAgentRuntimeFrame(history, index) {
continue
}
seen++