From 697f421e570606d0519f952a0a5689b5f639f902 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 11:11:48 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E8=B6=85=E8=BF=87=E4=BA=94=E7=99=BE=E9=A1=B9?= =?UTF-8?q?=E6=97=B6=E7=9A=84=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除账户素材全库数量与分页偏移的五百项限制,保留单页与导入安全边界 修复账户素材导入在身份租约内重复校验会话导致的自锁 补充大库分页、预览、实际导入与幂等验证,更新工具说明和项目文档 --- .../prompts/runtime/texts/direct-tools.json | 2 +- .../prompts/runtime/texts/runtime.json | 2 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 6 +- .../src-tauri/src/agent/direct_tools_mcp.rs | 253 +++++++++++++++++- .../src/agent/runtime_tools/context.rs | 8 +- .../src-tauri/src/commands.rs | 50 ++-- docs/project-memory/shared-memory/pitfalls.md | 8 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 8 files changed, 298 insertions(+), 35 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json index d259be149..043ab9557 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json @@ -33,7 +33,7 @@ "agc_list_project_files.description": "列出当前 AGC 项目文件,包括尚未登记的本地资源。结果返回项目相对路径、大小、文件类别、是否已登记及 assetImportable。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。", "agc_list_project_files.parameters.path": "可选的项目相对目录前缀,例如 assets 或 game", "agc_list_project_files.parameters.kind": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源", - "agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。", + "agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。可按 folderId 与 query(素材名或文件夹名)筛选;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页。账户素材总量不影响查询。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。", "agc_import_account_assets.description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源使用 agc_list_project_files 返回且 assetImportable=true 的项目根相对 localPaths。assetIds 与 localPaths 可混合提交。", "agc_create_or_derive_resource.description": "按用户当前意图创建或派生视频、角色动画、音效或背景音乐。相同未完成请求会优先恢复;付费请求结果不确定时先核对状态,禁止重发。", "agc_create_or_derive_resource.parameters.sourceLocalAssetId": "derive 时必须使用 agc_list_registered_assets 返回的当前项目 localAssetId", diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json index f01bcdaae..2299b3df6 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json @@ -72,7 +72,7 @@ "file_tools_contract": "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n", "task_tools_contract": "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n", "canvas_generate_contract": "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n", - "asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n", + "asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页,账户素材总量不影响查询。结果只含当前页 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n", "asset_import_contract": "canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后资源即可用于正式产物。\n", "collaboration_tools_contract": "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n", "empty_tool_inputs_contract": "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 1300be1b5..4940fbfec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -668,9 +668,6 @@ fn bridge_account_asset_page(arguments: &Value) -> Result<(usize, usize), String }) .transpose()? .unwrap_or(0); - if offset > 500 { - return Err("工具参数 offset 不能超过 500".to_string()); - } let limit = arguments .get("limit") .filter(|value| !value.is_null()) @@ -1232,6 +1229,9 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } let (offset, limit) = bridge_account_asset_page(arguments)?; + if offset > 500 { + return Err("工具参数 offset 不能超过 500".to_string()); + } let manifest = read_existing_manifest_for_project(root)?; let registered_ids = manifest .assets diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 35c166d53..95feedd00 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -443,7 +443,7 @@ fn direct_tools_mcp_specs_for_plugins( "properties": { "folderId": { "type": "string", "minLength": 1, "maxLength": 512 }, "query": { "type": "string", "minLength": 1, "maxLength": 120 }, - "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, + "offset": { "type": "integer", "minimum": 0 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } }, "additionalProperties": false @@ -981,15 +981,6 @@ fn validate_account_asset_library_arguments(arguments: &Value) -> Result<(), Str { return Err("工具参数 offset 必须是非负整数".to_string()); } - if let Some(offset) = arguments - .get("offset") - .filter(|value| !value.is_null()) - .and_then(Value::as_u64) - { - if offset > 500 { - return Err("工具参数 offset 不能超过 500".to_string()); - } - } if let Some(limit) = arguments.get("limit").filter(|value| !value.is_null()) { let limit = limit .as_u64() @@ -3283,6 +3274,16 @@ mod tests { #[test] fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() { + let specs = direct_tools_mcp_specs_for(false, false); + let account_tool = specs["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "agc_list_account_assets") + .unwrap(); + let validator = jsonschema::validator_for(&account_tool["inputSchema"]).unwrap(); + assert!(validator.is_valid(&json!({"offset": 501, "limit": 1}))); + assert!(!validator.is_valid(&json!({"offset": 501, "limit": 101}))); assert!(validate_registered_assets_arguments(&json!({ "kind": "character-animation", "includeSequenceFrames": true, @@ -3320,7 +3321,10 @@ mod tests { "limit": 100 })) .is_ok()); - assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_err()); + assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_ok()); + assert!(validate_account_asset_library_arguments(&json!({ "offset": -1 })).is_err()); + assert!(validate_account_asset_library_arguments(&json!({ "offset": 1.5 })).is_err()); + assert!(validate_account_asset_library_arguments(&json!({ "limit": 101 })).is_err()); assert!(validate_account_asset_library_arguments(&json!({ "unknown": true })).is_err()); assert!(validate_account_asset_import_arguments(&json!({ "assetIds": ["asset-1"], @@ -3903,6 +3907,233 @@ mod tests { .unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}")) } + /// 大库仍通过真实工具桥分页和按 ID 导入,平台私有字段不进入工具返回。 + #[tokio::test] + async fn account_asset_large_library_tool_chain_pages_filters_and_imports() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-account-large-library-"); + let root = temporary.path(); + init_local_game_project_at(root, "account-large-library", "账户大库工具链") + .expect("init project"); + let mut assets = (0..503) + .map(|index| { + json!({ + "assetId": format!("asset-{index:04}"), + "folderId": if index < 501 { "folder-main" } else { "folder-target" }, + "label": format!("素材-{index:04}"), + "objectKey": format!("private/account-{index:04}.png"), + "imageSrc": format!("/private/account-{index:04}.png"), + "assetObjectId": format!("private-object-{index:04}"), + "assetKind": "image", + "width": 2, + "height": 2 + }) + }) + .collect::>(); + assets.push(json!({ + "assetId": "unsupported-video", + "folderId": "folder-main", + "label": "不支持的视频", + "objectKey": "private/video.mp4", + "assetKind": "video" + })); + let library = json!({"data": {"library": { + "folders": [ + {"folderId": "folder-main", "label": "图标目录"}, + {"folderId": "folder-target", "label": "背景目录"} + ], + "assets": assets + }}}); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind account fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let media_url = format!("{base_url}/account-image.png"); + let png = tool_chain_png(); + let app = Router::new() + .route( + "/api/editor/assets/library", + axum::routing::get(move |headers: HeaderMap| { + let library = library.clone(); + async move { + assert_eq!( + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()), + Some("Bearer tool-chain-token") + ); + Json(library) + } + }), + ) + .route( + "/api/assets/read-url", + axum::routing::get( + move |axum::extract::Query(query): axum::extract::Query< + std::collections::HashMap, + >| { + let media_url = media_url.clone(); + async move { + assert_eq!( + query.get("objectKey").map(String::as_str), + Some("private/account-0502.png") + ); + Json(json!({"data": {"read": {"signedUrl": media_url}}})) + } + }, + ), + ) + .route( + "/account-image.png", + axum::routing::get(move || { + let png = png.clone(); + async move { ([(axum::http::header::CONTENT_TYPE, "image/png")], png) } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let _session = crate::platform_session::install_test_platform_session( + "account-large-library-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let (bridge, _turn_arm, _execution) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + + for (arguments, expected_total, expected_id, expected_next) in [ + ( + json!({"offset": 501, "limit": 1}), + 503, + "asset-0501", + json!(502), + ), + ( + json!({"offset": 502, "limit": 1}), + 503, + "asset-0502", + Value::Null, + ), + ( + json!({"folderId": "folder-target", "query": "素材-0502", "limit": 1}), + 1, + "asset-0502", + Value::Null, + ), + ( + json!({"query": "背景目录", "limit": 1}), + 2, + "asset-0501", + json!(1), + ), + ] { + let result = tool_chain_call(&bridge, root, "agc_list_account_assets", arguments).await; + assert_eq!(result["result"]["isError"], false, "{result}"); + assert_eq!(result["result"]["content"].as_array().unwrap().len(), 1); + let payload = tool_chain_payload(&result); + assert_eq!(payload["total"], expected_total, "{payload}"); + assert_eq!(payload["limit"], 1); + assert_eq!(payload["assets"].as_array().unwrap().len(), 1); + assert_eq!(payload["assets"][0]["assetId"], expected_id); + assert_eq!(payload["nextOffset"], expected_next); + let text = result.to_string(); + for private in [ + "private/account-", + "private-object-", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ] { + assert!(!text.contains(private), "私有字段进入工具返回:{private}"); + } + } + for offset in [503, usize::MAX] { + let result = tool_chain_call( + &bridge, + root, + "agc_list_account_assets", + json!({"offset": offset}), + ) + .await; + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["total"], 503); + assert_eq!(payload["offset"], json!(offset)); + assert_eq!(payload["limit"], 100); + assert_eq!(payload["assets"], json!([])); + assert_eq!(payload["nextOffset"], Value::Null); + } + let runtime = crate::agent::observe_agent_runtime_account_asset_library( + root, + &json!({"offset": 501, "limit": 1}), + ) + .await; + assert_eq!(runtime.status, "ok", "{}", runtime.summary); + let runtime_payload: Value = + serde_json::from_str(runtime.detail.as_deref().unwrap()).unwrap(); + assert_eq!(runtime_payload["assets"][0]["assetId"], "asset-0501"); + assert_eq!(runtime_payload["nextOffset"], 502); + + let snapshot = crate::commands::read_editor_asset_library() + .await + .expect("read large account library for UI"); + assert_eq!(snapshot.assets.len(), 503); + assert!(snapshot + .assets + .iter() + .any(|asset| asset.asset_id == "asset-0502" && asset.preview_available)); + let preview = crate::commands::read_editor_asset_preview("asset-0502".to_string()) + .await + .expect("preview image from large account library"); + assert!(preview.preview_url.starts_with("data:image/png;base64,")); + + let imported = tool_chain_call( + &bridge, + root, + "agc_import_account_assets", + json!({"assetIds": ["asset-0502"]}), + ) + .await; + assert_eq!(imported["result"]["isError"], false, "{imported}"); + let imported_payload = tool_chain_payload(&imported); + assert_eq!(imported_payload["status"], "completed"); + assert_eq!(imported_payload["revisionAdvanceCount"], 1); + let imported_asset = &imported_payload["imported"][0]; + let local_path = imported_asset["localPath"].as_str().expect("imported path"); + assert_eq!( + std::fs::read(root.join(local_path)).unwrap(), + tool_chain_png() + ); + let manifest_before = std::fs::read(root.join(".agent/manifest.json")).unwrap(); + + let repeated = tool_chain_call( + &bridge, + root, + "agc_import_account_assets", + json!({"assetIds": ["asset-0502"]}), + ) + .await; + assert_eq!(repeated["result"]["isError"], false, "{repeated}"); + let repeated_payload = tool_chain_payload(&repeated); + assert_eq!(repeated_payload["imported"][0]["id"], imported_asset["id"]); + assert_eq!(repeated_payload["revisionAdvanceCount"], 0); + for id in ["missing-asset", "unsupported-video"] { + let rejected = tool_chain_call( + &bridge, + root, + "agc_import_account_assets", + json!({"assetIds": [id]}), + ) + .await; + assert_eq!(rejected["result"]["isError"], true, "{rejected}"); + let rejected_payload = tool_chain_payload(&rejected); + assert_eq!(rejected_payload["status"], "failed"); + assert_eq!(rejected_payload["imported"], json!([])); + assert_eq!(rejected_payload["revisionAdvanceCount"], 0); + } + assert_eq!( + std::fs::read(root.join(".agent/manifest.json")).unwrap(), + manifest_before + ); + server.abort(); + } + /// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。 #[test] fn tool_prompt_limits_agree_with_the_client_authority() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index cbcc08d97..f888c3f61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -326,12 +326,16 @@ pub(in crate::agent) async fn observe_agent_runtime_account_asset_library( } } if input.get("offset").is_some_and(|value| { - !value.is_null() && (value.as_u64().is_none() || value.as_u64() > Some(500)) + !value.is_null() + && value + .as_u64() + .and_then(|offset| usize::try_from(offset).ok()) + .is_none() }) { return AgentRuntimeToolObservation { tool: "asset.library.list".to_string(), status: "rejected".to_string(), - summary: "工具参数 offset 必须是 0 到 500 的非负整数".to_string(), + summary: "工具参数 offset 必须是非负整数".to_string(), detail: None, }; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d77fc38b5..c5f2192cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -15,9 +15,9 @@ const UI_EDITOR_FONT_MAX_COUNT: usize = 64; const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024; const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024; const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; -// Agent 查询/导入账户素材使用比 UI 更窄的投影和同一批次上限。原始 objectKey、 +// Agent 查询/导入账户素材只接收安全投影,账户总量不作为拒绝条件。原始 objectKey、 // imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。 -const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; +const AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; @@ -1730,6 +1730,33 @@ mod agent_asset_import_tests { assert_eq!(records[0].object_key.as_deref(), Some("private/button.png")); } + #[test] + fn account_library_parser_keeps_large_account_snapshots_available() { + for count in [500, 501] { + let assets = (0..count) + .map(|index| { + serde_json::json!({ + "assetId": format!("image-{index:04}"), + "label": format!("图片-{index:04}"), + "objectKey": format!("private/{index}.png"), + "assetKind": "image" + }) + }) + .collect::>(); + let payload = serde_json::json!({ + "data": { "library": { "folders": [], "assets": assets } } + }); + let records = parse_agent_editor_asset_library(&payload).expect("parse large library"); + assert_eq!(records.len(), count); + assert_eq!( + records.last().unwrap().asset_id, + format!("image-{:04}", count - 1) + ); + let snapshot = editor_asset_library_snapshot_from_records(&records); + assert_eq!(snapshot.assets.len(), count); + } + } + #[test] fn project_resource_parser_requires_matching_project_and_filters_sequences() { let payload = serde_json::json!({ @@ -2376,13 +2403,6 @@ fn parse_agent_editor_asset_library( .and_then(serde_json::Value::as_array) .map(Vec::as_slice) .unwrap_or_default(); - if assets.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS { - return Err(format!( - "账户素材库数量超过 {} 项安全上限", - AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS - )); - } - let mut records = Vec::with_capacity(assets.len()); let mut seen_ids = std::collections::BTreeSet::new(); for asset in assets { @@ -2473,10 +2493,10 @@ fn parse_agent_editor_project_resources( .get("resources") .and_then(serde_json::Value::as_array) .ok_or_else(|| "网页项目响应缺少 resources".to_string())?; - if resources.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS { + if resources.len() > AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS { return Err(format!( "网页项目画布资源数量超过 {} 项安全上限", - AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS + AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS )); } @@ -3480,18 +3500,17 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit( .as_ref() .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; - commit_account_editor_asset_downloads(root, &access, downloads, write_permit) + // 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。 + commit_account_editor_asset_downloads(root, downloads, write_permit) } fn commit_account_editor_asset_downloads( root: &Path, - access: &ExternalEditorBindingAccess<'_>, downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>, write_permit: Option<&crate::agent::WritePermit>, ) -> Result { let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; let commit = || { - access.validate_frozen_session()?; let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(downloads.len()); for (record, media_type, local_path, bytes) in downloads { @@ -3610,11 +3629,8 @@ pub(crate) fn commit_account_asset_for_write_permit_test( height: Some(1), size_bytes: Some(bytes.len() as u64), }; - let access = - ExternalEditorBindingAccess::for_developer("https://fixture.invalid", "fixture-key")?; commit_account_editor_asset_downloads( root, - &access, vec![( record, "image/png".into(), diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 817cf6a64..83395954a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -9,6 +9,14 @@ - **处理**:线上只收敛 SPA allowlist(12 条,含 `/profile`)并补 `error_page 404 /404.html`;host-only 块收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 `Genarrative-Server-Provision` 安装。退役路由不进白名单,也不需要显式 404 配置,落 `location /` 即得品牌 404(非 HTML 客户端保持纯 404)。 - **判据/取证**:改前 `grep -c profile /etc/nginx/conf.d/genarrative.conf` 为 0;改后 `curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world/profile` 为 200(正文 sha 与 `/` 一致),`sudo nginx -T | grep -c 'editor/canvas|profile|project'` 为 1,`match3d` 计数为 0。 - **长期口径**:任何 SPA/路由白名单改动都要同时核对**生效配置**(`sudo nginx -T`)与仓库模板;线上主站 vhost 只由模板 + `genarrative-host-extras.conf` 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。 + +## 2026-10-01 AGC 账户总量门槛会阻断单项查询与导入 + +- 账户素材库接口当前返回全量快照;客户端不能以全库条数代替单次查询、返回或导入的资源边界。原先在解析阶段拒绝超过 500 项,会让 `limit=1` 和指定 ID 导入一起失效,且检查发生在下载和 JSON 解析之后,不能保护这两步开销。 +- 账户查询允许偏移超过 500,仍按单页最多 100 项返回安全元数据。修改时同步 MCP schema/校验、Direct 工具桥和 Runtime;共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。 +- 实际平台会话导入还需避免在身份租约内再次调用会话读取/校验:租约持有非重入互斥锁,提交 helper 重复校验会等待自身释放。下载后校验并取得租约,持有至本地提交完成即可;不能用无平台会话的提交夹具替代完整导入链路验证。 +- 后端有界分页与按 ID 读取由 #574 跟进,不作为 #549 客户端恢复可用的前置条件。当前合同与验证入口见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“账户 / 项目画布 / 本地素材导入”。 + ## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红 - **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 860ef3572..df7af577b 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -505,6 +505,10 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm ## 2026-08-25 账户 / 项目画布 / 本地素材导入 +- 账户素材库总量不作为客户端查询、预览或按 ID 导入的拒绝条件。账户查询继续先读取后端快照,再按静态图片、文件夹与关键词筛选并分页;`offset` 接受可表示的非负整数,超过结果总数返回空页,`limit` 保持 1–100(默认 100),结果保留 `total / offset / limit / nextOffset`。Direct MCP 的关键词匹配素材名或文件夹名,Runtime 保留现有素材名匹配语义。 +- 账户查询只把当前页安全元数据作为工具文本返回,不把全库作为附件交给 Agent。指定 ID 导入仍先读取账号快照再选取目标;单批数量、下载体积、账号/项目归属、会话、媒体类型和原子登记校验保持有效。绑定画布资源数量与项目文件发现的既有边界不随账户总量限制解除而放宽。无需后端 API、DTO、数据库或持久化迁移;全库传输与解析开销由后续 #574 的服务端分页及按 ID 读取处理,不阻塞 #549 的客户端修复。 +- 平台账号导入在下载后重新校验会话并取得身份租约,租约覆盖本地写入、manifest 登记与 revision 推进;提交过程不得再次读取同一登录态互斥锁,避免在身份保护内自锁。身份变更仍须在取得租约时拒绝,取消写入许可仍不得写入。 +- 验收覆盖 500/501 项账户快照、按文件夹/关键词查询、偏移超过 500 的后续页、越界空页和大库指定 ID 导入,并复核无效 ID/不支持媒体拒绝、安全投影、取消写入和账号切换。使用 `agent_asset_import_tests`、`account_asset`、`semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments`、`cancelled_import_write_permit`、`platform_session::tests` 定向 Rust 用例及提示词合同测试;模拟平台证据与真实账号验证分别记录。 - 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。 - Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。 - `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档、代码与**引擎资源**(Cocos Creator 的模型、动画、场景/预制体、材质/特效、图集与压缩纹理容器)与其它文件;Agent 只能提交前者。引擎资源在资源画布上是**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡,不承接编辑与派生;`.meta`、`library/`、`temp/` 等引擎生成物仍然只可发现、不可登记。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。 -- 2.52.0 From 41e2cc0b57b7f9f5faf06546c1f6108e88380ac3 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 1 Oct 2026 11:37:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=8E=9F=E7=94=9F=E5=B7=A5=E5=85=B7=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8F=82=E6=95=B0=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 Runtime 原生函数 schema 中账户素材偏移五百项上限 补充实际下发 strict 函数参数的后续页与边界验证 修正阻挡测试编译的旧流式结果测试调用参数 同步技术方案与原生工具 schema 验收排障记录 --- .../src-tauri/src/agent/claude_code_cli.rs | 1 - .../src-tauri/src/agent_native_tools.rs | 36 ++++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 1 + 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 641ace7bb..8adffce7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -898,7 +898,6 @@ mod tests { let error = parse_direct_stream_result( br#"{"type":"result","is_error":true,"result":"Authentication failed"} "#, - "turn-1", None, ) .expect_err("failed terminal must be rejected"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 8262ccb0c..7fa421b6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1137,7 +1137,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "properties": { "folderId": { "type": ["string", "null"], "maxLength": 512 }, "query": { "type": ["string", "null"], "maxLength": 120 }, - "offset": { "type": ["integer", "null"], "minimum": 0, "maximum": 500 }, + "offset": { "type": ["integer", "null"], "minimum": 0 }, "limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 } } }), @@ -2006,6 +2006,40 @@ mod tests { assert!(issues.is_empty(), "{}", issues.join("\n")); } + #[test] + fn account_asset_native_function_schema_accepts_later_pages() { + let functions = build_agent_runtime_native_function_tools().unwrap(); + let function = functions + .iter() + .find(|function| { + function.name == native_runtime_function_name_for_tool("asset.library.list") + }) + .expect("account asset native function"); + assert!(function.strict); + let validator = jsonschema::validator_for(&function.parameters).unwrap(); + let mut arguments = json!({ + "reason": "读取账户素材后续页", + "input": { "folderId": null, "query": null, "offset": null, "limit": null } + }); + assert!(validator.is_valid(&arguments)); + for offset in [0, 500, 501, 600, 10_000] { + for limit in [1, 100] { + arguments["input"]["offset"] = json!(offset); + arguments["input"]["limit"] = json!(limit); + assert!(validator.is_valid(&arguments), "{arguments}"); + } + } + for offset in [json!(-1), json!(501.5)] { + arguments["input"]["offset"] = offset; + assert!(!validator.is_valid(&arguments), "{arguments}"); + } + arguments["input"]["offset"] = json!(600); + for limit in [0, 101] { + arguments["input"]["limit"] = json!(limit); + assert!(!validator.is_valid(&arguments), "{arguments}"); + } + } + #[test] fn canvas_asset_generate_schema_uses_shared_asset_kind_catalog() { let schema = runtime_tool_input_schema("canvas.asset_generate"); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 83395954a..7d63d3e3a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -13,7 +13,7 @@ ## 2026-10-01 AGC 账户总量门槛会阻断单项查询与导入 - 账户素材库接口当前返回全量快照;客户端不能以全库条数代替单次查询、返回或导入的资源边界。原先在解析阶段拒绝超过 500 项,会让 `limit=1` 和指定 ID 导入一起失效,且检查发生在下载和 JSON 解析之后,不能保护这两步开销。 -- 账户查询允许偏移超过 500,仍按单页最多 100 项返回安全元数据。修改时同步 MCP schema/校验、Direct 工具桥和 Runtime;共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。 +- 账户查询允许偏移超过 500,仍按单页最多 100 项返回安全元数据。修改时同步 MCP schema/校验、Direct 工具桥、Runtime 执行层及 `agent_native_tools.rs` 下发的 strict 原生函数 schema;仅直接调用 observation 会绕过模型侧参数约束,无法证明后续页可达,必须验证实际生成的函数参数 schema。共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。 - 实际平台会话导入还需避免在身份租约内再次调用会话读取/校验:租约持有非重入互斥锁,提交 helper 重复校验会等待自身释放。下载后校验并取得租约,持有至本地提交完成即可;不能用无平台会话的提交夹具替代完整导入链路验证。 - 后端有界分页与按 ID 读取由 #574 跟进,不作为 #549 客户端恢复可用的前置条件。当前合同与验证入口见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“账户 / 项目画布 / 本地素材导入”。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index df7af577b..d2ab76784 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -506,6 +506,7 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm ## 2026-08-25 账户 / 项目画布 / 本地素材导入 - 账户素材库总量不作为客户端查询、预览或按 ID 导入的拒绝条件。账户查询继续先读取后端快照,再按静态图片、文件夹与关键词筛选并分页;`offset` 接受可表示的非负整数,超过结果总数返回空页,`limit` 保持 1–100(默认 100),结果保留 `total / offset / limit / nextOffset`。Direct MCP 的关键词匹配素材名或文件夹名,Runtime 保留现有素材名匹配语义。 +- Runtime 下发给模型的 `asset.library.list` strict 原生函数 schema 必须与执行层分页边界一致;验收须校验 `build_agent_runtime_native_function_tools` 生成的完整函数参数,确认 `offset=501/600` 可用、负数和非整数偏移及 `limit>100` 被拒绝,同时保留可选字段的 `null` 语义。直接调用 observation 函数不能替代模型可见 schema 的验证;定向用例为 `account_asset_native_function_schema_accepts_later_pages`。 - 账户查询只把当前页安全元数据作为工具文本返回,不把全库作为附件交给 Agent。指定 ID 导入仍先读取账号快照再选取目标;单批数量、下载体积、账号/项目归属、会话、媒体类型和原子登记校验保持有效。绑定画布资源数量与项目文件发现的既有边界不随账户总量限制解除而放宽。无需后端 API、DTO、数据库或持久化迁移;全库传输与解析开销由后续 #574 的服务端分页及按 ID 读取处理,不阻塞 #549 的客户端修复。 - 平台账号导入在下载后重新校验会话并取得身份租约,租约覆盖本地写入、manifest 登记与 revision 推进;提交过程不得再次读取同一登录态互斥锁,避免在身份保护内自锁。身份变更仍须在取得租约时拒绝,取消写入许可仍不得写入。 - 验收覆盖 500/501 项账户快照、按文件夹/关键词查询、偏移超过 500 的后续页、越界空页和大库指定 ID 导入,并复核无效 ID/不支持媒体拒绝、安全投影、取消写入和账号切换。使用 `agent_asset_import_tests`、`account_asset`、`semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments`、`cancelled_import_write_permit`、`platform_session::tests` 定向 Rust 用例及提示词合同测试;模拟平台证据与真实账号验证分别记录。 -- 2.52.0