Compare commits

..

1 Commits

Author SHA1 Message Date
lhk229 0388fa0912 稳定CI测试并发与工作流
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
为Project CI增加同分支并发取消,避免旧运行覆盖新结果

将Vitest默认线程数固定为1,降低异步UI测试竞态
2026-09-17 00:32:01 +08:00
77 changed files with 9476 additions and 1079 deletions
@@ -957,6 +957,9 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
return result; return result;
} }
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
const swarmConfirmationPromptPattern = /输入 approve 或 reject$/u; const swarmConfirmationPromptPattern = /输入 approve 或 reject$/u;
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u; const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
File diff suppressed because one or more lines are too long
@@ -2,4 +2,8 @@
需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 需要等待专业 Agent 时不得调用 respond_to_userRuntime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
`project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
@@ -14,7 +14,7 @@ Let the client derive projections from real disk changes and trusted tool result
3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list.
4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization.
5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. 5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image.
6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. 6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state.
7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable.
8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand.
9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change.
@@ -14,4 +14,4 @@ Read scopes remain separate: `asset.list` is the current project's local manifes
`agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity.
`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. `agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response.
@@ -1,6 +1,6 @@
{ {
"schemaVersion": "agc-skill-pack.v1", "schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.17", "version": "2026-08-26.16",
"skills": [ "skills": [
{ {
"name": "agc-game-production-workflow", "name": "agc-game-production-workflow",
@@ -123,7 +123,7 @@
"agents/openai.yaml", "agents/openai.yaml",
"references/projection-contract.md" "references/projection-contract.md"
], ],
"sha256": "a929c27bc5b2b0bee0b7935e5c7b04ddbab1eb1804fe196f8c2537ad040ca5b1" "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6"
} }
] ]
} }
@@ -1005,10 +1005,15 @@ pub(crate) async fn continue_design_agent_at(
.ok_or("策划 Agent 当前正在工作")?; .ok_or("策划 Agent 当前正在工作")?;
let mut session = match read_design_session(root)? { let mut session = match read_design_session(root)? {
Some(session) => session, Some(session) => session,
None => new_design_session( None => {
&project_id, if read_planning_session_v2(root)?.is_some() {
&load_game_creator_app_config()?.selected_model_id, return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
), }
new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
)
}
}; };
if session.project_id != project_id { if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into()); return Err("策划会话与当前项目不匹配".into());
@@ -1887,7 +1887,7 @@ async fn bridge_create_or_derive_resource(
async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value {
let result = async { let result = async {
super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
enforce_project_permission_policy(&state.root, "asset.register")?; enforce_project_permission_policy(&state.root, "asset.register")?;
let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?;
@@ -1896,8 +1896,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
"assetName", "assetName",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS,
)?; )?;
let background_mode = arguments.get("backgroundMode").and_then(Value::as_str);
let screen_color = arguments.get("screenColor").and_then(Value::as_str);
let manifest = read_existing_manifest_for_project(&state.root)?; let manifest = read_existing_manifest_for_project(&state.root)?;
let source_asset = manifest let source_asset = manifest
.assets .assets
@@ -1922,34 +1920,22 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.map_err(|_| "创建抠图服务连接失败".to_string())?; .map_err(|_| "创建抠图服务连接失败".to_string())?;
let context = let context =
prepare_external_canvas_generation_context(&state.root, &client, &access).await?; prepare_external_canvas_generation_context(&state.root, &client, &access).await?;
let fingerprint = background_removal_request_fingerprint( let fingerprint = format!("{}\0{}", source_asset_id, asset_name);
&source_asset_id,
&asset_name,
background_mode,
screen_color,
);
let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
let route = "/api/external/v1/editor/images/background-removals"; let route = "/api/external/v1/editor/images/background-removals";
let mut request_body = json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
});
if background_mode == Some("flat") {
request_body["backgroundMode"] = json!("flat");
}
if let Some(color) = screen_color {
request_body["screenColor"] = json!(color);
}
let response = crate::http_client::with_agc_main_site_marker( let response = crate::http_client::with_agc_main_site_marker(
client client
.post(format!("{}{}", api_base_url, route)) .post(format!("{}{}", api_base_url, route))
.bearer_auth(api_key) .bearer_auth(api_key)
.header("Idempotency-Key", idempotency_key) .header("Idempotency-Key", idempotency_key)
.json(&request_body), .json(&json!({
"sourceImageSrc": source_resource_id,
"projectId": manifest.project_id,
"assetKind": source_asset.kind,
"assetFolderId": context.asset_folder_id,
"assetLabel": asset_name,
"sourceResourceId": source_resource_id,
})),
) )
.send() .send()
.await .await
@@ -1986,20 +1972,6 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
} }
} }
fn background_removal_request_fingerprint(
source: &str,
name: &str,
mode: Option<&str>,
color: Option<&str>,
) -> String {
let mode = mode.unwrap_or("complex");
if mode == "complex" && color.is_none() {
format!("{source}\0{name}")
} else {
format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or(""))
}
}
fn bridge_safe_queue_state(value: Value) -> Value { fn bridge_safe_queue_state(value: Value) -> Value {
let object = value.as_object(); let object = value.as_object();
json!({ json!({
@@ -2731,29 +2703,6 @@ pub(crate) async fn start_direct_tool_bridge(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test]
fn remove_background_identity_preserves_default_and_distinguishes_options() {
let legacy = "asset-1\0透明图";
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", None, None),
legacy
);
assert_eq!(
background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None),
legacy
);
let mut identities = std::collections::HashSet::new();
identities.insert(legacy.to_string());
for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] {
let id =
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color);
assert_eq!(
id,
background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color)
);
assert!(identities.insert(id));
}
}
use super::*; use super::*;
use std::io::{Cursor, Read, Write}; use std::io::{Cursor, Read, Write};
@@ -435,7 +435,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
}), }),
json!({ json!({
"name": "agc_remove_background", "name": "agc_remove_background",
"description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。", "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -449,16 +449,6 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS
},
"backgroundMode": {
"type": "string",
"enum": ["complex", "flat"],
"description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex"
},
"screenColor": {
"type": "string",
"pattern": "^(auto|#[0-9A-Fa-f]{6})$",
"description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测"
} }
}, },
"required": ["sourceLocalAssetId", "assetName"], "required": ["sourceLocalAssetId", "assetName"],
@@ -891,46 +881,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin
} }
} }
pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> {
validate_tool_object_fields( validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?;
arguments,
&[
"sourceLocalAssetId",
"assetName",
"backgroundMode",
"screenColor",
],
)?;
bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?;
bounded_tool_string( bounded_tool_string(
arguments, arguments,
"assetName", "assetName",
DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS,
)?; )?;
if let Some(mode) = arguments.get("backgroundMode") {
let mode = mode
.as_str()
.ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?;
if mode != "complex" && mode != "flat" {
return Err("backgroundMode 必须是 complex 或 flat".to_string());
}
}
if let Some(color) = arguments.get("screenColor") {
let color = color
.as_str()
.ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?;
let valid_hex = color.len() == 7
&& color.starts_with('#')
&& color[1..]
.chars()
.all(|character| character.is_ascii_hexdigit());
if color != "auto" && !valid_hex {
return Err("screenColor 必须是 auto 或 #RRGGBB".to_string());
}
if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") {
return Err("complex 模式不能传 screenColor".to_string());
}
}
Ok(()) Ok(())
} }
@@ -1891,51 +1849,6 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn remove_background_arguments_enforce_mode_color_contract() {
for fields in [
json!({}),
json!({"backgroundMode":"complex"}),
json!({"backgroundMode":"flat"}),
json!({"backgroundMode":"flat","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_ok(),
"{fields}"
);
}
for fields in [
json!({"screenColor":"auto"}),
json!({"backgroundMode":"complex","screenColor":"auto"}),
json!({"backgroundMode":"flat","screenColor":""}),
json!({"backgroundMode":"flat","screenColor":" auto "}),
json!({"backgroundMode":"flat","screenColor":"AUTO"}),
json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}),
json!({"backgroundMode":"flat","screenColor":null}),
json!({"backgroundMode":"flat","screenColor":12}),
json!({"backgroundMode":""}),
json!({"backgroundMode":"FLAT"}),
json!({"backgroundMode":" flat "}),
json!({"backgroundMode":null}),
] {
let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"});
arguments
.as_object_mut()
.unwrap()
.extend(fields.as_object().unwrap().clone());
assert!(
validate_remove_background_arguments(&arguments).is_err(),
"{fields}"
);
}
}
#[cfg(all(windows, feature = "cocos-editor-execute"))] #[cfg(all(windows, feature = "cocos-editor-execute"))]
#[test] #[test]
fn builtin_mcp_process_probe() { fn builtin_mcp_process_probe() {
@@ -549,7 +549,7 @@ fn append_agent_runtime_file_read_evidence_ref(
// 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用 // 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用
// 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期 // 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期
// 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次 // 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次
// 拒绝。验收证据只接受当前根 run 的回执, // 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执,
// 合法取值唯一,本来就不该让它猜。 // 合法取值唯一,本来就不该让它猜。
observation.summary = format!( observation.summary = format!(
"{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}", "{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}",
@@ -919,9 +919,9 @@ mod file_read_source_action_id_tests {
AgentRuntimeToolObservation { AgentRuntimeToolObservation {
tool: "file.read".to_string(), tool: "file.read".to_string(),
status: "ok".to_string(), status: "ok".to_string(),
summary: "已读取 design.md 第 1-134 行(共 134 行)".to_string(), summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(),
detail: Some(format!( detail: Some(format!(
"design.md · sha256={} · lines 1-134 of 134 "game/fast_gdd.md · sha256={} · lines 1-134 of 134
第一行内容", 第一行内容",
"a".repeat(64) "a".repeat(64)
)), )),
@@ -954,7 +954,7 @@ mod file_read_source_action_id_tests {
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation)
.expect("safe detail still parses"); .expect("safe detail still parses");
let value = serde_json::from_str::<serde_json::Value>(&safe_detail).expect("json"); let value = serde_json::from_str::<serde_json::Value>(&safe_detail).expect("json");
assert_eq!(value["path"], "design.md"); assert_eq!(value["path"], "game/fast_gdd.md");
assert_eq!(value["lines"], "1-134 of 134"); assert_eq!(value["lines"], "1-134 of 134");
assert_eq!(value["contentSha256"], "a".repeat(64)); assert_eq!(value["contentSha256"], "a".repeat(64));
} }
@@ -78,6 +78,12 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
matches!(kind.as_str(), "empty-response" | "deserialize") matches!(kind.as_str(), "empty-response" | "deserialize")
} }
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
}
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 /// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
@@ -7,6 +7,9 @@ mod design_session;
mod finalization; mod finalization;
mod json_sidecar; mod json_sidecar;
mod models; mod models;
mod planning_gdd_model;
mod planning_policy_v2;
mod planning_session_v2;
mod provider_control; mod provider_control;
mod provider_retry; mod provider_retry;
mod real_e2e_checkpoint; mod real_e2e_checkpoint;
@@ -21,6 +24,9 @@ pub(crate) use design_session::*;
pub(in crate::agent) use finalization::*; pub(in crate::agent) use finalization::*;
pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use json_sidecar::*;
pub(in crate::agent) use models::*; pub(in crate::agent) use models::*;
pub(crate) use planning_gdd_model::*;
pub(crate) use planning_policy_v2::*;
pub(crate) use planning_session_v2::*;
pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_control::*;
pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use provider_retry::*;
pub(in crate::agent) use real_e2e_checkpoint::*; pub(in crate::agent) use real_e2e_checkpoint::*;
@@ -781,7 +781,7 @@ mod provider_reconciliation_diagnostic_tests {
let directory = tempfile::tempdir().expect("diagnostic directory"); let directory = tempfile::tempdir().expect("diagnostic directory");
let snapshot = AgentRuntimeProviderRequestSnapshot { let snapshot = AgentRuntimeProviderRequestSnapshot {
project_id: "project-1".to_string(), project_id: "project-1".to_string(),
agent_id: "project-supervisor".to_string(), agent_id: "project-planning".to_string(),
task_id: "task-1".to_string(), task_id: "task-1".to_string(),
session_id: "session-1".to_string(), session_id: "session-1".to_string(),
run_id: "run-1".to_string(), run_id: "run-1".to_string(),
@@ -805,7 +805,7 @@ mod provider_reconciliation_diagnostic_tests {
usage: None, usage: None,
tool_calls: vec![platform_llm::LlmToolCall { tool_calls: vec![platform_llm::LlmToolCall {
id: "call-1".to_string(), id: "call-1".to_string(),
name: "runtime_tool_agent_message".to_string(), name: "runtime_tool_plan_submit_gdd".to_string(),
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(), arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
}], }],
responses_output: Vec::new(), responses_output: Vec::new(),
@@ -201,17 +201,39 @@ pub(crate) fn observe_agent_runtime_agent_message(
} }
} }
/// 委派 task 末尾那句「你在这条链路上的位置」。返工和普通委派的语义不同,单独保留。 /// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话
/// 就会说谎,所以这里把它显式化。
/// ///
/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是 /// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是
/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` / /// 做游戏链路的**授权信号**——`design-foundation` / `art-director` /
/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId /// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId
/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处 /// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处
/// 角色 prompt)。这一支逐字不能动。 /// 角色 prompt)。这一支逐字不能动。
/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是
/// `static_delegate_clarification_round_limit_at`plan 链路 3 轮)。master 只有一道
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带
/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮
/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就
/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就
/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是
/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。
/// - `None`:普通委派,不加这一段。 /// - `None`:普通委派,不加这一段。
pub(in crate::agent) enum StaticDelegateHopNote<'a> { pub(in crate::agent) enum StaticDelegateHopNote<'a> {
None, None,
Repair { original_delegation_id: &'a str }, Repair {
original_delegation_id: &'a str,
},
UserRevision {
original_delegation_id: &'a str,
},
PlanClarification {
original_delegation_id: &'a str,
rounds_used: u32,
rounds_limit: u32,
},
} }
impl StaticDelegateHopNote<'_> { impl StaticDelegateHopNote<'_> {
@@ -225,6 +247,32 @@ impl StaticDelegateHopNote<'_> {
StaticDelegateHopNote::Repair { StaticDelegateHopNote::Repair {
original_delegation_id, original_delegation_id,
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
StaticDelegateHopNote::UserRevision {
original_delegation_id,
} => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。"
),
// 预算用尽:planning_coordinator 出卡时会用
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} if rounds_used >= rounds_limit => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。"
),
// 轮号必须和 planning_coordinator 出卡时的期望一致:那边用
// `validate_exact_plan_clarification_question(.., current_round + 1)`
// current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。
StaticDelegateHopNote::PlanClarification {
original_delegation_id,
rounds_used,
rounds_limit,
} => format!(
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
next_round = rounds_used.saturating_add(1),
),
} }
} }
} }
@@ -1475,3 +1523,122 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated(
.map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)), .map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
/// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。
///
/// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan`
/// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」
/// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。
///
/// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent
/// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。
/// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header
/// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。
/// 用户修订轮同样不能套返工文案。
///
/// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身
/// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。
/// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板
/// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。
#[test]
fn user_revision_hop_note_is_not_the_repair_round_note() {
let revision = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::UserRevision {
original_delegation_id: "delegation-old",
},
)
.expect("render user revision hop note");
assert!(
!revision.contains("唯一返工轮"),
"用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}"
);
assert!(
revision.contains("不消耗返工深度"),
"必须写明它不吃返工额度:{revision}"
);
assert!(
revision.contains("不是最后一轮"),
"必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}"
);
}
#[test]
fn plan_clarification_hop_note_is_not_the_repair_round_note() {
let repair = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付产物".to_string()],
&["assets/art-spec.png".to_string()],
StaticDelegateHopNote::Repair {
original_delegation_id: "delegation-old",
},
)
.expect("render repair");
assert!(
repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"),
"返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}"
);
let clarification = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 1,
rounds_limit: 3,
},
)
.expect("render clarification");
assert!(
!clarification.contains("唯一返工轮"),
"澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}"
);
assert!(
clarification.contains("已用澄清轮次 1/3"),
"澄清续跑必须写明已用轮次与上限:{clarification}"
);
assert!(
clarification.contains("第2轮·当前要决定:"),
"task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1{clarification}"
);
let exhausted = render_static_delegate_task_contract(
"任务",
"project-supervisor",
"run-1",
"delegation-new",
&["交付 game/fast_gdd.md".to_string()],
&["game/fast_gdd.md".to_string()],
StaticDelegateHopNote::PlanClarification {
original_delegation_id: "delegation-old",
rounds_used: 3,
rounds_limit: 3,
},
)
.expect("render exhausted clarification");
assert!(
exhausted.contains("澄清预算已用尽"),
"预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}"
);
assert!(
!exhausted.contains("第4轮·当前要决定:"),
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
);
}
}
@@ -10,6 +10,18 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block(
if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) { if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) {
return None; return None;
} }
if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) {
return Some(AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "blocked".to_string(),
summary: if is_agent_planning_storage_path(path) {
"`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string()
} else {
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string()
},
detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")),
});
}
match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) {
Ok(true) => { Ok(true) => {
let allowed = autonomous_manifest_owner_artifact_paths(agent_id); let allowed = autonomous_manifest_owner_artifact_paths(agent_id);
@@ -275,7 +275,8 @@ fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegis
/// ///
/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是 /// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是
/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实 /// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实
/// `agentId`,否则按身份收窄的工具面会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」 /// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact
/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
/// 从运行时静默扩权变成编译期错误。 /// 从运行时静默扩权变成编译期错误。
#[cfg(test)] #[cfg(test)]
pub(crate) fn build_agent_runtime_native_function_tools() -> Result<Vec<LlmFunctionTool>, String> { pub(crate) fn build_agent_runtime_native_function_tools() -> Result<Vec<LlmFunctionTool>, String> {
@@ -879,6 +879,104 @@ pub(crate) fn validated_local_project_directory_path(
Ok(path.to_path_buf()) Ok(path.to_path_buf())
} }
/// Open the approved Fast GDD Markdown in whatever application the OS has
/// registered for it.
///
/// The GDD is the one product artifact the 立项策划 lane hands back, and it is
/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the
/// approval receipt re-renders it with the approved header. This command only
/// hands that existing path to the shell; it never creates or rewrites it.
#[tauri::command]
pub(crate) fn open_local_project_plan_gdd_markdown(
app: tauri::AppHandle,
project_path: String,
) -> Result<(), String> {
let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?;
app.opener()
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
.map_err(|error| format!("打开 Fast GDD 文件失败:{error}"))
}
pub(crate) fn validated_local_project_plan_gdd_markdown_path(
project_path: &str,
) -> Result<PathBuf, String> {
let root = validated_local_project_directory_path(project_path)?;
// `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径
// 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧
// `planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是
// 同一个判定,不能一边解析一边拼字符串。
let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?;
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_file() => Ok(path),
Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string())
}
Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")),
}
}
#[cfg(test)]
mod plan_gdd_markdown_path_tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let temporary = tempfile::tempdir().expect("create GDD path fixture");
crate::project::init_local_game_project_at(
&temporary.path().join("project"),
"gdd-open",
"打开 GDD 产物",
)
.expect("initialize GDD path fixture");
temporary
}
#[test]
fn resolves_the_rendered_markdown_under_the_project_root() {
let temporary = fixture();
let root = temporary.path().join("project");
fs::create_dir_all(root.join("game")).expect("create game directory");
fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown");
let resolved =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect("resolve rendered markdown");
assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH));
}
#[test]
fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() {
// 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出
// 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。
let temporary = fixture();
let root = temporary.path().join("project");
let error =
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
.expect_err("missing markdown must fail closed");
assert!(error.contains("尚未生成"), "unexpected error: {error}");
}
#[test]
fn refuses_a_project_path_that_is_not_an_initialized_project() {
let temporary = tempfile::tempdir().expect("create bare fixture");
let error = validated_local_project_plan_gdd_markdown_path(
&temporary.path().to_string_lossy().into_owned(),
)
.expect_err("a directory without .agent is not a project root");
assert!(!error.is_empty());
}
#[test]
fn refuses_a_relative_project_path() {
let error = validated_local_project_plan_gdd_markdown_path("relative/project")
.expect_err("relative project path must fail");
assert!(error.contains("绝对路径"), "unexpected error: {error}");
}
}
#[tauri::command] #[tauri::command]
pub(crate) async fn get_local_game_manifest( pub(crate) async fn get_local_game_manifest(
project_path: String, project_path: String,
@@ -1756,8 +1756,8 @@ pub(crate) fn build_static_delegate_structured_result_at(
// (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父 // (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父
// Supervisor 直接进 needs-reconciliation 停下等人。 // Supervisor 直接进 needs-reconciliation 停下等人。
// //
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」 // 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和
// 降级成 needs-repair 并把解析错误当返工理由 // plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由
// 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。 // 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。
let (user_input_questions, user_input_questions_sha256, user_input_parse_error) = let (user_input_questions, user_input_questions_sha256, user_input_parse_error) =
match parse_static_delegate_user_input_request(error) { match parse_static_delegate_user_input_request(error) {
@@ -3426,11 +3426,11 @@ mod tests {
assert!(failed.contains("客观证据冲突")); assert!(failed.contains("客观证据冲突"));
let mut missing = base.clone(); let mut missing = base.clone();
missing.missing_expected_artifacts = vec!["design.md".to_string()]; missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()];
let missing_error = validate_static_delegate_structured_result( let missing_error = validate_static_delegate_structured_result(
&missing, &missing,
"completed", "completed",
&["design.md".to_string()], &["game/fast_gdd.md".to_string()],
) )
.expect_err("missing expected artifact must reject a user revision result"); .expect_err("missing expected artifact must reject a user revision result");
assert!(missing_error.contains("客观证据冲突")); assert!(missing_error.contains("客观证据冲突"));
@@ -2688,6 +2688,7 @@ fn main() {
read_agc_plugin_panel, read_agc_plugin_panel,
set_agc_plugin_project_path, set_agc_plugin_project_path,
open_local_project_directory, open_local_project_directory,
open_local_project_plan_gdd_markdown,
control_agent_run, control_agent_run,
generate_local_game_draft, generate_local_game_draft,
chat_with_game_creator_agent, chat_with_game_creator_agent,
@@ -2696,6 +2697,10 @@ fn main() {
chat_with_game_creator_direct_codex, chat_with_game_creator_direct_codex,
cancel_direct_codex_turn, cancel_direct_codex_turn,
select_game_creator_reasoning_effort, select_game_creator_reasoning_effort,
start_planning_session_v2,
continue_planning_session_v2,
decide_planning_artifact_v2,
hydrate_planning_session_v2,
hydrate_design_agent_session, hydrate_design_agent_session,
reset_design_agent_session, reset_design_agent_session,
get_design_agent_runtime_mode, get_design_agent_runtime_mode,
@@ -7,7 +7,9 @@ use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use unicode_normalization::UnicodeNormalization; use unicode_normalization::UnicodeNormalization;
use crate::project::{normalize_relative_path, validate_project_root}; use crate::project::{
is_plan_fast_gdd_projection_path, normalize_relative_path, validate_project_root,
};
const PROJECT_PATCHSET_MAX_CHANGES: usize = 12; const PROJECT_PATCHSET_MAX_CHANGES: usize = 12;
const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024; const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024;
@@ -464,6 +466,11 @@ fn normalize_and_validate_patchset_inputs(
let path = normalize_relative_path(change.path())?; let path = normalize_relative_path(change.path())?;
reject_sensitive_patchset_path(&path)?; reject_sensitive_patchset_path(&path)?;
if is_plan_fast_gdd_projection_path(&path) {
return Err(
"project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(),
);
}
let change = match change { let change = match change {
ParsedProjectPatchsetChange::Create { content, .. } => { ParsedProjectPatchsetChange::Create { content, .. } => {
validate_input_text(&content, &path)?; validate_input_text(&content, &path)?;
@@ -1365,6 +1372,11 @@ fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool {
} }
fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> { fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> {
if is_plan_fast_gdd_projection_path(relative_path) {
return Err(
"project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(),
);
}
let components = relative_path let components = relative_path
.split('/') .split('/')
.map(str::to_ascii_lowercase) .map(str::to_ascii_lowercase)
@@ -1694,6 +1706,8 @@ mod tests {
for path in [ for path in [
".agent/runtime/state.json", ".agent/runtime/state.json",
".agent/planning/gdd.v1.json",
"game/fast_gdd.md",
".env.local", ".env.local",
"config/private.pem", "config/private.pem",
"data/runtime.sqlite", "data/runtime.sqlite",
@@ -674,6 +674,7 @@ pub(crate) fn restore_local_project_checkpoint_at(
pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool {
should_skip_project_snapshot_path(relative_path) should_skip_project_snapshot_path(relative_path)
|| is_plan_fast_gdd_projection_path(relative_path)
|| relative_path == ".agent/agent.db" || relative_path == ".agent/agent.db"
|| relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_PERMISSION_POLICY_PATH
|| relative_path == PROJECT_WRITE_LOCK_PATH || relative_path == PROJECT_WRITE_LOCK_PATH
@@ -30,6 +30,7 @@ pub(crate) fn list_local_project_files_at(
if is_agent_runtime_private_control_path(&relative_path) if is_agent_runtime_private_control_path(&relative_path)
|| is_agent_checkpoint_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path)
|| is_agent_workbench_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path)
|| is_agent_planning_storage_path(&relative_path)
{ {
continue; continue;
} }
@@ -126,6 +127,56 @@ pub(crate) fn reject_agent_runtime_private_control_path(
Ok(()) Ok(())
} }
/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by
/// the narrow planning read tools, but generic project mutation helpers must
/// never be able to create, replace, patch, or delete it. Keeping this gate
/// separate from `reject_agent_runtime_private_control_path` is deliberate:
/// the planning Agent needs `file.read`/`file.list` observations while its
/// durable writer is still the only component allowed to mutate the sidecar.
pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool {
let normalized_path = normalized_path.to_ascii_lowercase();
normalized_path == ".agent/planning"
|| normalized_path.starts_with(".agent/planning/")
|| normalized_path == ".agent/planning-v2"
|| normalized_path.starts_with(".agent/planning-v2/")
}
pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool {
is_agent_planning_storage_path(normalized_path)
|| is_plan_fast_gdd_projection_path(normalized_path)
}
pub(crate) fn reject_agent_planning_storage_write_path(
normalized_path: &str,
) -> Result<(), String> {
if is_agent_planning_managed_write_path(normalized_path) {
return Err(
"`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝"
.to_string(),
);
}
Ok(())
}
/// `game/fast_gdd.md` is the human-readable planning projection. It lives
/// outside `.agent/planning`, but it is still Runtime-owned and must not be
/// mutated by generic file tools. Keep this predicate write-only so planning
/// observations can continue to read the projection.
pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool {
normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH)
}
pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> {
reject_agent_planning_storage_write_path(normalized_path)?;
if is_plan_fast_gdd_projection_path(normalized_path) {
return Err(
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝"
.to_string(),
);
}
Ok(())
}
fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> {
if matches!( if matches!(
normalized_path.split('/').next(), normalized_path.split('/').next(),
@@ -162,6 +213,7 @@ pub(crate) fn write_local_project_file_at(
) -> Result<LocalProjectFileMutationResult, String> { ) -> Result<LocalProjectFileMutationResult, String> {
let normalized_path = normalize_relative_path(relative_path)?; let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?; reject_agent_runtime_private_control_path(&normalized_path)?;
reject_plan_projection_write_path(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?;
if path.exists() && !path.is_file() { if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string()); return Err("只能写入文件".to_string());
@@ -181,6 +233,7 @@ pub(crate) fn delete_local_project_file_at(
) -> Result<LocalProjectFileMutationResult, String> { ) -> Result<LocalProjectFileMutationResult, String> {
let normalized_path = normalize_relative_path(relative_path)?; let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?; reject_agent_runtime_private_control_path(&normalized_path)?;
reject_plan_projection_write_path(&normalized_path)?;
reject_agent_control_path_delete(&normalized_path)?; reject_agent_control_path_delete(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?;
if !path.exists() { if !path.exists() {

Some files were not shown because too many files have changed in this diff Show More