diff --git a/backend/internal/app/cloud_agent_invalid_arguments_test.go b/backend/internal/app/cloud_agent_invalid_arguments_test.go new file mode 100644 index 000000000..99b742dd3 --- /dev/null +++ b/backend/internal/app/cloud_agent_invalid_arguments_test.go @@ -0,0 +1,248 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// 回归(线上 run ag3580aca,2026-10-07):用户发整份剧本,模型把大纲塞进 canvas_apply_ops +// 参数超过 32000 字节,validateCloudAgentCalls 把整批调用判成致命协议错误,run 以 +// 「Agent 执行中断」失败。参数超限是模型输出问题,应走纠偏:无效调用回结构化错误让模型 +// 拆分重试,有效调用继续执行;连续多批无效才硬失败。 +func oversizedCall(id, name string) cloudAgentCall { + args, _ := json.Marshal(map[string]any{"ops": []any{map[string]any{"type": "add_node", "id": "big", "content": strings.Repeat("章节大纲内容", 4000)}}}) + if len(args) <= cloudAgentToolArgumentsByteLimit { + panic("测试前提不成立:参数应超过字节上限") + } + return cloudAgentCall{ID: id, Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: name, Arguments: string(args)}} +} + +func validCall(id, name string) cloudAgentCall { + return cloudAgentCall{ID: id, Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: name, Arguments: `{"nodeId":"n1"}`}} +} + +func TestCloudAgentBatchInvalidCalls(t *testing.T) { + big := oversizedCall("call_big", "canvas_apply_ops") + ok := validCall("call_ok", "canvas_get_state") + + invalid, structural := cloudAgentBatchInvalidCalls([]cloudAgentCall{big, ok}) + if len(invalid) != 1 { + t.Fatalf("应只有超限调用无效:got %v", invalid) + } + if structural { + t.Fatal("参数超限不是结构类问题,不应整步拒绝") + } + if !strings.Contains(invalid[0], "32000") { + t.Fatalf("超限原因应说明字节上限:%s", invalid[0]) + } + + // 非法 JSON、ID 重复各自可判;重复 ID 的第二个调用即使参数有效也不放行, + // 且重复 ID 属结构类问题——按 ID 配对纠偏有歧义,必须整步拒绝。 + broken := cloudAgentCall{ID: "call_broken", Function: struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + }{Name: "canvas_apply_ops", Arguments: `{"ops":`}} + dup := validCall("call_ok", "canvas_get_state") + invalid, structural = cloudAgentBatchInvalidCalls([]cloudAgentCall{broken, ok, dup}) + if len(invalid) != 2 { + t.Fatalf("非法 JSON 与重复 ID 都应判无效:got %v", invalid) + } + if !structural { + t.Fatal("重复 ID 应标记为结构类问题") + } + if _, argumentProblem := cloudAgentInvalidCallReason(broken, map[string]bool{}); !argumentProblem { + t.Fatal("非法 JSON 应归类为参数问题") + } +} + +// 回归(评审):Pi 恢复后重放超限调用时,/tool 桥接的参数校验发生在回执回放之前, +// 模型只能拿到一句泛化的 invalid Agent tool arguments,拿不到纠偏提交预写的 +// 「拆分重试」结构化回执——Pi 会话与服务端 canonical 各记一份不同结果,下一轮模型 +// 请求按历史长度二选一。校验失败时必须先回放已保存回执,两边保持一致。 +func TestCloudAgentPiToolReplaysSavedReceiptForInvalidArguments(t *testing.T) { + s, db, _ := agentMediaFixture(t) + if sqlDB, err := db.DB(); err == nil { + t.Cleanup(func() { _ = sqlDB.Close() }) + } + req := agentTestRequest() + root, err := s.CreateCloudAgentRun("user", req, "") + if err != nil { + t.Fatal(err) + } + run, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + big := oversizedCall("call_big", "canvas_apply_ops") + receipt := `{"error":"该工具调用未通过参数校验:参数超过 32000 字节上限。","errorClass":"invalid_model_output","requiredAction":"fix_arguments"}` + state.Canonical.Messages = append(state.Canonical.Messages, + map[string]any{"role": "assistant", "content": "落大纲", "tool_calls": []cloudAgentCall{big}}, + map[string]any{"role": "tool", "tool_call_id": "call_big", "content": receipt}, + ) + if err = s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + return cloudAgentSave(current, &state) + }); err != nil { + t.Fatal(err) + } + + payload := map[string]json.RawMessage{ + "callId": json.RawMessage(`"call_big"`), + "name": json.RawMessage(`"canvas_apply_ops"`), + "arguments": json.RawMessage(big.Function.Arguments), + } + got, toolErr := s.cloudAgentPiTool(t.Context(), "user", run.ID, payload) + if toolErr != nil { + t.Fatalf("已保存回执的无效调用应回放回执而不是报校验错误:%v", toolErr) + } + result, _ := got.(map[string]any) + if result == nil { + t.Fatalf("应返回工具结果形态:%v", got) + } + if content, _ := result["content"].(string); !strings.Contains(content, "32000") || !strings.Contains(content, "fix_arguments") { + t.Fatalf("回放结果应是预写的结构化回执:%v", content) + } +} + +func TestFinishCloudAgentPiModelStepWithInvalidCalls(t *testing.T) { + s, db, _ := agentMediaFixture(t) + if sqlDB, err := db.DB(); err == nil { + t.Cleanup(func() { _ = sqlDB.Close() }) + } + req := agentTestRequest() + root, err := s.CreateCloudAgentRun("user", req, "") + if err != nil { + t.Fatal(err) + } + run, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + state.ActiveTaskID = "model-task-1" + state.TaskIDs = append(state.TaskIDs, "model-task-1") + if err = s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + return cloudAgentSave(current, &state) + }); err != nil { + t.Fatal(err) + } + + big := oversizedCall("call_big", "canvas_apply_ops") + ok := validCall("call_ok", "canvas_get_state") + invalid, structural := cloudAgentBatchInvalidCalls([]cloudAgentCall{big, ok}) + if structural { + t.Fatal("测试前提不成立:超限应属参数类问题") + } + if len(invalid) != 1 { + t.Fatalf("测试前提不成立:应恰好一个无效调用,got %v", invalid) + } + + if err := s.finishCloudAgentPiModelStepWithInvalidCalls("user", run.ID, "model-task-1", "我先落大纲", "推理文本", []cloudAgentCall{big, ok}, invalid); err != nil { + t.Fatal(err) + } + + latest, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + t.Fatal(err) + } + if fresh.ActiveTaskID != "" { + t.Fatal("纠偏提交后应释放活动任务") + } + if fresh.InvalidArgumentSteps != 1 { + t.Fatalf("参数纠偏计数应为 1:got %d", fresh.InvalidArgumentSteps) + } + if len(fresh.Calls) != 1 || fresh.Calls[0].ID != "call_ok" { + t.Fatalf("执行批次应只含有效调用:%+v", fresh.Calls) + } + if fresh.CallIndex != 0 { + t.Fatal("执行游标应回到 0") + } + + // canonical:assistant 保留全部调用,无效调用紧跟结构化错误结果(配对完整)。 + assistantCalls := 0 + receipt := "" + for _, message := range fresh.Canonical.Messages { + if stringField(message, "role") == "assistant" { + if calls, ok := message["tool_calls"].([]any); ok { + assistantCalls = len(calls) + } + } + if stringField(message, "role") == "tool" && stringField(message, "tool_call_id") == "call_big" { + receipt = stringField(message, "content") + } + } + if assistantCalls != 2 { + t.Fatalf("assistant 应保留全部 2 个调用:got %d", assistantCalls) + } + if receipt == "" { + t.Fatal("无效调用必须有错误回执") + } + var decoded map[string]any + if err := json.Unmarshal([]byte(receipt), &decoded); err != nil { + t.Fatalf("回执应是结构化 JSON:%v", err) + } + if decoded["errorClass"] != cloudAgentToolErrorInvalidModelOutput || decoded["requiredAction"] != "fix_arguments" { + t.Fatalf("回执应按模型输出问题分类并指引修正参数:%v", decoded) + } + if !strings.Contains(stringField(decoded, "error"), "拆分") { + t.Fatalf("超限回执应指引拆分重试:%v", decoded["error"]) + } + + // 事件可诊断:拒绝的工具体与尝试次数入日志(事件 JSON 顶层带 type 字段)。 + rejected := false + for _, event := range latest.Journal { + var payload map[string]any + if json.Unmarshal([]byte(event.EventJSON), &payload) == nil && payload["type"] == "tool_arguments_rejected" { + rejected = true + } + } + if !rejected { + t.Fatal("缺少 tool_arguments_rejected 事件") + } + + // 成功批次清零计数:普通提交一批全部有效的调用。 + state, err = cloudAgentDecode(latest) + if err != nil { + t.Fatal(err) + } + state.ActiveTaskID = "model-task-2" + state.TaskIDs = append(state.TaskIDs, "model-task-2") + if err = s.repo.MutateCloudAgent("user", run.ID, latest.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + return cloudAgentSave(current, &state) + }); err != nil { + t.Fatal(err) + } + next := validCall("call_next", "canvas_get_state") + if err := s.finishCloudAgentPiModelStep("user", run.ID, "model-task-2", "", "", []cloudAgentCall{next}); err != nil { + t.Fatal(err) + } + if latest, err = s.repo.CloudAgent("user", run.ID); err != nil { + t.Fatal(err) + } + if fresh, err = cloudAgentDecode(latest); err != nil { + t.Fatal(err) + } + if fresh.InvalidArgumentSteps != 0 { + t.Fatalf("全部有效的批次应清零纠偏计数:got %d", fresh.InvalidArgumentSteps) + } +} diff --git a/backend/internal/app/cloud_agent_pi_bridge.go b/backend/internal/app/cloud_agent_pi_bridge.go index b1234efc9..18e9d026f 100644 --- a/backend/internal/app/cloud_agent_pi_bridge.go +++ b/backend/internal/app/cloud_agent_pi_bridge.go @@ -329,6 +329,18 @@ func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, pa return nil, fmt.Errorf("tool call is missing a name") } if err := validateCloudAgentCalls([]cloudAgentCall{call}); err != nil { + // 参数校验失败的调用可能已由纠偏提交预写了结构化回执(见 + // finishCloudAgentPiModelStepWithInvalidCalls):超限/非法参数进不了执行器, + // 但模型仍需要「拆分重试」的结构化指引,而不是这里的一句泛化校验错误—— + // 那会让 Pi 会话与服务端 canonical 各记一份不同的结果,下一轮模型请求 + // 按历史长度二选一,看到哪份不稳定。优先回放已保存回执,两边保持一致。 + if run, loadErr := s.repo.CloudAgent(userID, runID); loadErr == nil { + if state, decodeErr := cloudAgentDecode(run); decodeErr == nil { + if content, ok := cloudAgentToolMessage(&state, call.ID); ok { + return cloudAgentPiToolResult(call, content), nil + } + } + } return nil, err } diff --git a/backend/internal/app/cloud_agent_pi_model.go b/backend/internal/app/cloud_agent_pi_model.go index d98235a02..d3c9b5642 100644 --- a/backend/internal/app/cloud_agent_pi_model.go +++ b/backend/internal/app/cloud_agent_pi_model.go @@ -331,21 +331,27 @@ func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID stri finishText, finishReasoning = "", "" } if !compactionSummary { - if err := validateCloudAgentCalls(result.ToolCalls); err != nil { - // The task itself succeeded, but its protocol result is not executable. - // Release the adopted task before returning the hard protocol error so a - // restarted run cannot remain stuck behind an invalid active task. + invalid, structural := cloudAgentBatchInvalidCalls(result.ToolCalls) + if len(invalid) > 0 && (structural || state.InvalidArgumentSteps >= cloudAgentInvalidArgumentStepLimit) { + // 结构类问题(ID 无效/重复、工具名无效)按 ID 配对纠偏会产生歧义,连续多批 + // 参数无效说明模型无法自我修正:两者都保持既有整步拒绝语义, + // 释放任务避免重启后的 run 卡在无效 active task 上。 if releaseErr := s.finishCloudAgentPiModelStep(userID, runID, task.ID, "", ""); releaseErr != nil { return nil, false, releaseErr } - return nil, false, fmt.Errorf("invalid Pi tool calls: %w", err) + if structural { + return nil, false, fmt.Errorf("invalid Pi tool calls: %w", validateCloudAgentCalls(result.ToolCalls)) + } + return nil, false, fmt.Errorf("invalid Pi tool calls: 模型连续 %d 批工具调用未通过参数校验", cloudAgentInvalidArgumentStepLimit) } - } - if compactionSummary { - if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, "", ""); err != nil { + if len(invalid) > 0 { + if err := s.finishCloudAgentPiModelStepWithInvalidCalls(userID, runID, task.ID, finishText, finishReasoning, result.ToolCalls, invalid); err != nil { + return nil, false, err + } + } else if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, finishText, finishReasoning, result.ToolCalls); err != nil { return nil, false, err } - } else if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, finishText, finishReasoning, result.ToolCalls); err != nil { + } else if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, "", ""); err != nil { return nil, false, err } delivered := map[string]any{"text": result.Text, "reasoning": result.Reasoning, "toolCalls": runtimeToolCalls(result.ToolCalls)} @@ -517,6 +523,10 @@ func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reaso } fresh.ActiveTaskID = "" fresh.ActiveTextDraft = "" + if len(callBatches) > 0 { + // 一批全部有效的调用意味着模型回到了正常输出轨道,参数纠偏计数清零。 + fresh.InvalidArgumentSteps = 0 + } if reasoning != "" { fresh.event(runID, "reasoning_message", map[string]any{"messageId": taskID + ":reasoning", "text": truncateRunes(reasoning, 8000)}) } @@ -547,6 +557,80 @@ func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reaso return repository.ErrCreationConflict } +// finishCloudAgentPiModelStepWithInvalidCalls 提交一个含无效调用的模型步骤(参数纠偏轮)。 +// +// 模型一步里可能混发有效与无效调用——线上 run ag3580aca 就是把整份剧本大纲塞进 +// canvas_apply_ops 的参数超过字节上限,此前整批被当致命协议错误判死成「Agent 执行中断」。 +// 参数超限是模型输出问题而非运行时故障:无效调用立即以结构化错误回执(invalid_model_output, +// 指引拆分重试),有效调用照常进入执行批次;assistant 消息保留全部调用,canonical 的 +// 调用/结果配对保持完整。连续 cloudAgentInvalidArgumentStepLimit 批无效才回到硬失败。 +func (s *Service) finishCloudAgentPiModelStepWithInvalidCalls(userID, runID, taskID, text, reasoning string, calls []cloudAgentCall, invalid map[int]string) error { + valid := make([]cloudAgentCall, 0, len(calls)) + for index, call := range calls { + if _, rejected := invalid[index]; !rejected { + valid = append(valid, call) + } + } + for attempt := 0; attempt < 8; attempt++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return err + } + err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + fresh, err := cloudAgentDecode(current) + if err != nil { + return err + } + if fresh.ActiveTaskID != taskID { + return nil + } + fresh.ActiveTaskID = "" + fresh.ActiveTextDraft = "" + fresh.InvalidArgumentSteps++ + if reasoning != "" { + fresh.event(runID, "reasoning_message", map[string]any{"messageId": taskID + ":reasoning", "text": truncateRunes(reasoning, 8000)}) + } + fresh.Canonical.ToolChoice = "auto" + fresh.Calls = valid + fresh.CallIndex = 0 + fresh.StepSnapshotHash = cloudAgentCaptureStepSnapshotHash(valid) + if text != "" { + fresh.event(runID, "assistant_message", map[string]any{"messageId": taskID, "text": text}) + } + fresh.Canonical.Messages = append(fresh.Canonical.Messages, map[string]any{ + "role": "assistant", "content": text, "tool_calls": calls, + }) + rejected := make([]string, 0, len(invalid)) + for index, call := range calls { + reason, ok := invalid[index] + if !ok { + continue + } + receipt, _ := json.Marshal(map[string]any{ + "error": "该工具调用未通过参数校验:" + reason + "。本次调用没有执行,其余同批调用不受影响。", + "errorClass": cloudAgentToolErrorInvalidModelOutput, + "errorClassLabel": cloudAgentToolErrorLabel(cloudAgentToolErrorInvalidModelOutput), + "requiredAction": "fix_arguments", + }) + fresh.Canonical.Messages = append(fresh.Canonical.Messages, map[string]any{ + "role": "tool", "tool_call_id": call.ID, "content": string(receipt), + }) + rejected = append(rejected, call.Function.Name) + } + fresh.event(runID, "tool_arguments_rejected", map[string]any{ + "toolNames": rejected, "attempt": fresh.InvalidArgumentSteps, + "maxAttempts": cloudAgentInvalidArgumentStepLimit, + "text": "模型工具调用参数未通过校验,已回执要求修正后重试", + }) + return cloudAgentSave(current, &fresh) + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + return repository.ErrCreationConflict +} + func (s *Service) waitCloudAgentTask(ctx context.Context, taskID string) (*model.Task, error) { ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index bee35b9b1..d886ae62b 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -152,6 +152,7 @@ type cloudAgentRuntime struct { DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"` ActionNudged bool `json:"actionNudged,omitempty"` EmptyOutputNudged int `json:"emptyOutputNudged,omitempty"` + InvalidArgumentSteps int `json:"invalidArgumentSteps,omitempty"` StepSnapshotHash string `json:"stepSnapshotHash,omitempty"` StoryboardTaskID string `json:"storyboardTaskId,omitempty"` Plan []cloudAgentPlanItem `json:"plan,omitempty"` diff --git a/backend/internal/app/cloud_agent_runtime_scheduler.go b/backend/internal/app/cloud_agent_runtime_scheduler.go index 91d26b3e1..196d67cc5 100644 --- a/backend/internal/app/cloud_agent_runtime_scheduler.go +++ b/backend/internal/app/cloud_agent_runtime_scheduler.go @@ -485,20 +485,75 @@ func compactCloudAgentContext(request *canonicalAgentRequest, budget cloudAgentC return changed } +// 单个工具调用参数的字节上限。中文约一万字——正常调用远低于此;超过通常意味着模型 +// 把整份剧本文本塞进了一次调用(线上 run ag3580aca 即此),应由纠偏路径引导拆分, +// 而不是判死整个运行。 +const cloudAgentToolArgumentsByteLimit = 32000 + func validateCloudAgentCalls(calls []cloudAgentCall) error { seen := make(map[string]bool, len(calls)) for _, call := range calls { - if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || seen[call.ID] || call.Function.Name == "" || len(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { + reason, argumentProblem := cloudAgentInvalidCallReason(call, seen) + if reason != "" { + if argumentProblem { + return errors.New("invalid Agent tool arguments") + } return errors.New("invalid Agent tool call") } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil || len(call.Function.Arguments) > 32000 { - return errors.New("invalid Agent tool arguments") - } - seen[call.ID] = true } return nil } +// 连续多少批模型调用含无效参数后放弃纠偏、回到硬失败路径。 +const cloudAgentInvalidArgumentStepLimit = 3 + +// cloudAgentBatchInvalidCalls 找出一批调用里所有无效项,返回 index → 原因,以及 +// 是否存在结构类问题(ID 无效/重复、工具名无效——非参数类)。无效调用也登记 seen, +// 避免同批后续调用复用无效调用的 ID 被放行。 +// +// 结构类问题不能按参数问题逐调用纠偏:重复 ID 的调用与回执按 ID 配对会产生歧义 +// (第二条同 ID 调用的拒绝回执可能被当作第一条有效调用的结果),历史配对校验也会 +// 失败——保持整步拒绝的既有语义。参数类(超限/JSON 非法)才走逐调用纠偏。 +func cloudAgentBatchInvalidCalls(calls []cloudAgentCall) (map[int]string, bool) { + seen := make(map[string]bool, len(calls)) + invalid := map[int]string{} + structural := false + for index, call := range calls { + reason, argumentProblem := cloudAgentInvalidCallReason(call, seen) + if reason == "" { + continue + } + invalid[index] = reason + seen[call.ID] = true + if !argumentProblem { + structural = true + } + } + return invalid, structural +} + +// cloudAgentInvalidCallReason 返回单个调用未通过校验的原因与是否属于参数问题; +// 空原因表示有效。seen 记录本批已出现的调用 ID(重复即无效),有效调用由本函数登记。 +func cloudAgentInvalidCallReason(call cloudAgentCall, seen map[string]bool) (string, bool) { + if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil { + return "调用 ID 无效", false + } + if seen[call.ID] { + return "调用 ID 与同批其他调用重复", false + } + if call.Function.Name == "" || len(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { + return "工具名无效", false + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil { + return "参数不是合法的 JSON 对象", true + } + if len(call.Function.Arguments) > cloudAgentToolArgumentsByteLimit { + return "参数超过 32000 字节上限(中文约一万字);长文本请拆分为多次调用,例如先建空结构再逐段追加", true + } + seen[call.ID] = true + return "", false +} + func (s *Service) terminateCloudAgent(run *model.CloudAgentExecution, message string) error { if run == nil { return errors.New(message)