186 lines
7.0 KiB
Go
186 lines
7.0 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"
|
||
cloudAgentToolErrorAdmission = "admission_failure"
|
||
cloudAgentToolErrorUnknown = "tool_error"
|
||
)
|
||
|
||
// cloudAgentToolErrorClass 返回 (errorClass, retryable, requiredAction)。
|
||
//
|
||
// requiredAction 是给模型/界面看的"下一步该做什么",取稳定的短标识:
|
||
// fix_arguments(按 schema 改参数后重试)、reread_canvas(重读画布再试)、retry(临时故障自动重试)、
|
||
// 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 admissionErr.ErrorClass != "" {
|
||
retryable, action := admissionErr.Retryable, admissionErr.RequiredAction
|
||
if action == "" {
|
||
action = "report_to_user"
|
||
}
|
||
return admissionErr.ErrorClass, retryable, action
|
||
}
|
||
if admissionErr.Reason == "snapshot_conflict" {
|
||
return cloudAgentToolErrorStateConflict, true, "reread_canvas"
|
||
}
|
||
return cloudAgentToolErrorAdmission, false, "report_to_user"
|
||
}
|
||
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"
|
||
}
|
||
// Unknown failures are not evidence that another model-generated argument
|
||
// will work. Never advertise them as retryable; the caller must report the
|
||
// failure instead of starting an unbounded variant loop.
|
||
return cloudAgentToolErrorUnknown, false, "report_to_user"
|
||
}
|
||
|
||
// 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 "上游故障"
|
||
case cloudAgentToolErrorAdmission:
|
||
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
|
||
}
|