工具失败此前只有一句自然语言(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)时套用,已分类的参数错误不套壳。 - 幻觉工具名的拒绝文案更具体:过去一律"工具未获本轮权限授权",现在指向"不存在的工具 + 只使用 本轮工具表里列出的工具"。 - 前端工具卡失败时显示归类标签,比统一的"执行失败"更能说明下一步该做什么。
175 lines
6.5 KiB
Go
175 lines
6.5 KiB
Go
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
|
||
}
|