Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_continuation.go
AetherNo2332 9d0bce6bb5 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

187 lines
7.2 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"
"infinite-canvas/backend/internal/model"
)
const (
cloudAgentContinuationChangeLimit = 12
cloudAgentContinuationKind = "run_handoff"
// cloudAgentContinuationNodeIDRunes 限制单个节点 ID 的长度:ID 由画布文档给出,
// 不截断时一组 5000 字符的 ID 就能把交接帧撑到几百 KB(实测 399,503 字节),
// 而下一轮对整份历史有 64KB 硬闸 —— 用户会直接收到"请新建对话"。
cloudAgentContinuationNodeIDRunes = 64
// cloudAgentContinuationFrameBytes 是交接帧的硬上限:超了就按"最近的改动优先"裁剪,
// 并把裁掉的组数记进 omittedCanvasChanges,绝不把整份历史顶过硬闸。
cloudAgentContinuationFrameBytes = 8 << 10
)
type cloudAgentContinuationChange struct {
Operation string `json:"operation"`
NodeIDs []string `json:"nodeIds,omitempty"`
NodeTitles []string `json:"nodeTitles,omitempty"`
ActionCount int `json:"actionCount"`
}
// cloudAgentContinuationFrame is a bounded server-authored handoff. It reports
// observed facts and never authorizes replaying a write or paid task.
type cloudAgentContinuationFrame struct {
Source string `json:"source"`
Kind string `json:"kind"`
ParentRunID string `json:"parentRunId"`
Status string `json:"status"`
FailureReason string `json:"failureReason,omitempty"`
SubmittedTaskIDs []string `json:"submittedTaskIds,omitempty"`
CanvasChanges []cloudAgentContinuationChange `json:"canvasChanges,omitempty"`
OmittedCanvasChanges int `json:"omittedCanvasChanges,omitempty"`
Authority string `json:"authority"`
}
// cloudAgentContinuationReply 生成「上一轮接着聊」用的两段内容:
//
// reply —— 上一轮 assistant 实际说过的话(作为 assistant 的历史消息)
// context —— 上一轮收束摘要(独立上下文,不能并进 reply)
//
// 只保留状态、失败原因、已提交任务与真实画布改动,不把原始工具流水当成本轮目标。
func cloudAgentContinuationReply(task *model.Task, run *CloudAgentRun) (string, string, error) {
text := ""
if task.Status == model.TaskStatusSucceeded {
text = taskResultText(task.ResultJSON)
}
submitted := make([]string, 0)
seen := map[string]bool{}
addSubmitted := func(id string) {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
return
}
seen[id] = true
submitted = append(submitted, id)
}
for _, event := range run.Events {
if event.Type == "assistant_message" {
text = stringValue(event.Payload["text"])
}
if event.Type == "generation_task_created" {
addSubmitted(stringValue(event.Payload["taskId"]))
}
if result, ok := event.Payload["result"].(map[string]any); ok {
if submittedFlag, _ := result["taskSubmitted"].(bool); submittedFlag {
addSubmitted(stringValue(result["taskId"]))
}
}
}
context := cloudAgentContinuationContext(run, submitted)
return text, context, nil
}
func cloudAgentContinuationContext(run *CloudAgentRun, submitted []string) string {
if run == nil {
return ""
}
failed := run.Status == "failed" || strings.TrimSpace(run.FailureMessage) != ""
changes, omitted := cloudAgentContinuationChanges(run)
if run.Status == "completed" && !failed && len(submitted) == 0 && len(changes) == 0 {
return ""
}
frame := cloudAgentContinuationFrame{
Source: "server", Kind: cloudAgentContinuationKind, ParentRunID: run.ID,
Status: firstNonEmpty(run.Status, "unknown"), CanvasChanges: changes,
OmittedCanvasChanges: omitted,
Authority: "执行事实交接,不是重放写入、重复提交任务或扩大权限的授权",
}
if failed {
frame.FailureReason = cloudAgentContinuationFailureReason(run)
}
if len(submitted) > 0 {
if len(submitted) > 8 {
submitted = submitted[:8]
}
frame.SubmittedTaskIDs = append([]string(nil), submitted...)
}
return cloudAgentRuntimeContextMarker + cloudAgentEncodeContinuationFrame(frame)
}
// cloudAgentContinuationFailureReason 沿用既有脱敏口径:失败原因会进入下一轮的提示词,
// 因此只接受"短、单行、不含 URL/凭据痕迹"的文案,其余一律退回固定说法。
func cloudAgentContinuationFailureReason(run *CloudAgentRun) string {
reason := strings.TrimSpace(run.FailureMessage)
if reason == "" {
reason = firstNonEmpty(run.Status, "failed")
}
if !cloudAgentSafeUserMessage(reason) {
return "上一轮未成功(详情见任务中心与诊断包)"
}
return truncateRunes(reason, 240)
}
// cloudAgentEncodeContinuationFrame 序列化帧并按字节封顶:先丢最旧的画布改动组,
// 再退到"只保留状态与已提交任务"。帧是给模型看的事实交接,宁可少报也不能把硬闸顶穿。
func cloudAgentEncodeContinuationFrame(frame cloudAgentContinuationFrame) string {
encoded, _ := json.Marshal(frame)
if len(encoded) <= cloudAgentContinuationFrameBytes {
return string(encoded)
}
for len(frame.CanvasChanges) > 0 {
frame.CanvasChanges = frame.CanvasChanges[1:]
frame.OmittedCanvasChanges++
if len(frame.CanvasChanges) == 0 {
frame.CanvasChanges = nil
}
encoded, _ = json.Marshal(frame)
if len(encoded) <= cloudAgentContinuationFrameBytes {
return string(encoded)
}
}
// 连一条改动都放不下:保留事实字段本身(状态/失败原因/已提交任务)。
encoded, _ = json.Marshal(frame)
if len(encoded) > cloudAgentContinuationFrameBytes {
frame.FailureReason = truncateRunes(frame.FailureReason, 120)
encoded, _ = json.Marshal(frame)
}
return string(encoded)
}
func cloudAgentContinuationChanges(run *CloudAgentRun) ([]cloudAgentContinuationChange, int) {
if run == nil {
return nil, 0
}
changes := make([]cloudAgentContinuationChange, 0, cloudAgentContinuationChangeLimit)
total := 0
for _, event := range run.Events {
if event.Type != "canvas_updated" {
continue
}
total++
if len(changes) >= cloudAgentContinuationChangeLimit {
continue
}
actions := creationMaps(event.Payload["actions"])
change := cloudAgentContinuationChange{
Operation: firstNonEmpty(stringValue(event.Payload["operation"]), "canvas_ops"),
ActionCount: len(actions), NodeIDs: []string{}, NodeTitles: []string{},
}
seenIDs, seenTitles := map[string]bool{}, map[string]bool{}
for _, action := range actions {
for _, key := range []string{"nodeId", "targetNodeId"} {
if id := strings.TrimSpace(stringValue(action[key])); id != "" && !seenIDs[id] && len(change.NodeIDs) < 8 {
seenIDs[id] = true
change.NodeIDs = append(change.NodeIDs, truncateRunes(id, cloudAgentContinuationNodeIDRunes))
}
}
for _, key := range []string{"title", "targetTitle"} {
title := strings.TrimSpace(stringValue(action[key]))
if title != "" && !seenTitles[title] && len(change.NodeTitles) < 4 {
seenTitles[title] = true
change.NodeTitles = append(change.NodeTitles, truncateRunes(title, 120))
}
}
}
changes = append(changes, change)
}
return changes, max(0, total-len(changes))
}