Merge remote-tracking branch 'origin/pr-585' into codex/pr-557-589-security-integration

This commit is contained in:
ddCat
2026-09-22 11:10:48 +08:00
6 changed files with 514 additions and 79 deletions

View File

@@ -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":

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
}

View 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)
}
}