Fix Issue 549 (#573)
Project CI / AI game creator shell Rust crates (push) Successful in 2m16s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m39s
Project CI / Backend tests (push) Successful in 5m39s
Project CI / Native shell tests (push) Successful in 6m3s
Project CI / Frontend tests (push) Successful in 1m49s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m50s
Project CI / Repository checks (push) Successful in 2m15s
Project CI / AI game creator shell web tests (push) Successful in 1m40s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 6m18s

账户素材超过 500 项时,AGC 曾在筛选和分页前直接拒绝整个素材库,导致查询、预览和指定 ID 导入不可用。本 PR 移除账户素材总量及账户分页 offset=500 的客户端限制,允许大库继续使用现有客户端筛选分页。

Closes #549

## 实现范围

- 移除账户素材库原始条数超过 500 的拒绝,以及 MCP schema、桥接、Runtime 执行层和原生函数 strict schema 的账户分页 offset 上限。
- 保留单页最多 100 项、导入批次数量与下载体积、媒体类型、账号归属、权限及会话校验。项目文件查询的 offset 上限和绑定项目画布资源的数量上限保持原有契约。
- 账户素材 UI 列表、预览及指定 ID 导入同步恢复可用。
- 修复真实账户导入时,在持有身份租约期间重复读取会话互斥锁造成的自锁;写入前校验和覆盖写入过程的身份租约继续保留。
- 同步工具说明、技术方案与排障记录。服务端仍返回全库,传输和解析开销另由 #574 跟进,不作为本 PR 的验收前置条件。

## 验收

以下已完成项表示本地自动化与代码核查通过,不代表线上真实账号验收。

- [x] 覆盖 500/501 项素材库解析;503 张静态图片及 1 项不支持媒体的模拟素材库可正常工作。
- [x] MCP 查询支持 limit=1、folderId、query 和 offset>500;nextOffset、末页、越界空页及默认每页 100 项符合现有契约。
- [x] 直接调用 Runtime 执行层时 offset=501 可查询;MCP schema 与参数校验一致,仍拒绝负数、非整数 offset 和 limit>100。这项不覆盖 Runtime 下发给模型的原生函数 schema。
- [x] Runtime 原生函数 asset.library.list 的 strict schema 允许 offset>500;实际下发的完整函数参数已验证 offset=501/600/10000、null 可用,负数/非整数偏移及超界页长拒绝。补丁已提交并推送:41e2cc0b5。
- [x] 共用读取链路的 UI 素材列表和预览在大库场景可用。
- [x] 指定有效 assetId 完成实际下载、本地文件写入及 manifest/revision 更新;重复导入保持幂等。
- [x] 模拟素材库中的不存在 ID 和不支持媒体被拒绝,且不改变 manifest;返回 Agent 的当前页元数据不泄露媒体地址、对象键或凭据。
- [x] 会话退出、账号切换、凭据刷新、身份租约和取消写入相关定向测试通过。
- [x] 工具说明、技术方案和共享排障文档已同步;项目文件与画布资源原有独立边界已核对保留。
- [ ] 真实平台账号联调:超过 500 项素材时查询、预览和导入,以及实际删除、跨账号、无权限素材的拒绝行为。

## 验证证据

- 账户素材、导入、参数校验、项目文件、取消写入与平台会话定向 Rust 测试:33 项通过。
- runtime_prompt_bundle_build:17 项通过;prompt_source_boundaries:6 项通过。合计 56 项通过。
- Rust 格式检查、npm run check:encoding、npm run check:doc-index、git diff --check 通过。
- 以上 Rust 测试在快进 master 前运行;快进至 44f415d05 后已核对本次代码及工具文本与已测试版本一致,并再次通过编码、文档索引、差异检查和提交时 Rust 格式检查。
- 未运行全量测试,未做真实线上账号或付费操作验证。

初次修复提交:697f421e570606d0519f952a0a5689b5f639f902;原生函数 schema 审查补丁:41e2cc0b57b7f9f5faf06546c1f6108e88380ac3(已推送)。
- 审查补丁验证:24 项定向 Rust 测试通过,覆盖全部 agent_native_tools 测试、大库查询/导入和修正后的旧流式结果测试;格式、编码、文档索引、差异检查、提交及推送检查通过。本轮未重复全量测试,未做真实模型或线上账号调用。

Reviewed-on: #573
This commit was merged in pull request #573.
This commit is contained in:
2026-10-01 20:55:28 +08:00
parent d74b2c003c
commit ded9f13f02
9 changed files with 334 additions and 36 deletions
@@ -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",
@@ -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 交付最终回复。",
@@ -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
@@ -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::<Vec<_>>();
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<String, String>,
>| {
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() {
@@ -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,
};
}
@@ -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");
@@ -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;
@@ -1731,6 +1731,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::<Vec<_>>();
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!({
@@ -2377,13 +2404,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 {
@@ -2474,10 +2494,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
));
}
@@ -3481,18 +3501,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<u8>)>,
write_permit: Option<&crate::agent::WritePermit>,
) -> Result<RemoteImportResult, String> {
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 {
@@ -3611,11 +3630,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(),
@@ -15,6 +15,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 执行层及 `agent_native_tools.rs` 下发的 strict 原生函数 schema;仅直接调用 observation 会绕过模型侧参数约束,无法证明后续页可达,必须验证实际生成的函数参数 schema。共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。
- 实际平台会话导入还需避免在身份租约内再次调用会话读取/校验:租约持有非重入互斥锁,提交 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 分钟即失败)。
@@ -505,6 +505,11 @@ 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 用例及提示词合同测试;模拟平台证据与真实账号验证分别记录。
- 素材读取区分三类来源:`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 原生文件选择器授权后导入,不开放任意绝对路径。