Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_plan.go
T

338 lines
12 KiB
Go

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
}