Merge remote-tracking branch 'origin/pr-585' into codex/pr-557-589-security-integration
This commit is contained in:
@@ -950,6 +950,20 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
|
||||
if !ok || detail == nil {
|
||||
detail = map[string]any{}
|
||||
}
|
||||
// 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按
|
||||
// "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。
|
||||
// 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段,
|
||||
// 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。
|
||||
var appErr *AppError
|
||||
var existingArgumentErr *cloudAgentArgumentError
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 &&
|
||||
!errors.As(err, &existingArgumentErr) &&
|
||||
errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) {
|
||||
err = &cloudAgentFieldArgumentError{
|
||||
error: &cloudAgentArgumentError{err},
|
||||
Field: strings.Join(missing, ","), Issue: "required",
|
||||
}
|
||||
}
|
||||
message := cloudAgentSafeToolError(err)
|
||||
detail["error"] = message
|
||||
var admissionErr *cloudAgentMediaAdmissionError
|
||||
@@ -979,6 +993,16 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen
|
||||
detail["exampleArguments"] = map[string]any{"snapshotHash": "<canvas_get_state.snapshotHash>", "ops": []any{map[string]any{"type": "add_node", "id": "<new-node-id>", "nodeType": "text", "content": "<content>"}}}
|
||||
}
|
||||
}
|
||||
// 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。
|
||||
// allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。
|
||||
if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" {
|
||||
detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class)
|
||||
detail["retryable"] = retryable
|
||||
if requiredAction != "" {
|
||||
detail["requiredAction"] = requiredAction
|
||||
}
|
||||
payload["errorClass"] = class
|
||||
}
|
||||
result = detail
|
||||
kind = "tool_failed"
|
||||
payload["text"] = message
|
||||
@@ -1173,7 +1197,13 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
|
||||
var toolErr error
|
||||
switch {
|
||||
case !allowed:
|
||||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||||
// 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用,
|
||||
// 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。
|
||||
if !cloudAgentPlatformToolNames()[call.Function.Name] {
|
||||
toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具")
|
||||
} else {
|
||||
toolErr = BadAuthRequest("工具未获本轮权限授权")
|
||||
}
|
||||
case call.Function.Name == "canvas_apply_ops":
|
||||
result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state))
|
||||
case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard":
|
||||
|
||||
174
backend/internal/app/cloud_agent_tool_error_class.go
Normal file
174
backend/internal/app/cloud_agent_tool_error_class.go
Normal 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
|
||||
}
|
||||
130
backend/internal/app/cloud_agent_tool_error_class_test.go
Normal file
130
backend/internal/app/cloud_agent_tool_error_class_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// handoff 工作项 B 的第一步:把"模型输出问题"与"权限/状态/上游"分开,并且只加标注不改行为。
|
||||
func TestCloudAgentToolErrorClass(t *testing.T) {
|
||||
canvasRequest := CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}
|
||||
readOnlyRequest := CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}}
|
||||
scopedRequest := CloudAgentRequest{PermissionMode: "auto"} // contextScope 为空:只暴露通用工具
|
||||
call := func(name string) cloudAgentCall {
|
||||
var c cloudAgentCall
|
||||
c.Function.Name = name
|
||||
return c
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
request CloudAgentRequest
|
||||
call cloudAgentCall
|
||||
err error
|
||||
allowed bool
|
||||
wantClass string
|
||||
wantRetryable bool
|
||||
wantAction string
|
||||
}{
|
||||
{"幻觉工具名", canvasRequest, call("make_coffee"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"},
|
||||
{"contextScope 为空却调画布工具", scopedRequest, call("canvas_get_state"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorInvalidModelOutput, true, "use_advertised_tools"},
|
||||
{"只读轮里发起生成", readOnlyRequest, call("generate_media"), BadAuthRequest("工具未获本轮权限授权"), false, cloudAgentToolErrorPermission, false, "ask_user"},
|
||||
{"参数不符合契约", canvasRequest, call("canvas_get_state"), cloudAgentJSONArgumentError(errors.New("unknown field")), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"},
|
||||
{"字段级参数错误", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "required", "缺 snapshotHash"), true, cloudAgentToolErrorSchemaError, true, "fix_arguments"},
|
||||
{"画布已变化", canvasRequest, call("canvas_apply_ops"), cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化"), true, cloudAgentToolErrorStateConflict, true, "reread_canvas"},
|
||||
{"上游 5xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 503}, true, cloudAgentToolErrorUpstream, true, "report_to_user"},
|
||||
{"上游 4xx", canvasRequest, call("task_get"), providerHTTPError{StatusCode: 400}, true, cloudAgentToolErrorUpstream, false, "report_to_user"},
|
||||
{"超时", canvasRequest, call("task_get"), context.DeadlineExceeded, true, cloudAgentToolErrorUpstream, true, "report_to_user"},
|
||||
{"其它", canvasRequest, call("task_get"), errors.New("说不清"), true, cloudAgentToolErrorUnknown, true, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
class, retryable, action := cloudAgentToolErrorClass(tc.request, tc.call, tc.err, tc.allowed)
|
||||
if class != tc.wantClass || retryable != tc.wantRetryable || action != tc.wantAction {
|
||||
t.Fatalf("class=%q retryable=%v action=%q,期望 %q/%v/%q", class, retryable, action, tc.wantClass, tc.wantRetryable, tc.wantAction)
|
||||
}
|
||||
if label := cloudAgentToolErrorLabel(class); label == "" {
|
||||
t.Fatalf("归类 %q 缺少人读标签", class)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归类必须落到工具失败事件上:payload 与回执都要带,界面与诊断包不用再猜。
|
||||
func TestCloudAgentToolResultCarriesErrorClass(t *testing.T) {
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}, Events: []CloudAgentEvent{}}
|
||||
var call cloudAgentCall
|
||||
call.ID = "call-1"
|
||||
call.Function.Name = "canvas_apply_ops"
|
||||
call.Function.Arguments = "{}"
|
||||
cloudAgentToolResult("run-1", state, call, nil, cloudAgentFieldError("snapshotHash", "stale_snapshot", "画布已变化,本次未写入;请重新读取并重新申请审批"))
|
||||
|
||||
var payload map[string]any
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "tool_failed" {
|
||||
payload = event.Payload
|
||||
}
|
||||
}
|
||||
if payload == nil || payload["errorClass"] != cloudAgentToolErrorStateConflict {
|
||||
t.Fatalf("tool_failed 载荷缺少归类:%+v", payload)
|
||||
}
|
||||
detail, _ := payload["result"].(map[string]any)
|
||||
if detail["errorClassLabel"] != "画布状态已变化" || detail["retryable"] != true || detail["requiredAction"] != "reread_canvas" {
|
||||
t.Fatalf("回执里的归类字段不对:%+v", detail)
|
||||
}
|
||||
}
|
||||
|
||||
// 真机踩到的场景:canvas_read_storyboard 缺 nodeId 时,业务校验抛的是普通 AppError
|
||||
// ("分镜节点ID、分页参数或每页行数无效")。按 schema 的 required 判定后必须归成
|
||||
// schema_error,并把 schema 回给模型,而不是落到兜底的 tool_error。
|
||||
func TestCloudAgentToolResultClassifiesMissingRequiredArguments(t *testing.T) {
|
||||
schema := []map[string]any{{"type": "function", "function": map[string]any{
|
||||
"name": "canvas_read_storyboard",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"nodeId"},
|
||||
"properties": map[string]any{
|
||||
"nodeId": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}}}
|
||||
state := &cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "read_only", ContextScope: []string{"canvas"}}, Canonical: canonicalAgentRequest{Tools: schema}, Events: []CloudAgentEvent{}}
|
||||
var call cloudAgentCall
|
||||
call.ID = "call-1"
|
||||
call.Function.Name = "canvas_read_storyboard"
|
||||
call.Function.Arguments = `{}`
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) != 1 || missing[0] != "nodeId" {
|
||||
t.Fatalf("必填字段检测 = %v,期望 [nodeId]", missing)
|
||||
}
|
||||
cloudAgentToolResult("run-1", state, call, nil, BadAuthRequest("分镜节点ID、分页参数或每页行数无效"))
|
||||
|
||||
var payload map[string]any
|
||||
for _, event := range state.Events {
|
||||
if event.Type == "tool_failed" {
|
||||
payload = event.Payload
|
||||
}
|
||||
}
|
||||
if payload == nil {
|
||||
t.Fatal("应当记成 tool_failed")
|
||||
}
|
||||
if payload["errorClass"] != cloudAgentToolErrorSchemaError {
|
||||
t.Fatalf("归类 = %v,期望 %s", payload["errorClass"], cloudAgentToolErrorSchemaError)
|
||||
}
|
||||
detail, _ := payload["result"].(map[string]any)
|
||||
if detail["reason"] != "invalid_tool_arguments" || detail["requiredAction"] != "fix_arguments" {
|
||||
t.Fatalf("缺少可自纠的参数错误标注:%+v", detail)
|
||||
}
|
||||
if detail["field"] != "nodeId" {
|
||||
t.Fatalf("field = %v,期望 nodeId", detail["field"])
|
||||
}
|
||||
if _, ok := detail["parameters"].(map[string]any); !ok {
|
||||
t.Fatalf("必须把本轮暴露的 schema 回给模型:%+v", detail["parameters"])
|
||||
}
|
||||
// 必填字段齐备时不误判:参数没问题就不该被当成契约错误。
|
||||
full := call
|
||||
full.Function.Arguments = `{"nodeId":"node-1"}`
|
||||
if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, full); len(missing) != 0 {
|
||||
t.Fatalf("字段齐备却报缺字段:%v", missing)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user