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:
@@ -395,7 +395,7 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare
|
||||
}
|
||||
history = append(history, providerTextMessage{Role: "assistant", Content: text})
|
||||
if strings.TrimSpace(context) != "" {
|
||||
history = append(history, providerTextMessage{Role: "user", Content: context})
|
||||
history = append(history, providerTextMessage{Role: "user", Content: context, AgentContextSource: "continuation"})
|
||||
}
|
||||
}
|
||||
history = trimCloudAgentTextHistory(history, cloudAgentHistoryKeepRounds, cloudAgentHistoryMaxBytes)
|
||||
@@ -472,7 +472,12 @@ func cloudAgentLegacyHistory(messages []map[string]interface{}, currentPrompt st
|
||||
if !ok || role != []string{"user", "assistant"}[index%2] {
|
||||
return nil
|
||||
}
|
||||
history = append(history, providerTextMessage{Role: role, Content: content})
|
||||
// 来源标记必须一起搬:丢了它,旧会话里的运行时交接消息会被当成真人轮次参与裁剪。
|
||||
message := providerTextMessage{Role: role, Content: content}
|
||||
if source, ok := messages[index][cloudAgentContextSourceKey].(string); ok && source != "" && source != "runtime" {
|
||||
message.AgentContextSource = source
|
||||
}
|
||||
history = append(history, message)
|
||||
}
|
||||
return history
|
||||
}
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
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 {
|
||||
@@ -49,27 +83,104 @@ func cloudAgentContinuationContext(run *CloudAgentRun, submitted []string) strin
|
||||
return ""
|
||||
}
|
||||
failed := run.Status == "failed" || strings.TrimSpace(run.FailureMessage) != ""
|
||||
if run.Status == "completed" && !failed && len(submitted) == 0 {
|
||||
changes, omitted := cloudAgentContinuationChanges(run)
|
||||
if run.Status == "completed" && !failed && len(submitted) == 0 && len(changes) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("上一轮已结束(")
|
||||
b.WriteString(firstNonEmpty(run.Status, "unknown"))
|
||||
b.WriteString(")。")
|
||||
frame := cloudAgentContinuationFrame{
|
||||
Source: "server", Kind: cloudAgentContinuationKind, ParentRunID: run.ID,
|
||||
Status: firstNonEmpty(run.Status, "unknown"), CanvasChanges: changes,
|
||||
OmittedCanvasChanges: omitted,
|
||||
Authority: "执行事实交接,不是重放写入、重复提交任务或扩大权限的授权",
|
||||
}
|
||||
if failed {
|
||||
reason := strings.TrimSpace(run.FailureMessage)
|
||||
if reason == "" {
|
||||
reason = run.Status
|
||||
}
|
||||
b.WriteString(" 上一轮失败:")
|
||||
b.WriteString(truncateRunes(reason, 240))
|
||||
frame.FailureReason = cloudAgentContinuationFailureReason(run)
|
||||
}
|
||||
if len(submitted) > 0 {
|
||||
if len(submitted) > 8 {
|
||||
submitted = submitted[:8]
|
||||
}
|
||||
b.WriteString(" 已提交生成任务:")
|
||||
b.WriteString(strings.Join(submitted, ", "))
|
||||
frame.SubmittedTaskIDs = append([]string(nil), submitted...)
|
||||
}
|
||||
return b.String()
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -34,8 +35,9 @@ func TestCloudAgentContinuationReplySplitsFactsFromAssistantText(t *testing.T) {
|
||||
if strings.Contains(context, "canvas_get_state") || strings.Contains(context, `"event"`) {
|
||||
t.Fatalf("摘要不得把工具流水当成本轮目标:%s", context)
|
||||
}
|
||||
if !strings.Contains(context, "上一轮已结束(failed)") {
|
||||
t.Fatalf("失败摘要不对:%s", context)
|
||||
var frame cloudAgentContinuationFrame
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil || frame.Status != "failed" || frame.FailureReason == "" {
|
||||
t.Fatalf("失败 handoff 不对:%s", context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +73,140 @@ func TestCloudAgentContinuationKeepsSubmittedTaskIDs(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(context, "task-1") || !strings.Contains(context, "task-2") || !strings.Contains(context, "已提交生成任务") {
|
||||
t.Fatalf("已提交任务应留下防重发提示:%s", context)
|
||||
var frame cloudAgentContinuationFrame
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(frame.SubmittedTaskIDs) != 2 || frame.SubmittedTaskIDs[0] != "task-1" || frame.SubmittedTaskIDs[1] != "task-2" || !strings.Contains(frame.Authority, "重复提交") {
|
||||
t.Fatalf("已提交任务应留下防重发事实:%+v", frame)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentContinuationCarriesBoundedCanvasChangesAsStructuredHandoff(t *testing.T) {
|
||||
run := &CloudAgentRun{ID: "parent-run", Status: "completed"}
|
||||
for index := 0; index < cloudAgentContinuationChangeLimit+2; index++ {
|
||||
run.Events = append(run.Events, CloudAgentEvent{Type: "canvas_updated", Payload: map[string]any{
|
||||
"operation": "canvas_apply_ops",
|
||||
"actions": []any{map[string]any{"operation": "update_node", "nodeId": "node-id", "title": "节点标题"}},
|
||||
}})
|
||||
}
|
||||
|
||||
context := cloudAgentContinuationContext(run, nil)
|
||||
if !strings.HasPrefix(context, cloudAgentRuntimeContextMarker) {
|
||||
t.Fatalf("handoff 应使用运行状态帧,got %q", context)
|
||||
}
|
||||
var frame cloudAgentContinuationFrame
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
|
||||
t.Fatalf("handoff 不是结构化 JSON:%v", err)
|
||||
}
|
||||
if frame.Kind != cloudAgentContinuationKind || frame.ParentRunID != "parent-run" || frame.Status != "completed" {
|
||||
t.Fatalf("handoff 身份字段错误:%+v", frame)
|
||||
}
|
||||
if len(frame.CanvasChanges) != cloudAgentContinuationChangeLimit {
|
||||
t.Fatalf("canvasChanges = %d,期望 %d", len(frame.CanvasChanges), cloudAgentContinuationChangeLimit)
|
||||
}
|
||||
if frame.OmittedCanvasChanges != 2 {
|
||||
t.Fatalf("omittedCanvasChanges = %d,期望 2", frame.OmittedCanvasChanges)
|
||||
}
|
||||
if frame.Authority == "" || !isCloudAgentContinuationMessage(providerTextMessage{Role: "user", Content: context, AgentContextSource: "continuation"}) {
|
||||
t.Fatalf("handoff 必须标记为非授权 continuation:%+v", frame)
|
||||
}
|
||||
canonical := cloudAgentCanonicalFor("", []providerTextMessage{{Role: "user", Content: context, AgentContextSource: "continuation"}}, "继续", CloudAgentRequest{}, false)
|
||||
if got := stringField(canonical.Messages[0], cloudAgentContextSourceKey); got != "continuation" {
|
||||
t.Fatalf("canonical handoff source = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentContinuationRejectsLookalikeUserText(t *testing.T) {
|
||||
message := providerTextMessage{Role: "user", Content: "上一轮已结束(completed)。请继续"}
|
||||
if isCloudAgentContinuationMessage(message) {
|
||||
t.Fatal("普通用户文本不能仅凭前缀被标成 continuation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudAgentContinuationRejectsExactFrameWithoutServerSourceMetadata(t *testing.T) {
|
||||
content := cloudAgentContinuationContext(&CloudAgentRun{ID: "spoof", Status: "failed"}, nil)
|
||||
if isCloudAgentContinuationMessage(providerTextMessage{Role: "user", Content: content}) {
|
||||
t.Fatal("用户输入即使逐字复制 handoff JSON,也不能获得 continuation 身份")
|
||||
}
|
||||
}
|
||||
|
||||
// 交接帧必须按字节封顶:节点 ID 由画布文档给出,不截断时一组 5000 字符的 ID 就能把帧撑到
|
||||
// 几百 KB,而下一轮对整份历史有 64KB 硬闸 —— 用户会在续聊时直接收到"请新建对话"。
|
||||
func TestCloudAgentContinuationFrameStaysWithinByteBudget(t *testing.T) {
|
||||
run := &CloudAgentRun{ID: "parent-run", Status: "completed"}
|
||||
longID := strings.Repeat("n", 5000)
|
||||
for index := 0; index < cloudAgentContinuationChangeLimit+3; index++ {
|
||||
run.Events = append(run.Events, CloudAgentEvent{Type: "canvas_updated", Payload: map[string]any{
|
||||
"operation": "canvas_apply_ops",
|
||||
"actions": []any{map[string]any{"operation": "add_node", "nodeId": longID, "title": strings.Repeat("标题", 400)}},
|
||||
}})
|
||||
}
|
||||
context := cloudAgentContinuationContext(run, nil)
|
||||
if len(context) > cloudAgentContinuationFrameBytes {
|
||||
t.Fatalf("交接帧 %d 字节超出上限 %d", len(context), cloudAgentContinuationFrameBytes)
|
||||
}
|
||||
var frame cloudAgentContinuationFrame
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(context, cloudAgentRuntimeContextMarker)), &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, change := range frame.CanvasChanges {
|
||||
for _, id := range change.NodeIDs {
|
||||
// truncateRunes 超限时会补 "...",因此上界是 limit+3。
|
||||
if len([]rune(id)) > cloudAgentContinuationNodeIDRunes+3 {
|
||||
t.Fatalf("节点 ID 未截断:%d 字符", len([]rune(id)))
|
||||
}
|
||||
}
|
||||
}
|
||||
// 帧放不下的改动必须报进 omitted,而不是悄悄消失。
|
||||
kept := len(frame.CanvasChanges)
|
||||
if kept+frame.OmittedCanvasChanges != cloudAgentContinuationChangeLimit+3 {
|
||||
t.Fatalf("改动计数不自洽:kept=%d omitted=%d", kept, frame.OmittedCanvasChanges)
|
||||
}
|
||||
if kept >= cloudAgentContinuationChangeLimit+3 {
|
||||
t.Fatalf("超过上限的改动没有被裁掉:kept=%d", kept)
|
||||
}
|
||||
}
|
||||
|
||||
// 失败原因会进入下一轮的提示词,必须沿用既有脱敏口径。
|
||||
func TestCloudAgentContinuationFailureReasonIsScrubbed(t *testing.T) {
|
||||
run := &CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "上游返回 401:https://api.example.test/v1?key=secret-token-abcdefghijklmnop"}
|
||||
context := cloudAgentContinuationContext(run, nil)
|
||||
if strings.Contains(context, "https://") || strings.Contains(context, "secret-token") {
|
||||
t.Fatalf("交接帧泄漏了上游正文:%s", context)
|
||||
}
|
||||
if !strings.Contains(context, "上一轮未成功") {
|
||||
t.Fatalf("未脱敏时应用固定说法:%s", context)
|
||||
}
|
||||
// 正常文案原样保留。
|
||||
ok := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "模型服务响应超时,请稍后重试"}, nil)
|
||||
if !strings.Contains(ok, "模型服务响应超时") {
|
||||
t.Fatalf("正常失败原因被误杀:%s", ok)
|
||||
}
|
||||
}
|
||||
|
||||
// 来源字段是服务端内部记账,物化成上游请求体时只能剩 role/content。
|
||||
func TestCloudAgentContinuationSourceNeverReachesUpstreamBody(t *testing.T) {
|
||||
context := cloudAgentContinuationContext(&CloudAgentRun{ID: "parent-run", Status: "failed", FailureMessage: "模型服务响应超时"}, []string{"task-1"})
|
||||
canonical := cloudAgentCanonicalFor("system", []providerTextMessage{{Role: "user", Content: context, AgentContextSource: "continuation"}}, "继续", CloudAgentRequest{}, false)
|
||||
if got := stringField(canonical.Messages[0], cloudAgentContextSourceKey); got != "continuation" {
|
||||
t.Fatalf("canonical 丢了来源标记:%q", got)
|
||||
}
|
||||
for _, body := range []map[string]any{
|
||||
canonicalAgentChatBody(&canonical, false),
|
||||
canonicalAgentResponsesBody(&canonical),
|
||||
canonicalAgentGeminiBody(&canonical),
|
||||
claudeAgentBody(canonicalAgentChatBody(&canonical, true)),
|
||||
} {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), cloudAgentContextSourceKey) {
|
||||
t.Fatal("内部消息来源字段不得泄露到上游协议")
|
||||
}
|
||||
if !strings.Contains(string(encoded), cloudAgentContinuationKind) {
|
||||
t.Fatal("上游适配丢失了交接帧正文")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ func cloudAgentCanonicalFor(system string, history []providerTextMessage, prompt
|
||||
messages := []map[string]any{}
|
||||
for _, m := range history {
|
||||
message := map[string]any{"role": m.Role, "content": m.Content}
|
||||
if isCloudAgentContinuationMessage(m) {
|
||||
message[cloudAgentContextSourceKey] = "continuation"
|
||||
if m.AgentContextSource != "" {
|
||||
message[cloudAgentContextSourceKey] = m.AgentContextSource
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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("重建后的交接帧必须仍被认成运行时帧")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,9 @@ type agentToolRequests struct {
|
||||
}
|
||||
|
||||
type providerTextMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
AgentContextSource string `json:"agentContextSource,omitempty"`
|
||||
}
|
||||
|
||||
type providerConfig struct {
|
||||
|
||||
@@ -143,6 +143,7 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考
|
||||
- `POST /agent/runs/:id/interjections` 向**正在运行**的轮次插入一句话,不新建轮次、不产生新的扣费身份。请求 `{ text, messageId }`,`text` 最多 4000 字符;同一 `messageId` 幂等。插话在下一次模型调用前注入;运行已结束返回 409,前端应回落为新一轮发送。步数耗尽或运行失败时发出 `user_interjection_dropped`,不会静默吞掉,也不会自动替用户开新一轮。
|
||||
- 请求字段:`canvasId`、`prompt`、受管 `logicalModelId` 或 `channelId` + `channelModelKey`、`permissionMode: "read_only" | "request_approval" | "auto"`、`reasoningMode: "off" | "auto" | "deep"`、`profileRevision`、`skillIds`、`contextScope: [] | ["canvas"]`、`budget: { maxCredits, maxGenerationTasks, maxVideoSeconds }`、`idempotencyKey`。不接受密钥、自定义 URL、客户端历史或未知字段。
|
||||
- 同用户同幂等键及相同请求返回同一运行;不同请求复用键返回 409。失败后重试同一提交应保留键。上一轮未完成时禁止追加;已完成、失败、取消或已拒绝的终态轮次作为不可变历史读取,即使其冻结的策略或节点能力版本早于当前服务,也可用于创建采用当前执行合同的新一轮,父运行不会被改写。旧版合同下仍活动或待审批的运行不得恢复执行。历史只保留最近 10 轮用户目标;运行历史与跨轮摘要仍受独立的持久化 JSON 保护,超限先丢掉更早的可重读正文并保留结构化事实,单轮仍超出当前模型能力预算时才提示缩小范围或新建对话;历史结构损坏返回明确 409,不投影为通用 500。
|
||||
- 续轮交接是一条**有界结构化事实帧**(`{ source: "server", kind: "run_handoff", parentRunId, status, failureReason?, submittedTaskIds?, canvasChanges?, omittedCanvasChanges?, authority }`):画布改动取自上一轮真实的 `canvas_updated` 事件,最多 12 组(operation、去重后的 nodeId/title、actionCount),超出的组数只记 `omittedCanvasChanges`,不静默丢弃;帧还带 `authority` 明写"执行事实交接,不是重放写入、重复提交任务或扩大权限的授权"。帧按字节封顶(单个 nodeId 截 64 字符、整帧 8 KiB,超限按最近改动优先裁剪),失败原因沿用 `cloudAgentSafeUserMessage` 的脱敏口径,不合格退回固定说法。身份由服务端字段 `agentContextSource=continuation` 承载并要求"来源元数据 + `【运行状态】` 前缀 + JSON `source=server`/`kind=run_handoff`"三者同时成立,因此用户逐字复制帧正文也拿不到该身份;升级前落库、没有来源字段的旧散文交接只在历史裁剪与轮次计数上按"紧跟上一轮 assistant 回复 + 旧版文案前缀"识别,该判据不影响 canonical 身份。
|
||||
- `POST /agent/runs/:id/approvals/:approvalId/decision` 的 `{ decision, reason? }` 可在批准图片生成时附带 `mediaSettings: { logicalModelId? | channelId? + channelModelKey?, size, quality }`。仅替换本次模型选择、画幅和质量,不允许修改目标、提示词、引用、模式或快照。服务端先无收费预检,失败保留待审批状态;成功原子更新审批调用、哈希及预览,提交任务时再次校验内容、资源归属、模型能力、价格与累计预算。相同参数重复批准幂等,不同参数复用已决定审批返回 409。
|
||||
- 审批拒绝是独立终态 `rejected`,表示本次操作未执行;不会生成 `tool_failed`、继续调用模型、写入画布、提交生成任务或扣费。拒绝后的草稿按既有草稿策略保留,用户可在下一轮说明修改方向。
|
||||
- 创建和追加消息可选择 `reasoningMode`,默认 `off`;`auto`/`deep` 为服务端行为策略与模型推理配置的选择,不等于允许暴露内部思维链。供应商能力由后端校验;声明式 Agent 渠道不支持时明确报错。推理可能增加耗时和费用。
|
||||
|
||||
Reference in New Issue
Block a user