合并 origin/master(真合并:AGC 统一错误诊断 / 图集连通域切分)
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m31s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 5m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 5m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m18s
Project CI / Native shell tests (pull_request) Failing after 2m14s
Project CI / Repository checks (pull_request) Failing after 1m17s
Project CI / AI game creator shell web tests (pull_request) Failing after 51s
Project CI / Frontend tests (pull_request) Failing after 3m26s
Project CI / Backend tests (pull_request) Successful in 7m9s

- canvas_generation.rs:接入 master 新增的 slice_mode / grid_x / grid_y 结构体字段
- commands.rs:master 的 read_agent_runtime_error_detail 命令与本分支 read_direct_tool_calls 并存(两侧都保留)
- main.rs:两条命令都注册
- 冲突按"两侧语义都保住"处理;未跑测试
This commit is contained in:
2026-09-15 20:51:19 +08:00
36 changed files with 1000 additions and 142 deletions
@@ -32,6 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on
- Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access.
- Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either.
- Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent.
- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output.
- For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs.
- Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record.
@@ -52,7 +52,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceMode`, `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` |
| Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
| Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
@@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project
The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL.
`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets.
`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result.
## Common Values
@@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system
1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context.
2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop.
3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default).
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet `<img>`, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation.
Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG.
@@ -881,12 +881,14 @@ def _self_test() -> None:
["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"],
canvasSession=session,
assetLabel="贪吃蛇透明图集",
sliceMode="connected-components",
referenceId="must-not-override-explicit-reference",
iconDescriptions=["不得覆盖显式图标描述"],
)
assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations"
assert calls[0]["body"]["referenceId"] == "editor-resource-spec"
assert calls[0]["body"]["screenColor"] == "auto"
assert calls[0]["body"]["sliceMode"] == "connected-components"
assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上"
assert calls[1]["path"] == "/api/external/v1/generations/task-operation-demo"
print("self-test ok")
@@ -19,6 +19,12 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices.
When `agc_generate_image` is used with `kind="art-spritesheet"`, pass
`sliceMode="connected-components"` (the default alpha-connectivity splitter)
or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried
through the client request and returned result; do not infer it from the number
of slices.
## Authorization boundary
`agc_tools` is an AGC client-owned bridge to the AGC backend. In the normal client build it uses the current client login session and account routes; the user and model never need to provide, configure, paste, create, or rotate an API Key, Token, Cookie, URL, or `.env` value. If the tool returns `401` or `403`, report only that the AGC client login or permission state is unavailable, stop the operation, and do not ask the user for credentials or expose an internal URL.
@@ -15,6 +15,7 @@
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
- `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent.
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata.
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
- `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities.
- `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys.
@@ -29,6 +29,7 @@ mod prompt;
mod runtime_actions;
mod runtime_adapter;
mod runtime_driver;
mod runtime_error;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
@@ -59,6 +60,7 @@ pub(crate) use prompt::*;
pub(crate) use runtime_actions::*;
pub(crate) use runtime_adapter::*;
pub(crate) use runtime_driver::*;
pub(crate) use runtime_error::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
@@ -277,6 +277,51 @@ fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmErro
))
}
fn game_creator_codex_app_server_error_kind_with_machine_detail(
kind: &str,
error: &serde_json::Value,
) -> platform_llm::LlmError {
let mut fields = Vec::new();
if let Some(object) = error.as_object() {
if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) {
if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
{
fields.push(format!("code={code}"));
}
}
let keys = object
.keys()
.filter(|key| {
matches!(
key.as_str(),
"httpConnectionFailed"
| "responseStreamConnectionFailed"
| "responseStreamDisconnected"
| "responseTooManyFailedAttempts"
| "activeTurnNotSteerable"
| "codexErrorInfo"
)
})
.cloned()
.collect::<Vec<_>>();
if !keys.is_empty() {
fields.push(format!("fields={}", keys.join(",")));
}
}
let suffix = if fields.is_empty() {
String::new()
} else {
format!(" detail={}", fields.join(" "))
};
platform_llm::LlmError::InvalidRequest(format!(
"{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}{suffix}"
))
}
fn game_creator_codex_app_server_error_http_status(
info: &serde_json::Value,
field: &str,
@@ -431,7 +476,7 @@ fn game_creator_codex_app_server_failed_turn_error(
return game_creator_codex_app_server_error_kind("unauthorized");
}
let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else {
return game_creator_codex_app_server_error_kind("other");
return game_creator_codex_app_server_error_kind_with_machine_detail("other", error);
};
if let Some(kind) = info.as_str() {
return match kind {
@@ -455,8 +500,8 @@ fn game_creator_codex_app_server_failed_turn_error(
game_creator_codex_app_server_error_kind("thread-rollback-failed")
}
"sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
"other" => game_creator_codex_app_server_error_kind("other"),
_ => game_creator_codex_app_server_error_kind("other"),
"other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
_ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
};
}
for field in [
@@ -472,7 +517,7 @@ fn game_creator_codex_app_server_failed_turn_error(
if info.get("activeTurnNotSteerable").is_some() {
return game_creator_codex_app_server_error_kind("active-turn-not-steerable");
}
game_creator_codex_app_server_error_kind("other")
game_creator_codex_app_server_error_kind_with_machine_detail("other", error)
}
async fn isolate_game_creator_codex_app_server_terminal_unknown(
@@ -549,6 +549,7 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value {
}
"agc_generate_image" => {
copy_string(object, "kind", &mut out);
copy_string(object, "sliceMode", &mut out);
copy_string(object, "aspectRatio", &mut out);
copy_string(object, "imageSize", &mut out);
copy_string(object, "assetName", &mut out);
@@ -2029,7 +2029,11 @@ fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
|| normalized.contains("insufficient-mud-points")
}
fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String {
fn record_direct_codex_turn_failure(
root: &Path,
failure: DirectCodexTurnFailure,
client_turn_id: Option<&str>,
) -> String {
let summary = direct_codex_failure_public_summary(&failure.error)
.map(str::to_string)
.unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320));
@@ -2066,18 +2070,53 @@ fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure
} else {
"未能保存项目诊断"
};
format!(
"direct-codex-failure:v1 stage={} retryable={} summary={};建议:{}{}",
let error_code = classify_direct_codex_error(&failure.error);
let unified_detail_ref = persist_agent_runtime_error(
root,
client_turn_id,
"direct-codex",
failure.stage.id(),
error_code,
retryable,
&summary,
recovery_hint,
&failure.error,
None,
serde_json::json!({
"legacyDiagnosticWritten": diagnostic_written,
}),
)
.ok()
.map(|event| event.detail_ref);
format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{}{}{}",
failure.stage.id(),
error_code,
retryable,
diagnostic["summary"]
.as_str()
.unwrap_or("未提供可安全展示的详细原因"),
recovery_hint,
diagnostics_suffix,
unified_detail_ref
.map(|path| format!(";详情:{path}"))
.unwrap_or_default(),
)
}
fn persist_direct_codex_failure_context(
root: &Path,
client_turn_id: &str,
error: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
error,
Some(&format!("direct-codex:{client_turn_id}:failure")),
)?;
append_direct_project_history_item_at(root, &item)
}
fn direct_taonier_art_generation_runtime_context(
root: &Path,
output_path: &str,
@@ -2393,9 +2432,19 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
}
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
let sources = direct_codex_game_outputs(root)
let mut source_paths = direct_codex_game_outputs(root)
.into_iter()
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
.map(|(relative_path, _, _)| relative_path)
.collect::<Vec<_>>();
// npm/Phaser projects put the actual scene and loader code below `game/src`.
// Keep the canonical output list for manifest projection, but scan the
// complete bounded source list for the asset reference contract.
source_paths.extend(direct_npm_source_paths(root));
source_paths.sort();
source_paths.dedup();
let sources = source_paths
.into_iter()
.filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok())
.collect::<Vec<_>>();
let mut available_paths = Vec::new();
if direct_taonier_art_base_is_valid(root) {
@@ -2408,6 +2457,28 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH.to_string());
}
available_paths.extend(direct_registered_taonier_slice_paths(root));
// A project may have a valid, client-registered art-spritesheet at a
// project-specific path (for example a generated building sheet). The
// fixed canonical package paths above are compatibility candidates only;
// the manifest is the authority for additional runtime image identities.
if let Ok(manifest) = read_manifest_for_project(root) {
available_paths.extend(
manifest
.assets
.into_iter()
.filter(|asset| {
matches!(
asset.kind.as_str(),
"art-spritesheet" | "art-spritesheet-slice" | "game-background"
) && asset.media_type == "image/png"
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& asset.local_path.starts_with("assets/")
})
.map(|asset| asset.local_path),
);
}
available_paths.sort();
available_paths.dedup();
available_paths
.into_iter()
.filter(|path| sources.iter().any(|source| source.contains(path.as_str())))
@@ -2907,6 +2978,9 @@ async fn generate_direct_taonier_art_asset_at(
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -4208,7 +4282,17 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
{
Ok(reply) => Ok(reply),
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
let error = record_direct_codex_turn_failure(
root,
failure,
turn_emitter.map(|emitter| emitter.turn_id()),
);
if let Some(emitter) = turn_emitter {
// Persist the safe terminal projection so the next DirectProject
// turn can answer a diagnostic question from evidence instead of
// guessing or starting another playtest.
let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error);
}
if let Some(emitter) = turn_emitter {
emitter.emit("failed", Some("none"), None, None);
}
@@ -6937,26 +7021,27 @@ mod tests {
#[test]
fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret",
),
None,
);
assert!(error
.starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary="));
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
assert!(!error.contains("?token=secret"), "{error}");
assert!(!error.contains("provider.example"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -6975,25 +7060,25 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let history_path = root
.path()
.join(".agent/conversations/project.jsonl")
.display()
.to_string();
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::CodeGeneration,
format!("DirectProject 历史记录类型无效:{history_path}"),
),
None,
);
assert!(
error.starts_with(
"direct-codex-failure:v1 stage=code-generation retryable=false summary="
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
),
"{error}"
);
@@ -7003,9 +7088,9 @@ mod tests {
),
"{error}"
);
assert!(error.ends_with("已保存脱敏项目诊断"), "{error}");
assert!(error.contains("已保存脱敏项目诊断"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -7019,19 +7104,20 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复",
),
None,
);
assert!(
error.contains("stage=art-preparation retryable=false"),
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
@@ -7039,15 +7125,16 @@ mod tests {
#[test]
fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据",
),
None,
);
assert!(
@@ -8147,6 +8234,40 @@ mod tests {
.any(|warning| warning.contains("不得猜测切片")));
}
#[test]
fn direct_completion_scans_npm_scene_modules_for_registered_asset_references() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-src-runtime", "源码模块素材引用")
.expect("init project");
register_direct_taonier_art_package_fixture(&root);
register_direct_taonier_art_slice_entries_fixture(&root);
std::fs::write(
root.join("game/package.json"),
"{\"scripts\":{\"build\":\"vite build\"}}",
)
.expect("package");
std::fs::write(root.join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.join("game/style.css"), "body {}").expect("style");
std::fs::write(root.join("game/game.js"), "import './src/scene.js';").expect("entry");
std::fs::create_dir_all(root.join("game/src")).expect("src dir");
std::fs::write(
root.join("game/src/scene.js"),
"const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';",
)
.expect("scene");
std::fs::write(
root.join("assets/art-spritesheet-slices/player.png"),
tiny_opaque_png(),
)
.expect("slice");
assert_eq!(
direct_game_sources_referenced_taonier_assets(&root),
vec!["assets/art-spritesheet-slices/player.png".to_string()]
);
}
#[test]
fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1078,7 +1078,9 @@ fn bridge_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -2104,6 +2106,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
"imageSize",
"assetName",
"outputPath",
"sliceMode",
"gridX",
"gridY",
],
)?;
enforce_project_permission_policy(&state.root, "canvas.asset_generate")?;
@@ -2145,6 +2150,44 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
.transpose()?
.unwrap_or_else(|| "AI 生成图片".to_string());
let output_path = bridge_optional_bounded_string(arguments, "outputPath", 512)?;
let slice_mode = arguments
.get("sliceMode")
.map(|_| bridge_bounded_string(arguments, "sliceMode", 32))
.transpose()?;
if slice_mode
.as_deref()
.is_some_and(|mode| !matches!(mode, "connected-components" | "grid"))
{
return Err("工具参数 sliceMode 只允许 connected-components 或 grid".to_string());
}
let grid_x = arguments
.get("gridX")
.map(|_| {
arguments
.get("gridX")
.and_then(Value::as_u64)
.map(|value| value as u32)
.ok_or_else(|| "工具参数 gridX 必须是整数".to_string())
})
.transpose()?;
let grid_y = arguments
.get("gridY")
.map(|_| {
arguments
.get("gridY")
.and_then(Value::as_u64)
.map(|value| value as u32)
.ok_or_else(|| "工具参数 gridY 必须是整数".to_string())
})
.transpose()?;
if slice_mode.as_deref() == Some("grid") && (grid_x.is_none() || grid_y.is_none()) {
return Err("grid 模式必须同时提供 gridX 与 gridY".to_string());
}
if grid_x.is_some_and(|value| !(1..=32).contains(&value))
|| grid_y.is_some_and(|value| !(1..=32).contains(&value))
{
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
}
let options = PlatformArtAssetGenerationOptions {
output_path,
aspect_ratio,
@@ -2153,6 +2196,9 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
slice_mode,
grid_x,
grid_y,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2564,6 +2610,30 @@ async fn handle_direct_tool_bridge(
}
_ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true),
};
if result.get("isError").and_then(Value::as_bool) == Some(true) {
let message = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or("客户端工具执行失败");
let code = if message.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else {
"tool-error"
};
let _ = persist_agent_runtime_error(
&state.root,
None,
"agc-tools",
"tool-execution",
code,
true,
message,
"查看项目错误诊断后处理",
message,
None,
serde_json::json!({"tool": request.tool}),
);
}
Json(result)
}
@@ -244,6 +244,24 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string",
"maxLength": 512,
"description": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件"
},
"sliceMode": {
"type": "string",
"enum": ["connected-components", "grid"],
"default": "connected-components",
"description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式横向网格数量"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式纵向网格数量"
}
},
"required": ["prompt"],
@@ -880,7 +898,9 @@ fn tool_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -1014,6 +1034,9 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
"imageSize",
"assetName",
"outputPath",
"sliceMode",
"gridX",
"gridY",
],
) {
return mcp_tool_result(error, Vec::new(), true);
@@ -1041,6 +1064,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value {
("imageSize", 4),
("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS),
("outputPath", 512),
("sliceMode", 32),
] {
if arguments.get(field).is_some() {
if let Err(error) = bounded_tool_string(arguments, field, max_chars) {
@@ -2226,6 +2250,10 @@ mod tests {
assert!(image_tool["description"]
.as_str()
.is_some_and(|description| description.contains("不是本工具的限制")));
assert_eq!(
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
json!(["connected-components", "grid"])
);
let edit_tool = specs["tools"]
.as_array()
.expect("tool array")
@@ -413,6 +413,9 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) asset_label: String,
pub(crate) replace_existing: bool,
pub(crate) slice_count: Option<usize>,
pub(crate) slice_mode: Option<String>,
pub(crate) grid_x: Option<u32>,
pub(crate) grid_y: Option<u32>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -425,6 +428,9 @@ impl Default for PlatformArtAssetGenerationOptions {
asset_label: "AI 游戏首版美术素材".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
}
}
}
@@ -1574,7 +1580,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
warning: Option<String>,
slice_warning: Option<String>,
slices: Vec<PreparedPlatformArtAssetSlice>,
spritesheet_slice_layout: Option<String>,
spritesheet_slice_mode: Option<String>,
generation_route: String,
generation_kind: String,
reference_resource_ids: Vec<String>,
@@ -2213,11 +2219,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
/// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个
/// 动作,必须各自独立成槽,才能在同一项目里同时在途。
///
/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的
/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被
/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。
/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到
/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。
/// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集
/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
@@ -2229,6 +2232,9 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> {
asset_label: &'a str,
replace_existing: bool,
require_slices: bool,
slice_mode: Option<&'a str>,
grid_x: Option<u32>,
grid_y: Option<u32>,
}
/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`
@@ -2265,6 +2271,9 @@ fn standalone_platform_art_generation_runtime_context(
asset_label: &options.asset_label,
replace_existing: options.replace_existing,
require_slices,
slice_mode: options.slice_mode.as_deref(),
grid_x: options.grid_x,
grid_y: options.grid_y,
})
.map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?;
let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes));
@@ -2811,6 +2820,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"sliceCount": options.slice_count,
"sliceMode": options.slice_mode,
"gridX": options.grid_x,
"gridY": options.grid_y,
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -3106,8 +3118,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
Vec::new()
};
let warning = platform_art_generation_warning(generated);
let spritesheet_slice_layout = if is_canonical_art_spritesheet {
json_string_field(generated, "sliceLayout")
let spritesheet_slice_mode = if is_canonical_art_spritesheet {
json_string_field(generated, "sliceMode")
} else {
None
};
@@ -3168,7 +3180,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
warning,
slice_warning,
slices,
spritesheet_slice_layout,
spritesheet_slice_mode,
generation_route,
generation_kind,
reference_resource_ids,
@@ -6508,7 +6520,7 @@ fn validate_strict_platform_art_spritesheet_contract(
task_id: Option<&str>,
generation_route: &str,
generation_kind: &str,
spritesheet_slice_layout: Option<&str>,
spritesheet_slice_mode: Option<&str>,
reference_resource_ids: &[String],
has_transparent_pixels: bool,
has_visible_pixels: bool,
@@ -6545,7 +6557,7 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
let _requested_slice_layout = spritesheet_slice_layout;
let _requested_slice_mode = spritesheet_slice_mode;
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -7307,7 +7319,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
warning,
mut slice_warning,
slices,
spritesheet_slice_layout,
spritesheet_slice_mode,
generation_route,
generation_kind,
reference_resource_ids,
@@ -7326,7 +7338,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
task_id.as_deref(),
&generation_route,
&generation_kind,
spritesheet_slice_layout.as_deref(),
spritesheet_slice_mode.as_deref(),
&reference_resource_ids,
spritesheet_has_transparent_pixels,
spritesheet_has_visible_pixels,
@@ -7901,8 +7913,8 @@ mod canvas_generation_tests {
let body = serde_json::json!({
"error": {
"code": "invalid-request",
"field": "sliceLayout",
"message": "只支持 grid-2x2operationId=private-operation-idapi_key=private-key",
"field": "sliceMode",
"message": "只支持 gridoperationId=private-operation-idapi_key=private-key",
},
"details": {
"path": "C:\\Users\\private\\secret.json",
@@ -7911,8 +7923,8 @@ mod canvas_generation_tests {
.to_string();
let summary = summarize_external_http_error_body(&body).expect("summary");
assert!(summary.contains("code=invalid-request"), "{summary}");
assert!(summary.contains("field=sliceLayout"), "{summary}");
assert!(summary.contains("只支持 grid-2x2"), "{summary}");
assert!(summary.contains("field=sliceMode"), "{summary}");
assert!(summary.contains("只支持 grid"), "{summary}");
assert!(!summary.contains("private-operation-id"), "{summary}");
assert!(!summary.contains("private-key"), "{summary}");
assert!(!summary.contains("C:\\Users\\private"), "{summary}");
@@ -8312,6 +8324,9 @@ mod canvas_generation_tests {
asset_label: "手工背景".to_string(),
replace_existing: true,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -9826,7 +9841,7 @@ mod canvas_generation_tests {
Some("spritesheet-task"),
"/api/external/v1/editor/icon-spritesheets/generations",
"icon-spritesheet",
Some("grid-2x2"),
Some("grid"),
&["art-spec-resource".to_string()],
true,
true,
@@ -10325,6 +10340,9 @@ mod canvas_generation_tests {
asset_label: "整包规范图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11220,6 +11238,9 @@ mod canvas_generation_tests {
asset_label: "整包背景图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11681,6 +11702,9 @@ mod canvas_generation_tests {
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -12287,6 +12311,9 @@ mod canvas_generation_tests {
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
}
}
@@ -12317,7 +12344,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices: Vec::new(),
spritesheet_slice_layout: Some("grid-2x2".to_string()),
spritesheet_slice_mode: Some("grid".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -12636,7 +12663,7 @@ mod canvas_generation_tests {
warning: None,
slice_warning: None,
slices,
spritesheet_slice_layout: Some("grid-2x2".to_string()),
spritesheet_slice_mode: Some("grid".to_string()),
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
generation_kind: "icon-spritesheet".to_string(),
reference_resource_ids: vec!["art-spec-resource".to_string()],
@@ -0,0 +1,167 @@
//! Shared, project-bound error events for Agent Runtime and DirectProject.
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub(crate) struct AgentRuntimeErrorEvent {
pub schema_version: &'static str,
pub event_id: String,
pub client_turn_id: Option<String>,
pub source: String,
pub stage: String,
pub code: String,
pub retryable: bool,
pub occurred_at_unix_nanos: String,
pub elapsed_ms: Option<u64>,
pub public_text: String,
pub recovery_hint: String,
pub detail_ref: String,
pub persistence_failed: bool,
pub metadata: Value,
}
pub(crate) fn persist_agent_runtime_error(
root: &Path,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: Value,
) -> Result<AgentRuntimeErrorEvent, String> {
let occurred_at_unix_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("读取错误事件时间失败:{error}"))?
.as_nanos();
let sequence = ERROR_EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let event_id = format!("error-{occurred_at_unix_nanos}-{sequence}");
let detail_ref = format!(".agent/runtime/errors/{event_id}.json");
let safe_detail =
redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS);
let diagnostic = serde_json::json!({
"schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
"eventId": event_id,
"clientTurnId": client_turn_id,
"source": source,
"stage": stage,
"code": code,
"retryable": retryable,
"occurredAtUnixNanos": occurred_at_unix_nanos.to_string(),
"elapsedMs": elapsed_ms,
"publicText": public_text,
"recoveryHint": recovery_hint,
"detail": safe_detail,
"metadata": metadata,
});
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
"统一 Agent Runtime 错误诊断",
&diagnostic,
16 * 1024,
)?;
Ok(AgentRuntimeErrorEvent {
schema_version: AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
event_id,
client_turn_id: client_turn_id.map(str::to_string),
source: source.to_string(),
stage: stage.to_string(),
code: code.to_string(),
retryable,
occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(),
elapsed_ms,
public_text: public_text.to_string(),
recovery_hint: recovery_hint.to_string(),
detail_ref,
persistence_failed: false,
metadata,
})
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
"turn-idle-timeout"
} else if normalized.contains("达到 directproject 硬上限") {
"turn-hard-timeout"
} else if normalized.contains("transport closed") || normalized.contains("连接已关闭") {
"transport-closed"
} else if normalized.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else if (normalized.contains("tool") || normalized.contains("工具"))
&& normalized.contains("参数")
{
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
} else {
"runtime-failure"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_event_is_bounded_and_redacts_private_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
let event = persist_agent_runtime_error(
&root,
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件",
"查看诊断后重试",
"C:\\Users\\private\\project https://provider.example/a?token=secret",
Some(1200),
serde_json::json!({"lastEvent":"item/started"}),
)
.expect("persist event");
assert_eq!(event.code, "turn-idle-timeout");
let path = root.join(&event.detail_ref);
let text = std::fs::read_to_string(path).expect("diagnostic");
assert!(text.contains("<absolute-path>"));
assert!(text.contains("<redacted-url>"));
assert!(!text.contains("token=secret"));
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
classify_direct_codex_error("等待 turn/completed 超时"),
"turn-idle-timeout"
);
assert_eq!(
classify_direct_codex_error("达到 DirectProject 硬上限"),
"turn-hard-timeout"
);
assert_eq!(
classify_direct_codex_error("工具参数 attempt 必须是 1 到 3 的整数"),
"tool-invalid-arguments"
);
}
}
@@ -101,6 +101,26 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
error: &str,
) -> Result<(), String> {
let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error);
// Keep the existing conversation projection, but also persist one common
// bounded diagnostic event for every Agent Runtime terminal failure. This
// makes non-DirectProject failures observable through the same detail API.
let _ = persist_agent_runtime_error(
root,
Some(&state.run_id),
"agent-runtime",
&state.phase,
"agent-runtime-terminal",
false,
&content,
"查看项目错误诊断后处理",
error,
None,
serde_json::json!({
"agentId": state.agent_id,
"sessionId": state.session_id,
"runId": state.run_id,
}),
);
let status = if state.phase == "budget-exhausted" {
"budget-exhausted"
} else if state.phase == "needs-reconciliation" {
@@ -555,6 +555,17 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| input.get("slice_count"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as usize);
let slice_mode = agent_runtime_tool_input_text(input, &["sliceMode", "slice_mode"]);
let grid_x = input
.get("gridX")
.or_else(|| input.get("grid_x"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as u32);
let grid_y = input
.get("gridY")
.or_else(|| input.get("grid_y"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as u32);
let mut requested_options = PlatformArtAssetGenerationOptions {
output_path: (!output_path.trim().is_empty()).then_some(output_path),
aspect_ratio,
@@ -563,6 +574,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
asset_label,
replace_existing,
slice_count,
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
grid_x,
grid_y,
};
if let Some(pending) = pending_action {
match recover_persisted_visual_generation_options(
@@ -609,6 +623,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
},
replace_existing,
slice_count,
slice_mode: requested_options
.slice_mode
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
grid_x,
grid_y,
}
};
options.replace_existing = replace_existing;
@@ -654,6 +673,28 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None,
};
}
if options
.slice_mode
.as_deref()
.is_some_and(|slice_mode| !matches!(slice_mode, "connected-components" | "grid"))
{
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "图片生成 sliceMode 不受支持".to_string(),
detail: None,
};
}
if options.slice_mode.as_deref() == Some("grid")
&& (options.grid_x.is_none() || options.grid_y.is_none())
{
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "grid 模式必须同时提供 gridX 与 gridY".to_string(),
detail: None,
};
}
if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
@@ -3192,7 +3192,9 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate(
"spritesheetImageSrc": "/generated/canvas/spritesheet.png",
"spritesheetWidth": 2,
"spritesheetHeight": 1,
"sliceLayout": "grid-2x2",
"sliceMode": "grid",
"gridX": 2,
"gridY": 2,
"iconImageSrcs": icon_image_srcs,
"sliceWarning": null,
"prompt": "原创游戏素材图集",
@@ -1076,6 +1076,9 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
},
)
.await;
@@ -5493,6 +5496,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
slice_mode: None,
grid_x: None,
grid_y: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
+18 -1
View File
@@ -6888,8 +6888,25 @@ export function App({
void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID);
const message =
error instanceof Error ? error.message : String(error);
let persistedDetail = '';
const detailRef = message.match(
/详情:(\.agent\/runtime\/errors\/[^\s]+)/,
)?.[1];
if (detailRef && directInvoke) {
try {
persistedDetail = await directInvoke<string>(
'read_agent_runtime_error_detail',
{
projectPath: directProjectPath,
detailRef,
},
);
} catch {
persistedDetail = '';
}
}
const visibleMessage = projectRuntimeVisibleError(
message,
persistedDetail ? `${message}\n\n${persistedDetail}` : message,
'陶泥儿智能创作',
true,
);
@@ -3370,16 +3370,32 @@
"maxLength": 200
}
},
"sliceLayout": {
"sliceMode": {
"type": "string",
"deprecated": true,
"description": "历史兼容字段,新的调用请使用 sliceCount"
"enum": [
"connected-components",
"grid"
],
"default": "connected-components",
"description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式的横向网格数量。"
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32,
"description": "grid 模式的纵向网格数量。"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "可选的目标切片数量;省略时按图像内容自动识别。"
"description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。"
},
"screenColor": {
"type": ["string", "null"],
@@ -3603,15 +3619,28 @@
},
"iconImageSrcs": {
"type": "array",
"description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。",
"description": "按 sliceMode 识别或裁切并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;connected-components 模式可通过 sliceCount 指定目标数量。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
},
"sliceLayout": {
"sliceMode": {
"type": "string",
"deprecated": true,
"description": "历史兼容字段。"
"enum": [
"connected-components",
"grid"
],
"description": "实际采用的图集切分模式。"
},
"gridX": {
"type": "integer",
"minimum": 1,
"maximum": 32
},
"gridY": {
"type": "integer",
"minimum": 1,
"maximum": 32
},
"sliceCount": {
"type": "integer",
@@ -3628,7 +3657,7 @@
"type": "null"
}
],
"description": "可信透明图集已成功持久化,但全连通域自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-onlysliceWarning 为 null。"
"description": "可信透明图集已成功持久化,但所选 sliceMode 的自动拆分未完成时返回;此时 iconImageSrcs 为空,调用方仍应使用整张图集。原始连通域、输出数量、网格裁切或 CPU 预算超限不会产生切片 PUT、资源或画布切片。透明处理、Alpha/尺寸恢复、provider 原图修复性回读或透明图完整解码失败时走 provider 原图 source-onlysliceWarning 为 null。"
},
"prompt": {
"type": "string"
@@ -0,0 +1,35 @@
# AGC 统一错误诊断与验收反馈实施计划
Version: 1.0
Status: active
Date: 2026-09-15
Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md`
## 修改边界
1. 新增 `agent/runtime_error.rs`,承载统一事件字段、code/stage 白名单、脱敏后的 public projection、项目错误 JSONL/sidecar 落库和 detail 读取边界。
2. `direct_runtime.rs` 使用统一事件替代仅写 `failure.json` 的路径;失败 assistant 投影带稳定 ID,下一轮 prompt 注入最近失败事件摘要。
3. `codex_app_server.rs` 将 failed turn、idle/hard timeout、transport close、invalid terminal 和 stderr tail 转成稳定事件字段;不公开原始 detail。
4. `direct_tool_bridge.rs``direct_tools_mcp.rs` 让 attempt 由客户端回合状态约束,越界请求返回终态工具错误;不扩展重试预算。
5. `direct_runtime.rs` 的素材扫描递归覆盖可执行源码模块,基于 manifest 身份和浏览器 URL 映射判定;补充模块引用回归测试。
6. 前端读取后端 `publicText/detailRef`,在现有 Runtime 错误面板中加入详情入口;不在 React 侧重新分类错误。
## 实现顺序
先写统一事件模型和 Rust 单测,再接 direct failure/app-server/tool bridge,随后接 prompt/history 与前端详情,最后修素材验收和 attempt 生命周期。每一步保留原有脱敏和失败关闭行为。
## 验证命令
- `cargo fmt --check`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error direct_runtime codex_app_server direct_tool_bridge`
- `npm run --prefix apps/ai-game-creator-shell typecheck`
- `npm run check:encoding`
- `git diff --check`
- 必要时运行 AGC deterministic playable E2E;真实 Provider smoke 与浏览器双视口 smoke 单独报告。
## 风险与回滚
- 统一事件 schema 只新增项目内文件和对话投影,不修改已有 manifest、公开 API 或 SpacetimeDB schema。
- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。
- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。
- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。
@@ -0,0 +1,39 @@
# AGC 统一错误诊断与验收反馈
Version: 1.0
Status: active
Date: 2026-09-15
Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”
## 目标
让 DirectProject 和共享 Agent Runtime 对失败使用同一份安全、可追踪、可恢复的错误事件合同;用户追问失败原因时能够读取上一轮证据;构建与浏览器验收只依据真实源码、manifest 身份和运行时证据判断。
## 范围
- 统一错误事件模型与项目内诊断落库。
- DirectProject 失败 assistant 投影、下一轮诊断上下文和前端详情入口。
- app-server 终态/超时、内置 MCP 工具错误和试玩 attempt 上限的分类。
- 游戏源码模块素材扫描、manifest 身份映射与浏览器观察映射。
- 定向 Rust/前端回归和现有 AGC 运行时门禁。
## 不做
- 不改变 Provider、External Editor 或 app-server 的 wire 协议。
- 不放宽项目写锁、凭据隔离、工具白名单或完成门安全边界。
- 不迁移历史项目文件;旧诊断只读兼容,新增事件使用新 schema。
- 不把原始 stderr、请求正文或绝对路径展示给用户。
## 验收标准
1. 任一 DirectProject 失败均生成统一事件、稳定 `eventId` 和有界诊断引用;落库失败不覆盖原始错误。
2. 失败安全投影写入对话历史,下一轮能读取 `publicText / code / stage / detailRef`,不会因追问而自动试玩。
3. 结构化 failed turn、idle/hard timeout、transport close、MCP 参数错误和 `other` 各有稳定 code 与 recoveryHint。
4. `attempt` 由客户端按回合分配并有上限;越界调用不会让回合继续等待。
5. `game/src` 下模块引用已登记素材、Vite dist 稳定映射和浏览器实际观察均能通过;未登记素材仍失败。
6. 脱敏测试证明 Token、Cookie、URL/query、私钥、宿主绝对路径和 stderr 私密内容不会进入用户文本。
## 依赖
- 现有 `direct_project_history``runtime_state``codex_app_server``direct_tool_bridge` 与浏览器 validation 证据。
- 现有 DirectProject 诊断 sidecar 和 manifest 资源身份。
@@ -8741,6 +8741,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`S11a / §7.3)。
## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken
- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。
@@ -8751,3 +8752,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。
- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL``AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。
## 2026-09-15 AGC 统一错误事件与项目诊断落库
- 背景:DirectProject 的 app-server 超时、MCP 参数错误、浏览器完成门误判和普通 Agent Runtime 失败分别投影为短文案;失败正文没有稳定落库,下一轮模型看不到上一轮失败证据,用户追问原因时可能继续试玩或重复修改。
- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/<eventId>.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。
- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。
- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。
@@ -1379,3 +1379,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
## 2026-09-14 新游戏策划到真实美术接入的连续交付
DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视觉素材作为同一交付链路处理:先读取当前项目已登记资源;策划案包含角色、对象、背景、特效、界面或其它视觉实体且现有资源不满足时,Codex 必须在同一游戏实现任务中调用审核的 `agc_tools` 生图或编辑工具,读取返回的资源身份与相对路径,把真实产物接入游戏源码,再构建并验证实际渲染。生成了素材但源码仍使用 emoji、CSS 形状或临时占位图替代策划要求的视觉元素,不能报告游戏完成。只有策划明确不需要视觉素材,或现有已登记素材完全满足需求时,才允许跳过生图;图片生成、处理、登记和接入不因用户没有重复输入“生图”而降级为可选建议。
## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈
DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / retryable / occurredAt / elapsedMs / publicText / recoveryHint / detailRef`,其中 `publicText` 是脱敏后的可行动摘要,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。
项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示 `publicText`,点击详情后按 `detailRef` 读取有界、脱敏的诊断,不直接展示私有 `detail`
`turn/completed` 等待超时必须区分 `idle-timeout``hard-timeout``transport-closed``failed-turn``invalid-terminal``tool-error`;收到内置工具参数错误后必须结束当前工具调用并进入可行动终态,不能继续使用越界的试玩 `attempt` 或无限等待。试玩次数由客户端按当前 `clientTurnId` 持久化分配,模型不能自由递增;超过上限必须返回一次终态并停止回合。
游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。
验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。
@@ -40,6 +40,7 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。
- 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。
- 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。
- queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。

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