Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_plan.go
T
ddCatandCursor d9abe41ceb feat(agent): Agent 工作台 - 计划插话记忆、统一模型选择器并修复摘要与视频入库
将计划清单、运行中插话和个人记忆接入云端 Agent;全站模型选择器统一为左右二级菜单。画布摘要按预算截断不再整轮拒绝,首页视频未知宽高可入库。README 补充贡献者 morrow。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 20:21:25 +08:00

247 lines
8.4 KiB
Go

package app
import (
"fmt"
"strings"
)
type cloudAgentPlanItem struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
func cloudAgentPendingPlanItems(plan []cloudAgentPlanItem) []string {
pending := make([]string, 0, len(plan))
for _, item := range plan {
if item.Status != "done" {
pending = append(pending, item.Title)
}
}
return pending
}
func cloudAgentPlanBlock(plan []cloudAgentPlanItem) string {
if len(plan) == 0 {
return ""
}
var b strings.Builder
b.WriteString(cloudAgentPlanBlockMarker)
b.WriteString("当前用户消息是本轮目标。清单只辅助推进仍覆盖在该目标下的步骤;用户已改口时不要回头确认上一轮有没有做完。每完成一项就调用 plan_update 更新它的 status。\n\n")
for _, item := range plan {
mark := "未开始"
switch item.Status {
case "doing":
mark = "进行中"
case "done":
mark = "已完成"
}
b.WriteString(fmt.Sprintf("- [%s] %s. %s\n", mark, item.ID, item.Title))
}
return b.String()
}
const cloudAgentPlanBlockMarker = "\n\n## 本轮待办清单\n\n"
const cloudAgentRuntimeContextMarker = "【运行状态】"
func stripCloudAgentPlanBlock(system string) string {
if i := strings.Index(system, cloudAgentPlanBlockMarker); i >= 0 {
return system[:i]
}
return system
}
func isCloudAgentRuntimeContextMessage(message map[string]any) bool {
if stringField(message, "role") != "user" {
return false
}
return strings.HasPrefix(strings.TrimSpace(stringField(message, "content")), cloudAgentRuntimeContextMarker)
}
func stripCloudAgentRuntimeContext(messages []map[string]any) []map[string]any {
if len(messages) == 0 || !isCloudAgentRuntimeContextMessage(messages[len(messages)-1]) {
return messages
}
return messages[:len(messages)-1]
}
func attachCloudAgentPlan(canonical *canonicalAgentRequest, plan []cloudAgentPlanItem) {
if canonical == nil {
return
}
canonical.SystemPrompt = stripCloudAgentPlanBlock(canonical.SystemPrompt)
canonical.Messages = stripCloudAgentRuntimeContext(canonical.Messages)
block := cloudAgentPlanBlock(plan)
if block == "" {
return
}
canonical.Messages = append(canonical.Messages, map[string]any{
"role": "user",
"content": cloudAgentRuntimeContextMarker + "这不是新的用户指令,只是当前待办状态。当前用户消息仍是本轮目标。\n" + strings.TrimSpace(block),
})
}
func cloudAgentPlanRequiresFirstApproval(state *cloudAgentRuntime, call cloudAgentCall) bool {
if state == nil || state.Approval != nil || len(state.Plan) != 0 || state.Request.PermissionMode != "request_approval" {
return false
}
_, ok := cloudAgentPlanApprovalPreview(call)
return ok
}
func cloudAgentLastMessageIsUserInstruction(messages []map[string]any) bool {
if len(messages) == 0 {
return false
}
last := messages[len(messages)-1]
if stringField(last, "role") != "user" {
return false
}
content := strings.TrimSpace(stringField(last, "content"))
if content == "" {
return false
}
if strings.HasPrefix(content, cloudAgentRuntimeContextMarker) {
return false
}
if strings.HasPrefix(content, "【用户插话】") {
return true
}
if strings.Contains(content, "待办清单") || strings.Contains(content, "现在就调用工具") {
return false
}
return true
}
func cloudAgentUserFirstPrefix(state *cloudAgentRuntime) string {
if state == nil || !cloudAgentLastMessageIsUserInstruction(state.Canonical.Messages) {
return ""
}
last := state.Canonical.Messages[len(state.Canonical.Messages)-1]
userText := truncateRunes(strings.TrimSpace(stringField(last, "content")), 200)
return "用户刚给了新要求:「" + userText + "」。**先按用户的最新要求来做**,做完再继续下面这件事:"
}
func cloudAgentPlanNudgeContent(state *cloudAgentRuntime, pendingTitle string) string {
return cloudAgentUserFirstPrefix(state) + "待办清单里「" + pendingTitle + "」还没完成,这一轮不能算结束。请按这个循环推进:**执行 → 检查结果 → 用 plan_update 更新该项状态 → 做下一项**。现在就调用工具继续。"
}
func cloudAgentPlanRequiredNudgeContent(state *cloudAgentRuntime) string {
return cloudAgentUserFirstPrefix(state) + "你还没有把这次要做的事列成待办清单,服务端无法判断是否做完。请先用 plan_update 把剩余工作一次列全(一项一个可验收的产出,例如「生成镜头2视频」),再按清单逐项推进:每完成一项就更新该项状态,然后做下一项。现在就调用工具。"
}
func cloudAgentPlanApprovalPreview(call cloudAgentCall) (cloudAgentApprovalPreview, bool) {
var args struct {
Items []cloudAgentPlanItem `json:"items"`
}
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return cloudAgentApprovalPreview{}, false
}
items := make([]cloudAgentApprovalPreviewItem, 0, len(args.Items))
for _, entry := range args.Items {
title := strings.TrimSpace(entry.Title)
if title == "" {
continue
}
items = append(items, cloudAgentApprovalPreviewItem{Operation: "plan_step", Summary: truncateRunes(title, 240)})
}
if len(items) < 2 {
return cloudAgentApprovalPreview{}, false
}
return cloudAgentApprovalPreview{
Kind: "plan",
Title: "确认执行计划",
Description: fmt.Sprintf("Agent 把这轮任务拆成 %d 步。确认后才会开始执行;暂不执行则会让它改用别的做法。", len(items)),
Items: items,
}, true
}
func cloudAgentApplyPlanUpdate(state *cloudAgentRuntime, call cloudAgentCall) (any, error) {
var args struct {
Items []cloudAgentPlanItem `json:"items"`
}
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, BadAuthRequest("工具参数必须是只含支持字段的JSON对象")
}
if len(args.Items) > 20 {
return nil, BadAuthRequest("待办清单最多 20 项")
}
seen := map[string]bool{}
for i := range args.Items {
args.Items[i].ID = strings.TrimSpace(args.Items[i].ID)
args.Items[i].Title = strings.TrimSpace(args.Items[i].Title)
if args.Items[i].ID == "" || args.Items[i].Title == "" {
return nil, BadAuthRequest("待办的 id 和 title 不能为空")
}
if seen[args.Items[i].ID] {
return nil, BadAuthRequest("待办 id 重复:" + args.Items[i].ID)
}
seen[args.Items[i].ID] = true
switch args.Items[i].Status {
case "pending", "doing", "done":
default:
args.Items[i].Status = "pending"
}
}
state.Plan = args.Items
return map[string]any{"items": args.Items, "pendingTitles": cloudAgentPendingPlanItems(args.Items)}, nil
}
func cloudAgentAskUser(call cloudAgentCall) (any, error) {
var args struct {
Question string `json:"question"`
Options []struct {
Label string `json:"label"`
Detail string `json:"detail"`
} `json:"options"`
AllowFreeform *bool `json:"allowFreeform"`
}
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, BadAuthRequest("工具参数必须是只含支持字段的JSON对象")
}
question := strings.TrimSpace(args.Question)
if question == "" {
return nil, BadAuthRequest("ask_user 必须给出 question:把要用户拍板的那一个问题一句话写清")
}
options := make([]map[string]any, 0, len(args.Options))
for _, option := range args.Options {
label := strings.TrimSpace(option.Label)
if label == "" {
continue
}
entry := map[string]any{"label": truncateRunes(label, 120)}
if detail := strings.TrimSpace(option.Detail); detail != "" {
entry["detail"] = truncateRunes(detail, 240)
}
options = append(options, entry)
}
if len(options) < 2 {
return nil, BadAuthRequest("ask_user 至少要给 2 个候选项;若你自己能定,直接做完继续,不要问")
}
if len(options) > 6 {
options = options[:6]
}
allowFreeform := true
if args.AllowFreeform != nil {
allowFreeform = *args.AllowFreeform
}
return map[string]any{
"phase": "question",
"question": truncateRunes(question, 400),
"options": options,
"allowFreeform": allowFreeform,
}, nil
}
func skipRemainingCloudAgentCalls(runID string, state *cloudAgentRuntime) {
for index := state.CallIndex + 1; index < len(state.Calls); index++ {
cloudAgentToolResult(runID, state, state.Calls[index], map[string]any{"skipped": true}, BadAuthRequest("本轮已结束(等待用户决定),该调用未执行"))
}
}
func cloudAgentCanonicalWithPlan(state *cloudAgentRuntime) canonicalAgentRequest {
canonical := state.Canonical
attachCloudAgentPlan(&canonical, state.Plan)
return canonical
}