fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行 (#701)

* fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行

* fix(agent): 画布助手 - 桥接优先回放参数回执,结构类调用问题整步拒绝

---------

Co-authored-by: Future <jiaboxuan1205@163.com>
This commit is contained in:
jiabx
2026-10-08 12:15:22 +08:00
committed by GitHub
parent d98e9f78f1
commit e69bea15ef
5 changed files with 414 additions and 14 deletions

View File

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

View File

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

View File

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

View File

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

View File

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