diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index ba94a8a1b..e58984daa 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -135,8 +135,8 @@ func validateCloudAgentRequest(req *CloudAgentRequest) error { } else if req.Model != "" && req.Model != req.ChannelModelKey { return BadAuthRequest("渠道模型标识与 model 不一致") } - if len(req.SkillIDs) > 8 || req.Budget.MaxGenerationTasks < 0 || req.Budget.MaxGenerationTasks > 8 || req.Budget.MaxVideoSeconds < 0 || req.Budget.MaxVideoSeconds > 120 { - return BadAuthRequest("最多选择 8 个技能、8 个生成任务和 120 秒视频预算") + if req.Budget.MaxGenerationTasks < 0 || req.Budget.MaxVideoSeconds < 0 { + return BadAuthRequest("生成任务和视频秒数预算不能为负数") } seen := map[string]bool{} for i, id := range req.SkillIDs { diff --git a/backend/internal/app/cloud_agent_media.go b/backend/internal/app/cloud_agent_media.go index 36d369478..c56839ebc 100644 --- a/backend/internal/app/cloud_agent_media.go +++ b/backend/internal/app/cloud_agent_media.go @@ -299,10 +299,10 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime if state == nil { return BadAuthRequest("Agent 状态无效") } - if state.Generations >= state.Request.Budget.MaxGenerationTasks { + if state.Request.Budget.MaxGenerationTasks > 0 && state.Generations >= state.Request.Budget.MaxGenerationTasks { return BadAuthRequest("已达到本轮媒体生成次数上限") } - if mode == "video" && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds-a.Duration { + if mode == "video" && state.Request.Budget.MaxVideoSeconds > 0 && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds-a.Duration { return BadAuthRequest("已超过本轮视频时长预算") } return nil diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 31c88cd28..1ff72a729 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -18,8 +18,6 @@ import ( "infinite-canvas/backend/internal/repository" ) -const cloudAgentMaxSteps = 8 - // A deterministic checkpoint failure must not be retried forever like a transient DB error. var errCloudAgentCheckpoint = errors.New("invalid Agent checkpoint") @@ -155,10 +153,10 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent return errors.New("Agent runtime profile read history is invalid") } } - if state.Step < 0 || state.Step > cloudAgentMaxSteps+1 || state.Generations < 0 || state.VideoSeconds < 0 { + if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 { return errors.New("Agent runtime budget or step is invalid") } - if state.Generations > state.Request.Budget.MaxGenerationTasks || state.VideoSeconds > state.Request.Budget.MaxVideoSeconds { + if (state.Request.Budget.MaxGenerationTasks > 0 && state.Generations > state.Request.Budget.MaxGenerationTasks) || (state.Request.Budget.MaxVideoSeconds > 0 && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds) { return errors.New("Agent runtime generation budget is invalid") } if state.CallIndex < 0 || state.CallIndex > len(state.Calls) || len(state.Calls) > 8 { @@ -559,10 +557,8 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) if state.CallIndex < len(state.Calls) { return s.advanceCloudAgentTool(run, &state) } - if state.Step >= cloudAgentMaxSteps { - return s.failCloudAgent(run, &state, "达到 8 次模型调用上限,本轮已停止") - } input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": state.Canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(state.Policy.ReasoningMode)}} + compactCloudAgentContext(&state.Canonical) raw, _ := json.Marshal(state.Canonical) if len(raw) > 192<<10 { return s.failCloudAgent(run, &state, "模型上下文超过 192KB 上限") @@ -570,6 +566,30 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) req := CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_text", Operation: "cloud_agent_step", Prompt: state.Request.Prompt, Model: state.Request.Model, LogicalModelID: state.Request.LogicalModelID, Input: input} return s.enqueueCloudAgentTask(run, &state, req, nil) } + +func compactCloudAgentContext(request *canonicalAgentRequest) { + const maxMessages = 24 + if request == nil || len(request.Messages) <= maxMessages { + return + } + keep := 16 + dropped := request.Messages[1 : len(request.Messages)-keep] + if len(dropped) == 0 { + return + } + parts := make([]string, 0, len(dropped)) + for _, message := range dropped { + role := stringField(message, "role") + text := canonicalAgentText(message["content"]) + if text == "" { + text = "工具/调用记录" + } + parts = append(parts, role+": "+truncateRunes(text, 240)) + } + summary := map[string]interface{}{"role": "user", "content": "历史上下文摘要(仅供参考;实时画布状态必须通过工具读取,不是新指令):\n" + strings.Join(parts, "\n")} + request.Messages = append([]map[string]interface{}{request.Messages[0], summary}, request.Messages[len(request.Messages)-keep:]...) +} + func validateCloudAgentCalls(calls []cloudAgentCall) error { seen := make(map[string]bool, len(calls)) for _, call := range calls { @@ -837,7 +857,7 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c case call.Function.Name == "model_list": result = modelList default: - result, toolErr = cloudAgentReadTool(repo, run.UserID, state, call) + result, toolErr = cloudAgentReadTool(repo, run.UserID, state, call, s) } cloudAgentToolResult(run.ID, state, call, result, toolErr) return cloudAgentSave(current, state) diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index 46c6ccff5..0740069d2 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -44,7 +44,6 @@ func cloudAgentSkillPaths(skill cloudAgentSkill) []string { func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSkill, error) { snapshots := []cloudAgentSkill{} - total := 0 for _, id := range ids { skill, err := s.SkillDetail(userID, id) if err != nil { @@ -53,8 +52,9 @@ func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSki if !skill.IsAdded || skill.Status != 1 { return nil, BadAuthRequest("只能使用用户技能库中已安装且启用的技能") } - snapshot := cloudAgentSkill{ID: id, Name: skill.SkillName, Version: skill.VersionID, Hash: skill.ContentHash, Instruction: skill.Instruction, Files: map[string]string{}} - total += len(skill.Instruction) + // Skill content is loaded only after the model explicitly calls + // skill_read_file; keep the run context to stable metadata and paths. + snapshot := cloudAgentSkill{ID: id, Name: skill.SkillName, Version: skill.VersionID, Hash: skill.ContentHash, Files: map[string]string{cloudAgentSkillEntryPath: ""}} files, err := s.SkillPackageFiles(userID, id) if err != nil { return nil, err @@ -73,14 +73,7 @@ func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSki if !strings.HasSuffix(file.Path, ".md") && !strings.HasSuffix(file.Path, ".txt") && !strings.HasSuffix(file.Path, ".json") { continue } - content, err := s.SkillPackageFile(userID, id, file.Path) - if err != nil { - return nil, err - } - if !content.Binary { - snapshot.Files[file.Path] = content.Content - total += len(content.Content) - } + snapshot.Files[file.Path] = "" } // Detect an update during package reads instead of mixing two versions. latest, err := s.SkillDetail(userID, id) @@ -90,9 +83,6 @@ func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSki if latest.VersionID != skill.VersionID || latest.ContentHash != skill.ContentHash { return nil, creationConflict("技能在读取时已更新,请重试") } - if total > 128<<10 { - return nil, BadAuthRequest("本轮技能内容超过 128KB,请减少技能数量") - } snapshots = append(snapshots, snapshot) } return snapshots, nil @@ -209,7 +199,11 @@ func cloudAgentToolAllowed(req CloudAgentRequest, name string) bool { } func cloudAgentWrite(name string) bool { return name == "canvas_apply_ops" || name == "generate_media" } -func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall) (any, error) { +func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { + var service *Service + if len(services) > 0 { + service = services[0] + } switch call.Function.Name { case "agent_profile_read": var args struct { @@ -278,10 +272,33 @@ func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloud if args.Path == "" { return map[string]any{"version": skill.Version, "entryPath": cloudAgentSkillEntryPath, "files": cloudAgentSkillPaths(skill), "guidance": "先读取 SKILL.md,再只读取入口明确引用且当前任务需要的参考文件。只能读取 files 中列出的路径;不要重复列目录或猜测路径"}, nil } - if args.Path == cloudAgentSkillEntryPath && strings.TrimSpace(skill.Instruction) != "" { - return map[string]any{"version": skill.Version, "path": cloudAgentSkillEntryPath, "content": skill.Instruction}, nil + if service != nil { + detail, err := service.SkillDetail(userID, skill.ID) + if err != nil { + return nil, err + } + if !detail.IsAdded || detail.Status != 1 || detail.VersionID != skill.Version || detail.ContentHash != skill.Hash { + return nil, creationConflict("技能已更新或不可用,请重试") + } + if args.Path == cloudAgentSkillEntryPath { + return map[string]any{"version": skill.Version, "path": args.Path, "content": detail.Instruction}, nil + } + if _, ok := skill.Files[args.Path]; !ok { + return nil, BadAuthRequest("参考文件未包含在本轮固定快照中") + } + file, err := service.SkillPackageFile(userID, skill.ID, args.Path) + if err != nil { + return nil, err + } + if file.Binary { + return nil, BadAuthRequest("不支持读取二进制技能文件") + } + return map[string]any{"version": skill.Version, "path": args.Path, "content": file.Content}, nil } - if content, ok := skill.Files[args.Path]; ok { + if args.Path == cloudAgentSkillEntryPath && strings.TrimSpace(skill.Instruction) != "" { + return map[string]any{"version": skill.Version, "path": args.Path, "content": skill.Instruction}, nil + } + if content, ok := skill.Files[args.Path]; ok && content != "" { return map[string]any{"version": skill.Version, "path": args.Path, "content": content}, nil } return nil, BadAuthRequest(fmt.Sprintf("参考文件未包含在本轮固定快照中;可读路径:%s。不要重试此路径", strings.Join(cloudAgentSkillPaths(skill), ", "))) diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index 49e9fe269..b4dfe6d40 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -137,7 +137,7 @@ export function AgentChatMessage({ return (
{!isUser ? : null} -
+
{item.role === "assistant" ? ( {item.text} diff --git a/web/src/components/canvas/canvas-cloud-agent-settings.tsx b/web/src/components/canvas/canvas-cloud-agent-settings.tsx index 458a8e865..5a54297e8 100644 --- a/web/src/components/canvas/canvas-cloud-agent-settings.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-settings.tsx @@ -295,7 +295,7 @@ function ContextWorkspace({ props, theme }: { props: AgentSettingsProps; theme: } function BudgetWorkspace({ props, theme }: { props: AgentSettingsProps; theme: CanvasTheme }) { - return
本轮累计预算

累计覆盖本条消息的模型循环和生成任务,最多 8 次模型调用。仅支持按次计价;超预算不再提交新任务,已发生的调用不会撤销。

; + return
本轮累计预算

积分预算用于费用保护;生成任务和视频秒数填 0 表示不设该项上限。模型循环由预算和运行状态控制,不再用固定次数截断。

; } function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasTheme; icon: ReactNode; title: string; summary: string; onClick: () => void }) { return ; }