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:
@@ -8,11 +8,12 @@ import (
|
||||
|
||||
func TestTrimCloudAgentTextHistoryKeepsRecentRounds(t *testing.T) {
|
||||
history := make([]providerTextMessage, 0, 36)
|
||||
handoff := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent", Status: "failed"}, nil)
|
||||
for round := 1; round <= 12; round++ {
|
||||
history = append(history,
|
||||
providerTextMessage{Role: "user", Content: "目标" + strconv.Itoa(round)},
|
||||
providerTextMessage{Role: "assistant", Content: "回复" + strconv.Itoa(round)},
|
||||
providerTextMessage{Role: "user", Content: "上一轮已结束(completed)。本轮只执行当前用户消息,不要回头核对上一轮待办是否完成。"},
|
||||
providerTextMessage{Role: "user", Content: handoff, AgentContextSource: "continuation"},
|
||||
)
|
||||
}
|
||||
trimmed := trimCloudAgentTextHistory(history, 10, cloudAgentHistoryMaxBytes)
|
||||
@@ -23,7 +24,7 @@ func TestTrimCloudAgentTextHistoryKeepsRecentRounds(t *testing.T) {
|
||||
t.Fatalf("最旧保留轮次不对:%q", trimmed[0].Content)
|
||||
}
|
||||
last := trimmed[len(trimmed)-1]
|
||||
if last.Role != "user" || last.Content != "上一轮已结束(completed)。本轮只执行当前用户消息,不要回头核对上一轮待办是否完成。" {
|
||||
if last.Role != "user" || last.Content != handoff {
|
||||
t.Fatalf("最近一轮摘要应还在:%+v", last)
|
||||
}
|
||||
}
|
||||
@@ -51,3 +52,57 @@ func TestTrimCloudAgentTextHistoryCannotHideSingleOversizedRound(t *testing.T) {
|
||||
t.Fatal("只剩一轮且本身超过 64KB 时不能假装压进去")
|
||||
}
|
||||
}
|
||||
|
||||
// 旧数据兜底:部署前落库的交接消息没有 agentContextSource 字段(内容是纯文本
|
||||
// "上一轮已结束(…)")。裁剪与轮次计数必须按**结构位置**把它认出来,否则"最近 10 轮"
|
||||
// 会被幽灵轮次占满,更早的真人轮次被提前裁掉。
|
||||
func TestTrimCloudAgentTextHistoryKeepsRoundsWithLegacyHandoffText(t *testing.T) {
|
||||
history := make([]providerTextMessage, 0, 36)
|
||||
for round := 1; round <= 12; round++ {
|
||||
history = append(history,
|
||||
providerTextMessage{Role: "user", Content: "目标" + strconv.Itoa(round)},
|
||||
providerTextMessage{Role: "assistant", Content: "回复" + strconv.Itoa(round)},
|
||||
// 旧格式:没有来源标记,只有纯文本约定。
|
||||
providerTextMessage{Role: "user", Content: "上一轮已结束(completed)。 已提交生成任务:task-" + strconv.Itoa(round)},
|
||||
)
|
||||
}
|
||||
if got := cloudAgentHistoryUserInstructionCount(history); got != 12 {
|
||||
t.Fatalf("真人轮次计数 = %d,期望 12(旧格式交接不能被算成真人轮次)", got)
|
||||
}
|
||||
trimmed := trimCloudAgentTextHistory(history, 10, cloudAgentHistoryMaxBytes)
|
||||
if len(trimmed) == 0 || trimmed[0].Content != "目标3" {
|
||||
t.Fatalf("最旧保留轮次不对:%+v", trimmed[0])
|
||||
}
|
||||
// 结构判据不能把真人消息误判:紧跟 assistant 的 user 才是运行时帧。
|
||||
mixed := []providerTextMessage{
|
||||
{Role: "user", Content: "目标"},
|
||||
{Role: "assistant", Content: "回复"},
|
||||
{Role: "user", Content: "接着做"},
|
||||
}
|
||||
if got := cloudAgentHistoryUserInstructionCount(mixed); got != 2 {
|
||||
t.Fatalf("真人轮次计数 = %d,期望 2(\"接着做\"是真人消息)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 来源标记必须在历史重建里活下来:旧会话走 cloudAgentLegacyHistory 重建,
|
||||
// 压缩走检查点重建,两边都只拷 role/content 的话身份就丢了。
|
||||
func TestCloudAgentHistoryRebuildKeepsContextSource(t *testing.T) {
|
||||
// cloudAgentLegacyHistory 只接受 user/assistant 交替、且当前目标是偶数位的形态。
|
||||
messages := []map[string]interface{}{
|
||||
{"role": "user", "content": "目标"},
|
||||
{"role": "assistant", "content": "回复"},
|
||||
{"role": "user", "content": "【运行状态】{\"source\":\"server\",\"kind\":\"run_handoff\"}", cloudAgentContextSourceKey: "continuation"},
|
||||
{"role": "assistant", "content": "回复2"},
|
||||
{"role": "user", "content": "当前目标"},
|
||||
}
|
||||
history := cloudAgentLegacyHistory(messages, "当前目标")
|
||||
if len(history) != 4 {
|
||||
t.Fatalf("legacy history 长度 = %d,期望 4", len(history))
|
||||
}
|
||||
if history[2].AgentContextSource != "continuation" {
|
||||
t.Fatalf("重建后丢了来源标记:%+v", history[2])
|
||||
}
|
||||
if !isCloudAgentRuntimeFrame(history, 2) {
|
||||
t.Fatal("重建后的交接帧必须仍被认成运行时帧")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user