diff --git a/backend/internal/app/cloud_agent_tool_schema_budget_test.go b/backend/internal/app/cloud_agent_tool_schema_budget_test.go new file mode 100644 index 000000000..b47d32f55 --- /dev/null +++ b/backend/internal/app/cloud_agent_tool_schema_budget_test.go @@ -0,0 +1,54 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" +) + +// 工具 schema 是每一步都要发出去(并且是前缀缓存的第一段)的固定开销,因此值得钉住体积: +// 平台工具全集按 auto + canvas + 技能构造(覆盖条件暴露的工具)。 +func platformToolSchema(t *testing.T) ([]map[string]any, []byte) { + t.Helper() + req := CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}, SkillIDs: []string{"capability-list"}} + req.Budget.MaxGenerationTasks = 1 + tools := cloudAgentTools(req) + raw, err := json.Marshal(tools) + if err != nil { + t.Fatal(err) + } + return tools, raw +} + +func TestCloudAgentToolSchemaStaysCompact(t *testing.T) { + tools, raw := platformToolSchema(t) + t.Logf("platform tool schema: %d tools, %d bytes", len(tools), len(raw)) + + // 顶层 type 枚举 + 服务端按 action 强校验已经表达了"每种操作要哪些字段", + // 再挂一份 oneOf 只是重复:同一个约束会被下发两遍。 + for _, tool := range tools { + function, _ := tool["function"].(map[string]any) + if function["name"] != "canvas_apply_ops" { + continue + } + parameters, _ := function["parameters"].(map[string]any) + ops, _ := parameters["properties"].(map[string]any) + items, _ := ops["ops"].(map[string]any) + opItem, _ := items["items"].(map[string]any) + if _, exists := opItem["oneOf"]; exists { + t.Fatal("canvas_apply_ops 的 ops.items 不该再带重复的 oneOf") + } + if props, ok := opItem["properties"].(map[string]any); !ok || len(props) == 0 { + t.Fatal("canvas_apply_ops 的 ops.items 必须保留 properties") + } + } + + // 体积预算:当前 20 个工具约 22.4 KB,这里留 ~7% 余量;新增工具或字段时请重新测量并 + // 有意识地调整这个数字,而不是让 schema 悄悄膨胀(它每一步都要发、还在前缀最前面)。 + if len(raw) > 24000 { + t.Fatalf("平台工具 schema 体积 %d 字节超出预算 24000:请压缩描述或显式调整预算", len(raw)) + } + if strings.Count(string(raw), "oneOf") > 2 { + t.Fatalf("工具 schema 里出现了过多 oneOf:%s", string(raw[:200])) + } +} diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index b04c898ea..ea30dceab 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -191,9 +191,9 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "steps": map[string]any{"type": "array", "maxItems": 12, "items": map[string]any{ "type": "object", "properties": map[string]any{ - "tool": str("这一步用哪个工具(工具名)"), + "tool": str("工具名"), "action": str("这一步做什么"), - "note": str("可选:坑/前提/注意"), + "note": str("可选:坑或前提"), }, "required": []string{"tool", "action"}, "additionalProperties": false, }}, @@ -251,11 +251,6 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "properties": opProperties, "required": []string{"type", "id"}, "additionalProperties": false, - "oneOf": []map[string]any{ - {"properties": map[string]any{"type": map[string]any{"const": "add_node"}}, "required": []string{"nodeType"}}, - {"properties": map[string]any{"type": map[string]any{"const": "update_node"}}, "required": []string{"patch"}}, - {"properties": map[string]any{"type": map[string]any{"const": "connect_nodes"}}, "required": []string{"fromNodeId", "toNodeId"}}, - }, } add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType,update_node 还需要按节点能力清单填写 patch,connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops") } @@ -264,7 +259,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma "mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"), "logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"), "durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"}, - "snapshotHash": str("可省略;省略时服务端使用当前画布内容快照。也可传 canvas_get_state 的 mediaSnapshotHash 或上一写操作返回的 snapshotHash"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;无文本来源则留空,绝不能填图片/视频/音频ID"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("当前画布媒体参考节点ID;参考图只放此处,保持引用顺序")}, "referenceTransientIds": map[string]any{"type": "array", "maxItems": 4, "items": str("由 image_annotation_render 返回的临时参考图ID")}, + "snapshotHash": str("可省略:省略时用当前画布内容快照"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;不要填媒体节点"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("画布媒体参考节点ID,按引用顺序")}, "referenceTransientIds": map[string]any{"type": "array", "maxItems": 4, "items": str("由 image_annotation_render 返回的临时参考图ID")}, }, "mode", "prompt", "nodeId", "title", "referenceNodeIds") } return tools