fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行 (#701)
* fix(agent): 画布助手 - 工具参数超限改为纠偏回执,不再判死整个运行 * fix(agent): 画布助手 - 桥接优先回放参数回执,结构类调用问题整步拒绝 --------- Co-authored-by: Future <jiaboxuan1205@163.com>
This commit is contained in:
248
backend/internal/app/cloud_agent_invalid_arguments_test.go
Normal file
248
backend/internal/app/cloud_agent_invalid_arguments_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user