Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_plan.go
2026-09-27 18:31:37 +08:00

338 lines
12 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 (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"sort"
"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
}
const cloudAgentPlanBlockMarker = "\n\n## 本轮待办清单\n\n"
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 stringField(message, cloudAgentContextSourceKey) == "runtime"
}
func stripCloudAgentRuntimeContext(messages []map[string]any) []map[string]any {
if len(messages) == 0 || !isCloudAgentRuntimeContextMessage(messages[len(messages)-1]) {
return messages
}
var context cloudAgentRuntimeContext
content := strings.TrimPrefix(stringField(messages[len(messages)-1], "content"), cloudAgentRuntimeContextMarker)
if json.Unmarshal([]byte(content), &context) != nil || context.Kind != cloudAgentContextPlan {
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)
if len(plan) == 0 {
return
}
canonical.Messages = append(slices.Clone(canonical.Messages), cloudAgentRuntimeMessage(cloudAgentRuntimeContext{Kind: cloudAgentContextPlan, Items: plan}))
}
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 cloudAgentPlanNudgeMessage(state *cloudAgentRuntime, pendingTitle string) map[string]any {
return cloudAgentRuntimeMessage(cloudAgentRuntimeContext{
Kind: cloudAgentContextPendingPlan, PendingTitle: pendingTitle,
LatestUserMessage: cloudAgentLatestUserInstruction(state.Canonical.Messages),
})
}
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"
}
}
if !slices.Equal(state.Plan, args.Items) {
state.ActionNudged = false
}
state.Plan = args.Items
return map[string]any{"items": args.Items, "pendingTitles": cloudAgentPendingPlanItems(args.Items)}, nil
}
func cloudAgentConfirmationPointFingerprint(_ string, options []map[string]any, fields []map[string]any) string {
// Options and form field definitions are the durable identity of a decision point.
// Question wording is intentionally ignored so harmless paraphrases do not consume
// another confirmation round.
parts := make([]string, 0, len(options)+len(fields))
for _, option := range options {
label := strings.ToLower(strings.Join(strings.Fields(stringValue(option["label"])), " "))
detail := strings.ToLower(strings.Join(strings.Fields(stringValue(option["detail"])), " "))
parts = append(parts, "option\x00"+label+"\x00"+detail)
}
for _, field := range fields {
id := strings.ToLower(strings.Join(strings.Fields(stringValue(field["id"])), " "))
typ := strings.ToLower(strings.Join(strings.Fields(stringValue(field["type"])), " "))
parts = append(parts, "field\x00"+id+"\x00"+typ+"\x00"+stringValue(field["defaultValue"]))
}
sort.Strings(parts)
canonical := strings.Join(parts, "\x00")
sum := sha256.Sum256([]byte(canonical))
return hex.EncodeToString(sum[:])
}
func cloudAgentHasConfirmationFingerprint(state *cloudAgentRuntime, fingerprint string) bool {
if state == nil || fingerprint == "" {
return false
}
for _, existing := range state.ConfirmationFingerprints {
if existing == fingerprint {
return true
}
}
return false
}
func cloudAgentAskUser(call cloudAgentCall, states ...*cloudAgentRuntime) (any, error) {
var state *cloudAgentRuntime
if len(states) > 0 {
state = states[0]
}
var args struct {
Question string `json:"question"`
QuestionID string `json:"questionId"`
Options []struct {
Label string `json:"label"`
Detail string `json:"detail"`
} `json:"options"`
Fields []struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Options []struct {
ID string `json:"id"`
Label string `json:"label"`
Detail string `json:"detail"`
Recommended bool `json:"recommended"`
} `json:"options"`
DefaultValue string `json:"defaultValue"`
Required bool `json:"required"`
AllowCustom bool `json:"allowCustom"`
Placeholder string `json:"placeholder"`
} `json:"fields"`
AllowFreeform *bool `json:"allowFreeform"`
Round int `json:"round"`
MaxRounds int `json:"maxRounds"`
}
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)
}
fields := make([]map[string]any, 0, len(args.Fields))
validTypes := map[string]bool{"single_select": true, "segmented": true, "text": true, "textarea": true, "model_picker": true}
for _, field := range args.Fields {
id := strings.TrimSpace(field.ID)
title := strings.TrimSpace(field.Title)
if id == "" || title == "" || !validTypes[field.Type] {
continue
}
entry := map[string]any{"id": truncateRunes(id, 80), "title": truncateRunes(title, 80), "type": field.Type, "required": field.Required, "allowCustom": field.AllowCustom}
if value := strings.TrimSpace(field.DefaultValue); value != "" {
entry["defaultValue"] = truncateRunes(value, 200)
}
if placeholder := strings.TrimSpace(field.Placeholder); placeholder != "" {
entry["placeholder"] = truncateRunes(placeholder, 200)
}
fieldOptions := make([]map[string]any, 0, len(field.Options))
for _, option := range field.Options {
label := strings.TrimSpace(option.Label)
if label == "" {
continue
}
optionEntry := map[string]any{"label": truncateRunes(label, 100), "recommended": option.Recommended}
if id := strings.TrimSpace(option.ID); id != "" {
optionEntry["id"] = truncateRunes(id, 80)
}
if detail := strings.TrimSpace(option.Detail); detail != "" {
optionEntry["detail"] = truncateRunes(detail, 180)
}
fieldOptions = append(fieldOptions, optionEntry)
}
if len(fieldOptions) > 8 {
fieldOptions = fieldOptions[:8]
}
if len(fieldOptions) > 0 {
entry["options"] = fieldOptions
}
fields = append(fields, entry)
}
if len(fields) > 6 {
fields = fields[:6]
}
if len(fields) > 0 && len(options) > 0 {
return nil, BadAuthRequest("ask_user 的 options 与 fields 只能二选一;多个参数请只使用 fields")
}
if len(fields) == 0 && len(options) < 2 {
return nil, BadAuthRequest("ask_user 必须提供动态表单字段,或至少 2 个候选项;若你自己能定,直接做完继续,不要问")
}
allowFreeform := true
if args.AllowFreeform != nil {
allowFreeform = *args.AllowFreeform
}
maxRounds := cloudAgentMaxConfirmationRounds
currentRound := 1
fingerprint := cloudAgentConfirmationPointFingerprint(question, options, fields)
duplicate := false
if state != nil {
currentRound = state.ConfirmationRounds + 1
duplicate = cloudAgentHasConfirmationFingerprint(state, fingerprint)
}
payload := map[string]any{
"phase": "question",
"kind": "form",
"question": truncateRunes(question, 400),
"options": options,
"fields": fields,
"questionId": truncateRunes(strings.TrimSpace(args.QuestionID), 100),
"allowFreeform": allowFreeform,
"round": currentRound,
"maxRounds": maxRounds,
}
if len(fields) == 0 {
payload["kind"] = "choice"
delete(payload, "fields")
}
if duplicate || currentRound > maxRounds {
payload["phase"] = "defaulted"
payload["defaulted"] = true
payload["reason"] = "repeated_confirmation_point"
if currentRound > maxRounds {
payload["reason"] = "confirmation_round_limit"
}
payload["text"] = "确认次数已达到上限或确认点已重复。请使用安全默认方案继续,不要再次询问;最终回复中列出采用的默认假设。"
if state != nil {
state.PendingConfirmationFingerprint = ""
}
return payload, nil
}
if state != nil {
state.ConfirmationRounds = currentRound
state.ConfirmationFingerprints = append(state.ConfirmationFingerprints, fingerprint)
state.PendingConfirmationFingerprint = fingerprint
}
return payload, nil
}
// skipRemainingCloudAgentCalls 结束本批剩余调用(ask_user 之后本轮不再继续执行)。
// 末尾的 flush 让"本批前面的看图结果"仍能落在全部 tool 结果之后:这批调用到这里已经
// 完整(每个声明的 tool_call_id 都有回执),但本轮就此结束、不会再走 advanceCloudAgent
// 的兜底 flush,少了这一步缓冲的图片会被丢掉。
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("本轮已结束(等待用户决定),该调用未执行"))
}
cloudAgentFlushPendingImages(state)
}
func cloudAgentCanonicalWithPlan(state *cloudAgentRuntime) canonicalAgentRequest {
canonical := state.Canonical
attachCloudAgentPlan(&canonical, state.Plan)
return canonical
}