fix(agent): 优化预算与技能按需加载

This commit is contained in:
ddCat
2026-09-15 07:04:08 +08:00
parent 201f3aac65
commit a840e552d5
6 changed files with 69 additions and 32 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)

View File

@@ -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), ", ")))

View File

@@ -137,7 +137,7 @@ export function AgentChatMessage({
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`min-w-0 text-sm leading-6 ${isUser ? "max-w-[82%] rounded-2xl rounded-br-md px-3.5 py-2.5 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color, ...(isUser ? { background: theme.node.fill } : {}) }}>
<div className={`min-w-0 text-sm leading-6 ${isUser ? "max-w-[82%] rounded-2xl rounded-br-md px-3.5 py-2.5 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color, ...(isUser ? { background: "#edf6ff" } : {}) }}>
{item.role === "assistant" ? (
<AIMessageMarkdown className="text-left" isStreaming={isStreaming}>
{item.text}

View File

@@ -295,7 +295,7 @@ function ContextWorkspace({ props, theme }: { props: AgentSettingsProps; theme:
}
function BudgetWorkspace({ props, theme }: { props: AgentSettingsProps; theme: CanvasTheme }) {
return <div className="thin-scrollbar min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5"><div className="rounded-xl p-4" style={{ background: theme.node.fill }}><div className="text-sm font-semibold">本轮累计预算</div><p className="mt-2 text-xs opacity-60">累计覆盖本条消息的模型循环和生成任务,最多 8 次模型调用。仅支持按次计价;超预算不再提交新任务,已发生的调用不会撤销。</p></div><BudgetInput label="积分上限" hint="必填且大于 0;所有步骤累计" value={props.maxCredits} onChange={props.onMaxCreditsChange} theme={theme} /><BudgetInput label="生成任务上限" hint="0–8;只读模式不执行生成" value={props.maxGenerationTasks} onChange={props.onMaxGenerationTasksChange} theme={theme} /><BudgetInput label="视频秒数上限" hint="0–120;按请求时长累计" value={props.maxVideoSeconds} onChange={props.onMaxVideoSecondsChange} theme={theme} /></div>;
return <div className="thin-scrollbar min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-5"><div className="rounded-xl p-4" style={{ background: theme.node.fill }}><div className="text-sm font-semibold">本轮累计预算</div><p className="mt-2 text-xs opacity-60">积分预算用于费用保护;生成任务和视频秒数填 0 表示不设该项上限。模型循环由预算和运行状态控制,不再用固定次数截断。</p></div><BudgetInput label="积分上限" hint="必填且大于 0;所有步骤累计" value={props.maxCredits} onChange={props.onMaxCreditsChange} theme={theme} /><BudgetInput label="生成任务上限" hint="0 表示不限;只读模式不执行生成" value={props.maxGenerationTasks} onChange={props.onMaxGenerationTasksChange} theme={theme} /><BudgetInput label="视频秒数上限" hint="0 表示不限;按请求时长累计" value={props.maxVideoSeconds} onChange={props.onMaxVideoSecondsChange} theme={theme} /></div>;
}
function SettingRow({ theme, icon, title, summary, onClick }: { theme: CanvasTheme; icon: ReactNode; title: string; summary: string; onClick: () => void }) { return <button type="button" className="canvas-agent-setting-row flex w-full min-w-0 items-center gap-3 rounded-xl p-3 text-left transition-colors focus-visible:outline focus-visible:outline-2" onClick={onClick}><span className="grid size-9 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>{icon}</span><span className="min-w-0 flex-1"><span className="block text-[13px] font-medium">{title}</span><span className="mt-1 block text-xs leading-5" style={{ color: theme.node.muted }}>{summary}</span></span><ChevronRight className="size-4 shrink-0" style={{ color: theme.node.muted }} /></button>; }