Merge branch 'master' into fix/chat-state
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m13s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m24s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m11s
Project CI / Native shell tests (pull_request) Failing after 2m25s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m14s
Project CI / Frontend tests (pull_request) Failing after 2m59s
Project CI / Repository checks (pull_request) Failing after 3m17s
Project CI / Backend tests (pull_request) Successful in 7m15s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m13s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m24s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m3s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m40s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m11s
Project CI / Native shell tests (pull_request) Failing after 2m25s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m14s
Project CI / Frontend tests (pull_request) Failing after 2m59s
Project CI / Repository checks (pull_request) Failing after 3m17s
Project CI / Backend tests (pull_request) Successful in 7m15s
This commit is contained in:
@@ -1295,7 +1295,7 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
@@ -1307,19 +1307,19 @@ const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
};
|
||||
const expectedBundledWindowsResources = {
|
||||
'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe',
|
||||
'resources/codex/win-x64/bin/codex-code-mode-host.exe':
|
||||
'codex/win-x64/bin/codex-code-mode-host.exe',
|
||||
'coding-agent/win-x64/bin/codex-code-mode-host.exe',
|
||||
'resources/codex/win-x64/codex-path/rg.exe':
|
||||
'codex/win-x64/codex-path/rg.exe',
|
||||
'coding-agent/win-x64/codex-path/rg.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-command-runner.exe':
|
||||
'codex/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'coding-agent/win-x64/codex-resources/codex-command-runner.exe',
|
||||
'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe':
|
||||
'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe',
|
||||
'resources/codex/win-x64/codex-package.json':
|
||||
'codex/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json',
|
||||
'coding-agent/win-x64/codex-package.json',
|
||||
'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md',
|
||||
'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json',
|
||||
'resources/plugins': 'plugins',
|
||||
};
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -22,7 +22,6 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = resolve(appRoot, '../..');
|
||||
const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js');
|
||||
const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG';
|
||||
const designDebugEnabled =
|
||||
process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1';
|
||||
|
||||
@@ -137,7 +136,6 @@ async function runTauriDev(
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_ENV]: designDebugEnabled,
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
});
|
||||
const childResult = waitForCli(child);
|
||||
@@ -191,7 +189,6 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...withAgcDevEndpointEnv(endpoint),
|
||||
[AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -74,7 +74,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计
|
||||
三个接口:**对上**承顶层系统范围表并跑循环覆盖检查;**对内**地图↔职责↔依赖
|
||||
三方一致、主数据归属唯一;**对下**目录映射 + MVP 闭环喂系统文档站。
|
||||
|
||||
## 四、怎么写(模板即流程,十二节按序)
|
||||
## 四、怎么写(模板参考结构,建议按此组织)
|
||||
(本节是带写法要领的教学版;实际填写的纯净模板在 templates/architecture.md)
|
||||
|
||||
### 1. 架构定位与目标
|
||||
@@ -85,7 +85,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计
|
||||
→ 没有变更记录的架构文档,第二轮迭代就会变成黑箱。
|
||||
|
||||
### 2. 系统地图
|
||||
Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。
|
||||
Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。
|
||||
P0 段五列表:
|
||||
| 系统 | 目的 | 输入 | 输出 | P0 原因 |
|
||||
→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。
|
||||
|
||||
@@ -69,7 +69,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
记住三个接口:**对内**锚点仲裁一切;**对下**张力变取舍表、定稿变硬约束;
|
||||
**对上**边界画线防止越层。九节不是清单,是一台咬合的机器。
|
||||
|
||||
## 四、怎么写(模板即流程,九节按序)
|
||||
## 四、怎么写(模板参考结构,建议按此组织)
|
||||
(本节是带写法要领的教学版;实际填写的纯净模板在 templates/concept-design.md)
|
||||
|
||||
### 1. 一句话概念
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
---
|
||||
name: game-gdd-system-doc
|
||||
description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、
|
||||
description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、
|
||||
红线与分析文档格式。每类系统的专属写法与模板在 modules/system-types/ 下对应目录的 SKILL.md
|
||||
与对应模块的模板.md 里,按需取用。
|
||||
---
|
||||
@@ -26,9 +26,9 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。
|
||||
2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗
|
||||
的判定部分),读取对应的 `SKILL.md` 与 `模板.md`。
|
||||
3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。
|
||||
3. 该文件夹标注"参考例子"的,可先读例子了解写法。
|
||||
|
||||
## 三、十二节总览:写什么、为什么、怎么咬合
|
||||
## 三、常见内容总览:写什么、为什么、怎么咬合
|
||||
|
||||
系统文档回答四个问题:
|
||||
**这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→
|
||||
@@ -52,7 +52,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越
|
||||
职责边界;**对下**第 7 节交接喂 TDD。
|
||||
|
||||
## 四、十二节通用写法
|
||||
## 四、常见内容的参考写法
|
||||
(各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md)
|
||||
|
||||
1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。
|
||||
@@ -65,7 +65,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。
|
||||
9 内部循环:动词链;可拆单次/区域/长期三层。
|
||||
10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。
|
||||
11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。
|
||||
11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。
|
||||
12 开放问题:结构级才留;手感数值类标"待原型验证"。
|
||||
|
||||
## 五、分析文档(全局一份,按层分节)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,7 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
(大⇄小⇄最小单位)+ 资源三段全;**对下**系统范围表喂架构的系统地图、
|
||||
顶层定稿当架构的紧箍咒、验证标准当原型试玩判据。
|
||||
|
||||
## 四、怎么写(模板即流程,十六节按序)
|
||||
## 四、怎么写(模板参考结构,建议按此组织)
|
||||
(本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md)
|
||||
|
||||
### 1. 顶层定位与规模锚点
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
|
||||
你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料;所有文件路径使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。修改文件后,简要说明修改内容和相对路径。对不确定内容区分用户确认、Agent 建议和待原型验证事项;不要把建议写成用户已确认的决定。
|
||||
|
||||
优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答,并据此更新相关产物;不要带着这类未决问题提交阶段审批。
|
||||
优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答。决定稳定后,再更新受影响的正式产物和必要的过程记录;不要带着这类未决问题提交阶段审批。
|
||||
|
||||
分析阶段优先记录当前目标、上层约束、候选方案、取舍、用户已确认或 Agent 暂定的边界,以及必须检查的验收项。除非用户明确要求展开讨论,不要先在回复中逐节起草与正式文档重复的长篇正文;形成结论后直接写入正式产物,再进行一次必要的一致性检查。文件操作前只需说明简短计划、目标文件和主要变化。
|
||||
|
||||
正式策划文档在文档头部写明版本标记,例如“版本:v1”。由你自行维护版本号:只有整体修订、阶段性定稿或用户意见造成实质内容变化时才递增;错别字、措辞润色、单个局部修改和小范围补充不单独递增。
|
||||
|
||||
阶段审批是每个阶段的最终检查,表示本阶段产物已经完成,无未决内容,交给用户做最终检阅,不承担问询功能。提交前,解决所有影响本阶段完成的关键问题,或明确说明它们不阻塞本阶段交付,并更新相关产物。可以保留不阻塞当前阶段的后续事项和待原型验证项。
|
||||
|
||||
过程文档用于记录关键依据、决定和待办,不要求实时完整,也不应重复正式设计文档。阶段内优先完成主要设计内容;只有稳定且影响后续工作的决定才需要同步到多个过程文档。阶段提交前,补齐影响验收的关键记录。
|
||||
|
||||
阶段获批后,产物中已经采用的方案作为后续工作的依据,并保留原有决策来源。除非用户主动质疑或出现新的约束冲突,不要反复要求确认历史暂定决定。
|
||||
|
||||
用户说“继续”时,继续推进当前阶段最有价值的工作。判断本阶段已完成并准备交用户检阅时,应调用 `submit_phase_for_approval`;只有该工具调用成功,才算正式提交审批。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件,优先用于已有文件的小范围修订。先读文件,以唯一且非空的 old_text 精确匹配并替换为 new_text;new_text 为空可删除片段,保留原文并追加可插入。匹配失败不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["path","old_text","new_text"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
|
||||
+7
-1
@@ -19,8 +19,14 @@ Use this Skill as the top-level SOP for a new game or a substantial game brief.
|
||||
|
||||
## Stage transitions
|
||||
|
||||
Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and stop or repair at that stage instead of silently substituting a later-stage placeholder.
|
||||
Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and handle it under "Error handling" instead of silently substituting a later-stage placeholder.
|
||||
|
||||
For a small edit to an existing game where the brief and suitable assets are unchanged, use the focused edit path and do not regenerate art. This exception does not apply to a new game or a substantial planning brief.
|
||||
|
||||
## Error handling
|
||||
|
||||
When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output.
|
||||
|
||||
Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered.
|
||||
|
||||
Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.13",
|
||||
"version": "2026-08-26.14",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -22,7 +22,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/workflow-contract.md"
|
||||
],
|
||||
"sha256": "91082fdff4123f1e1fcf930af433cbea51a8c9d26991678b19028b344ea49f39"
|
||||
"sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c"
|
||||
},
|
||||
{
|
||||
"name": "agc-project-structure",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
@@ -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.
|
||||
|
||||
@@ -28,6 +28,7 @@ mod prompt;
|
||||
mod runtime_actions;
|
||||
mod runtime_adapter;
|
||||
mod runtime_driver;
|
||||
mod runtime_error;
|
||||
mod runtime_protocol;
|
||||
mod runtime_state;
|
||||
mod runtime_tools;
|
||||
@@ -56,6 +57,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::*;
|
||||
|
||||
@@ -276,6 +276,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,
|
||||
@@ -430,7 +475,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 {
|
||||
@@ -454,8 +499,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 [
|
||||
@@ -471,7 +516,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(
|
||||
|
||||
@@ -6,8 +6,9 @@ use sha2::{Digest, Sha256};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str =
|
||||
"coding-agent/win-x64/manifest.json";
|
||||
const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [
|
||||
"bin/codex.exe",
|
||||
"bin/codex-code-mode-host.exe",
|
||||
|
||||
@@ -606,11 +606,7 @@ fn build_design_request(
|
||||
|
||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||
fn design_debug(root: &Path, kind: &str, data: Value) {
|
||||
if std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some("1")
|
||||
{
|
||||
if !design_debug_enabled() {
|
||||
return;
|
||||
}
|
||||
type Entry = (PathBuf, Value);
|
||||
@@ -1133,6 +1129,18 @@ fn resolve_design_runtime_mode(root: &Path) -> Result<Option<DesignRuntimeMode>,
|
||||
}))
|
||||
}
|
||||
|
||||
fn design_debug_enabled() -> bool {
|
||||
std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some("1")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn is_design_agent_debug_enabled() -> bool {
|
||||
cfg!(debug_assertions) && design_debug_enabled()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_design_agent_runtime_mode(
|
||||
project_path: String,
|
||||
@@ -1159,12 +1167,7 @@ pub(crate) fn debug_fast_forward_design_session(
|
||||
project_path: String,
|
||||
target_phase: String,
|
||||
) -> Result<DesignRuntimeMode, String> {
|
||||
if !cfg!(debug_assertions)
|
||||
|| std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG")
|
||||
.ok()
|
||||
.as_deref()
|
||||
!= Some("1")
|
||||
{
|
||||
if !is_design_agent_debug_enabled() {
|
||||
return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string());
|
||||
}
|
||||
let root = Path::new(project_path.trim());
|
||||
|
||||
@@ -264,22 +264,45 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
"patch_file" => {
|
||||
let relative = required_tool_path(args)?;
|
||||
let old = args
|
||||
.get("old_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 old_text")?;
|
||||
let new = args
|
||||
.get("new_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 new_text")?;
|
||||
if old.is_empty() {
|
||||
return Err("old_text 不能为空".to_string());
|
||||
}
|
||||
let edits = if let Some(items) = args.get("edits").and_then(Value::as_array) {
|
||||
if items.is_empty() {
|
||||
return Err("edits 不能为空".to_string());
|
||||
}
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let old = item
|
||||
.get("old_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("edits[{index}].old_text 必须是字符串"))?;
|
||||
let new = item
|
||||
.get("new_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| format!("edits[{index}].new_text 必须是字符串"))?;
|
||||
if old.is_empty() {
|
||||
return Err(format!("edits[{index}].old_text 不能为空"));
|
||||
}
|
||||
Ok((old.to_string(), new.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?
|
||||
} else {
|
||||
let old = args
|
||||
.get("old_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 old_text")?;
|
||||
let new = args
|
||||
.get("new_text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 new_text")?;
|
||||
if old.is_empty() {
|
||||
return Err("old_text 不能为空".to_string());
|
||||
}
|
||||
vec![(old.to_string(), new.to_string())]
|
||||
};
|
||||
let (display, path) = resolve_design_workspace_path(root, &relative)?;
|
||||
if !path.is_file() {
|
||||
return Ok(Value::String(format!(
|
||||
"局部修改失败:文件不存在:{display}"
|
||||
)));
|
||||
return Err(format!("文件不存在:{display}"));
|
||||
}
|
||||
let content =
|
||||
fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?;
|
||||
@@ -288,20 +311,52 @@ pub(crate) fn execute_design_file_tool(
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let old = old.replace("\r\n", "\n").replace('\n', newline);
|
||||
let new = new.replace("\r\n", "\n").replace('\n', newline);
|
||||
let count = content.matches(&old).count();
|
||||
if count != 1 {
|
||||
return Err(format!(
|
||||
"原文匹配 {count} 处,需要唯一匹配;请重新读取文件并扩大匹配范围"
|
||||
));
|
||||
let normalized = edits
|
||||
.into_iter()
|
||||
.map(|(old, new)| {
|
||||
(
|
||||
old.replace("\r\n", "\n").replace('\n', newline),
|
||||
new.replace("\r\n", "\n").replace('\n', newline),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut matches = Vec::new();
|
||||
for (index, (old, new)) in normalized.iter().enumerate() {
|
||||
let count = content.matches(old).count();
|
||||
if count == 0 {
|
||||
return Err(format!("edits[{index}] 原文未找到:{display}"));
|
||||
}
|
||||
if count != 1 {
|
||||
return Err(format!(
|
||||
"edits[{index}] 原文匹配 {count} 处,必须唯一:{display}"
|
||||
));
|
||||
}
|
||||
let start = content.find(old).expect("count checked");
|
||||
let end = start + old.len();
|
||||
if let Some((other_index, _other_start, _other_end)) = matches
|
||||
.iter()
|
||||
.find(|(_, other_start, other_end)| start < *other_end && *other_start < end)
|
||||
{
|
||||
return Err(format!(
|
||||
"edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}"
|
||||
));
|
||||
}
|
||||
matches.push((index, start, end));
|
||||
let _ = new;
|
||||
}
|
||||
crate::write_game_creator_private_file(
|
||||
&path,
|
||||
content.replacen(&old, &new, 1).as_bytes(),
|
||||
"策划工作区文件",
|
||||
)?;
|
||||
Ok(Value::String(format!("已局部修改 {display}")))
|
||||
let mut updated = content.clone();
|
||||
for (index, start, end) in matches.into_iter().rev() {
|
||||
let (_, new) = &normalized[index];
|
||||
updated.replace_range(start..end, new);
|
||||
}
|
||||
if updated == content {
|
||||
return Err(format!("没有产生修改:{display}"));
|
||||
}
|
||||
crate::write_game_creator_private_file(&path, updated.as_bytes(), "策划工作区文件")?;
|
||||
Ok(Value::String(format!(
|
||||
"已局部修改 {display}({} 处)",
|
||||
normalized.len()
|
||||
)))
|
||||
}
|
||||
"delete_path" => {
|
||||
let relative = required_tool_path(args)?;
|
||||
@@ -645,6 +700,29 @@ mod tests {
|
||||
)
|
||||
.expect("patch");
|
||||
assert!(patched.as_str().unwrap().contains("已局部修改"));
|
||||
execute_design_file_tool(
|
||||
root,
|
||||
"write_file",
|
||||
&json!({"path":"notes/multi.md","content":"甲\n乙\n丙"}),
|
||||
)
|
||||
.expect("write multi");
|
||||
let multi = execute_design_file_tool(
|
||||
root,
|
||||
"patch_file",
|
||||
&json!({
|
||||
"path":"notes/multi.md",
|
||||
"edits":[
|
||||
{"old_text":"甲","new_text":"一"},
|
||||
{"old_text":"丙","new_text":"三"}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.expect("multi patch");
|
||||
assert!(multi.as_str().unwrap().contains("2 处"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("design_artifacts/notes/multi.md")).expect("read multi"),
|
||||
"一\n乙\n三"
|
||||
);
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"notes"}))
|
||||
.expect("delete dir");
|
||||
assert!(!root.join("design_artifacts/notes").exists());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1908,7 +1908,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));
|
||||
@@ -1945,18 +1949,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,
|
||||
@@ -2272,9 +2311,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) {
|
||||
@@ -2287,6 +2336,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())))
|
||||
@@ -2786,6 +2857,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)?;
|
||||
@@ -4097,7 +4171,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);
|
||||
}
|
||||
@@ -6544,26 +6628,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)
|
||||
@@ -6582,25 +6667,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}"
|
||||
);
|
||||
@@ -6610,9 +6695,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)
|
||||
@@ -6626,19 +6711,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}");
|
||||
@@ -6646,15 +6732,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!(
|
||||
@@ -7754,6 +7841,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,12 +2196,16 @@ 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(
|
||||
generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options),
|
||||
)
|
||||
.await?;
|
||||
emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art");
|
||||
let resources = bridge_art_resources(
|
||||
&state.root,
|
||||
std::slice::from_ref(&generated.asset.local_path),
|
||||
@@ -2563,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-2x2;operationId=private-operation-id;api_key=private-key",
|
||||
"field": "sliceMode",
|
||||
"message": "只支持 grid;operationId=private-operation-id;api_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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user