fix(agent): 优化预算与技能按需加载
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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), ", ")))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>; }
|
||||
|
||||
Reference in New Issue
Block a user