Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_continuation.go

191 lines
7.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"
"yingce/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
// cloudAgentContinuationEventLimit 是续轮读取上一轮事件的上限。运行详情默认只返回尾部
// 一窗,而收束要覆盖上一轮**全部**真实改动(画布改动、已提交任务、助手原话),
// 被窗口截断就会表现为"新一轮忘了上一轮做过什么"。
cloudAgentContinuationEventLimit = 1000
)
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))
}