fix(cloud-agent): 工具失败归类与可行动字段

工具失败此前只有一句自然语言(tool_failed + text),无法区分"模型自己出的错"和真实的
权限/状态/上游问题:模型拿不到可自纠的反馈,排查时分不清该改提示词、改权限还是等上游。

- 新增 backend/internal/app/cloud_agent_tool_error_class.go:把失败归成
  invalid_model_output(幻觉工具名;本轮契约之外、只被 contextScope 挡掉的调用)、
  schema_error(缺必填字段/类型错/未知字段/非法 JSON)、state_conflict(画布在读取之后被改动)、
  permission_violation(只读轮发起写入等真实授权边界)、upstream_failure(上游 4xx/5xx、
  超时、媒体上游拒绝),其余归 tool_error。
- 每类附 retryable 与稳定的 requiredAction(fix_arguments / reread_canvas /
  use_advertised_tools / ask_user / report_to_user),写进 tool_failed 的回执与载荷顶层。
  只加标注:没有任何放行或拒绝逻辑读它。
- 缺必填字段的通用判定:各工具的校验点抛的多是普通 AppError,从错误类型看不出"模型漏了字段"。
  现在用本轮实际暴露的 schema 的 required 与参数比对,命中即按参数契约错误处理(回传 schema 与
  guidance 供模型自纠);只在业务侧确实按参数问题拒绝(400/422)时套用,已分类的参数错误不套壳。
- 幻觉工具名的拒绝文案更具体:过去一律"工具未获本轮权限授权",现在指向"不存在的工具 + 只使用
  本轮工具表里列出的工具"。
- 前端工具卡失败时显示归类标签,比统一的"执行失败"更能说明下一步该做什么。
This commit is contained in:
AetherNo2332
2026-09-22 00:53:32 +08:00
parent 6f45240599
commit 4e2df72eeb
6 changed files with 514 additions and 79 deletions

View File

@@ -0,0 +1,174 @@
package app
import (
"context"
"encoding/json"
"errors"
"strings"
)
// 工具调用失败的稳定归类(handoff 工作项 B 的第一步:只加分类,不改执行行为)。
//
// 目标是把"模型自己出的错"和"真实世界/上游的错"分开:
// - 模型幻觉出不存在的工具、在只暴露画布工具的这一轮里调画布工具、参数不合法 → 模型输出问题;
// - 只读轮里发起写入、目标不具备能力 → 权限边界问题;
// - 画布在模型读取之后被改动 → 状态冲突(可重读后重试);
// - 上游拒绝/超时/网络 → 上游故障。
//
// 分类只进事件载荷,不参与任何放行或拒绝判定:拒绝逻辑仍然在各自的校验点。
const (
cloudAgentToolErrorInvalidModelOutput = "invalid_model_output"
cloudAgentToolErrorSchemaError = "schema_error"
cloudAgentToolErrorStateConflict = "state_conflict"
cloudAgentToolErrorPermission = "permission_violation"
cloudAgentToolErrorUpstream = "upstream_failure"
cloudAgentToolErrorUnknown = "tool_error"
)
// cloudAgentToolErrorClass 返回 (errorClass, retryable, requiredAction)。
//
// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识,不写自然语言:
// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、
// use_advertised_tools(只使用本轮工具表里列出的工具)、ask_user(权限被拒,别自己绕)、
// report_to_user(上游故障,告诉用户)。
func cloudAgentToolErrorClass(req CloudAgentRequest, call cloudAgentCall, err error, allowed bool) (string, bool, string) {
if err == nil {
return "", true, ""
}
if !allowed {
// 工具名不在平台工具集里 = 模型幻觉;只被 contextScope 挡掉 = 模型在本轮契约之外调用。
// 两者都是模型输出问题,不是权限问题(它本来就不该发这个调用)。
if !cloudAgentPlatformToolNames()[call.Function.Name] {
return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"
}
scopeRequest := req
scopeRequest.ContextScope = []string{"canvas"}
if cloudAgentToolAllowed(scopeRequest, call.Function.Name) {
return cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"
}
return cloudAgentToolErrorPermission, false, "ask_user"
}
// 过期快照在上游是结构化字段错误(Field/Issue),先于通用参数错误判定。
var fieldErr *cloudAgentFieldArgumentError
if errors.As(err, &fieldErr) {
if fieldErr.Issue == "stale_snapshot" {
return cloudAgentToolErrorStateConflict, true, "reread_canvas"
}
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
}
var argumentErr *cloudAgentArgumentError
if errors.As(err, &argumentErr) {
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
}
var admissionErr *cloudAgentMediaAdmissionError
if errors.As(err, &admissionErr) {
// 媒体准入里"上游拒绝该规格"属于上游故障;其余是参数/状态问题。
if strings.Contains(admissionErr.Error(), "上游") {
return cloudAgentToolErrorUpstream, false, "report_to_user"
}
return cloudAgentToolErrorSchemaError, true, "fix_arguments"
}
var httpErr providerHTTPError
if errors.As(err, &httpErr) {
return cloudAgentToolErrorUpstream, httpErr.StatusCode >= 500 || httpErr.StatusCode == 429, "report_to_user"
}
var pendingErr providerStatePendingError
if errors.As(err, &pendingErr) {
return cloudAgentToolErrorUpstream, true, "report_to_user"
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return cloudAgentToolErrorUpstream, true, "report_to_user"
}
return cloudAgentToolErrorUnknown, true, ""
}
// cloudAgentPlatformToolNames 是平台支持的工具全集(含只在特定条件下暴露的工具)。
func cloudAgentPlatformToolNames() map[string]bool {
names := make(map[string]bool)
for _, name := range CloudAgentSupportedToolNames() {
names[name] = true
}
return names
}
// cloudAgentToolErrorLabel 把归类转成一句给人看的短标签(界面与诊断包共用)。
func cloudAgentToolErrorLabel(class string) string {
switch class {
case cloudAgentToolErrorInvalidModelOutput:
return "模型输出问题"
case cloudAgentToolErrorSchemaError:
return "参数不符合契约"
case cloudAgentToolErrorStateConflict:
return "画布状态已变化"
case cloudAgentToolErrorPermission:
return "超出本轮权限"
case cloudAgentToolErrorUpstream:
return "上游故障"
default:
return "工具执行失败"
}
}
// schemaStringList 读 schema 里的字符串列表:既可能是 JSON 解码出来的 []any,
// 也可能是 Go 里直接构造的 []string。
func schemaStringList(value any) []string {
switch typed := value.(type) {
case []string:
return append([]string(nil), typed...)
case []any:
list := make([]string, 0, len(typed))
for _, item := range typed {
if text, ok := item.(string); ok && text != "" {
list = append(list, text)
}
}
return list
default:
return nil
}
}
// cloudAgentMissingRequiredArguments 用"本轮实际暴露的 schema"检查必填字段。
//
// 为什么需要它:各工具的校验点是业务代码,抛的多是普通 AppError(例如分镜读取参数无效),
// 从错误类型上看不出"这是模型漏了必填字段"。而模型漏字段恰恰是最常见的工具失败
// (handoff 工作项 B 的证据里,缺 size / 缺 nodeId 类占大头)。这里按 schema 的 required
// 直接判定,把这些错误归到参数契约问题,并把 schema 回给模型让它改对。
//
// 参数不是 JSON 对象时返回空列表(那种情况由 decodeCloudAgentJSONObject 的既有路径覆盖)。
func cloudAgentMissingRequiredArguments(tools []map[string]any, call cloudAgentCall) []string {
name := strings.TrimSpace(call.Function.Name)
if name == "" {
return nil
}
var schema map[string]any
for _, tool := range tools {
function, _ := tool["function"].(map[string]any)
if stringValue(function["name"]) != name {
continue
}
schema, _ = function["parameters"].(map[string]any)
break
}
if schema == nil {
return nil
}
required := schemaStringList(schema["required"])
if len(required) == 0 {
return nil
}
arguments := map[string]any{}
raw := strings.TrimSpace(call.Function.Arguments)
if raw != "" {
if json.Unmarshal([]byte(raw), &arguments) != nil {
return nil
}
}
missing := make([]string, 0, len(required))
for _, key := range required {
if _, ok := arguments[key]; !ok {
missing = append(missing, key)
}
}
return missing
}