续轮(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 用于「历史怎么裁、怎么数轮」;写入与收费闸门读的 是落库策略(审批闸、生成次数闸、视频秒数闸、工具授权),不读消息内容,因此帧不构成重放 写入、重复提交付费任务或扩大权限的授权依据。
143 lines
4.5 KiB
Go
143 lines
4.5 KiB
Go
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
|
||
}
|