diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 8b1f7d6dc..8f83bfc4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -23,10 +23,10 @@ pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024; pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200; pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200; -/// 身份行里 public summary 的字符预算。 +/// 详情行里 public summary 的字符预算。 /// -/// 身份行必须短小:`summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文, -/// 所以这里与 sidecar 侧的摘要预算同口径(320 字符)截断,并再脱敏一次。 +/// `summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文;这里与 sidecar 侧的摘要 +/// 预算同口径(320 字符)截断,并再脱敏一次,避免摘要把整条详情行占满。 pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS: usize = 320; static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -143,8 +143,10 @@ pub(crate) fn persist_agent_runtime_error( /// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。 /// /// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被 -/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位,详情行可能因此消失; -/// 身份行保持短小且只含摘要与引用,保证事件还能被定位。 +/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或 +/// 调用方常量字段**(eventId / source / stage / code / retryable / clientTurnId / elapsedMs / +/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词 +/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。 /// /// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行, /// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先 @@ -179,7 +181,7 @@ pub(crate) fn agent_runtime_error_app_log_lines( .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()); let identity = format!( - "agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref} summary={public_text}" + "agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref}" ); let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS); let metadata = redact_agent_runtime_error( @@ -188,8 +190,9 @@ pub(crate) fn agent_runtime_error_app_log_lines( AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS, ); let detail_line = format!( - "agent.runtime.error.detail eventId={event_id} hint={} detail={} metadata={}", + "agent.runtime.error.detail eventId={event_id} hint={} summary={} detail={} metadata={}", single_line_log_field(recovery_hint), + single_line_log_field(&public_text), single_line_log_field(&detail), single_line_log_field(&metadata), ); @@ -305,16 +308,42 @@ mod tests { identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"), "{identity}" ); - assert!( - identity.contains("summary=本轮没有收到完成事件 附带换行"), - "{identity}" - ); + // 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。 + assert!(!identity.contains("summary="), "{identity}"); let detail = &persisted[1]; + assert!( + detail.contains("summary=本轮没有收到完成事件 附带换行"), + "{detail}" + ); assert!( detail.contains("detail= 第二行诊断"), "{detail}" ); assert!(!detail.contains("Bearer secret"), "{detail}"); + + // 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。 + let marked = agent_runtime_error_app_log_lines( + &root, + "error-3-1", + None, + "agc-tools", + "tool-execution", + "tool-error", + true, + "credential rotation failed", + "查看项目错误诊断后处理", + ".agent/runtime/errors/error-3-1.json", + &safe_detail, + None, + &serde_json::json!({"tool": "agc_tools"}), + ); + let identity = crate::sanitize_diagnostic_message(&marked[0], None); + assert!(identity.contains("eventId=error-3-1"), "{identity}"); + assert!(identity.contains("code=tool-error"), "{identity}"); + assert_eq!( + crate::sanitize_diagnostic_message(&marked[1], None), + "" + ); } #[test] diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 5c2046cdc..38d241c33 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9177,8 +9177,8 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 ## 2026-09-21 统一错误事件额外投影到 AppData 应用日志 - 背景:`direct-codex-failure:v2 ... 详情:.agent/runtime/errors/error--9.json`(例如 `stage=code-generation code=turn-idle-timeout`)里的诊断正文只落在项目目录,而“报告问题”只上传 AppData `diagnostics/application.log`;用户提交上来的失败消息因此只有一个指向项目文件的引用,团队复现不到 stderr 摘要、退出状态这些真因。 -- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 detail / metadata 按应用日志预算(1200 / 200 字符)脱敏截断,身份行的 `summary` 也按 320 字符预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 -- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。 +- 决策:统一错误写入边界 `agent/runtime_error.rs` 在落 `.agent/runtime/errors/.json` 之前先把同一份已脱敏诊断投影成应用日志两行——`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata)。字段仍只从 sidecar 那份 diagnosis 来,不新增第二份来源;落盘前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(`direct_tool_bridge` 会把它当自由文本传工具错误原文,而 `app_log!` 同时写 stderr,那里没有 `sanitize_diagnostic_message` 兜底),自由文本先压平换行。 +- 决策(两行而不是一行):整行一旦命中 `sanitize_diagnostic_message` 的凭据标记(token / bearer / authorization / credential / api key)会被整体替换成 ``;拆开后详情行即使被吃掉,身份行仍能定位 eventId 与 detailRef。后续复核发现:裸词标记(例如 `credential rotation failed`)脱敏消不掉,summary 留在身份行时仍会连 eventId 一起被替换,因此 summary / hint 等自由文本一律只放详情行,身份行只留程序生成与调用方常量字段。 - 边界:进程内错误报告事件池(`error_report`)与项目内 `.agent/runtime/errors/.json` sidecar 是两套东西——本次只把 sidecar 的同一份诊断作为**应用日志行**落盘,不进事件池、不改报告上传协议、不改 `read_agent_runtime_error_detail` 详情入口。 - 影响面:只改 `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs` 这一处写入边界(覆盖 direct-codex / agc-tools / agent-runtime 三类来源);sidecar schema、对话投影、前端详情入口与错误报告协议均未改动。 - 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error`(7 passed,含新增 `error_event_app_log_lines_keep_identity_and_redact_detail`)与 `-- direct_runtime direct_tool_bridge`(131 passed)全绿;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 5ea414011..ba88d60ab 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5854,4 +5854,4 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-09-21 应用日志整行凭据脱敏会吃掉整条结构化诊断 -`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时先按两行落:身份行短小、详情行独立,且自由文本先自行压平换行)。 +`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=`、`bearer `、`authorization`、`credential`、`api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 ``,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId`、`code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。 diff --git a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md index 1eeed278b..74f326266 100644 --- a/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md +++ b/docs/technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md @@ -13,7 +13,7 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事 - 指纹计算可使用调用方的 page/action 及脱敏后的首个调用点作为进程内区分输入,但这些上下文不会作为事件字段上传;消息与 stack 在入池前统一脱敏,WebCrypto 失败时降级为稳定可读指纹,采集本身不得产生新的未处理拒绝。 - 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。 - Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`;WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。 -- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary)与 `agent.runtime.error.detail`(详情行:hint / detail / metadata)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 detail / metadata 各按应用日志预算(1200 / 200 字符)脱敏截断,身份行里的 `summary` 也按 320 字符预算脱敏截断(该字段由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。详情行可能因整行凭据标记被整体脱敏,身份行保证事件仍可定位。 +- 这里有两套互不相干的东西,不要互相代入:**错误报告事件池**是进程内 `error_report` 的结构化事件(本次变更不动它,仍然只在内存里、提交时才生成 `events.jsonl`);**统一 Agent Runtime 错误事件**是项目内 sidecar `.agent/runtime/errors/.json`,既不进事件池也不进报告包。因为报告包里的日志附件只有 AppData 应用日志,所以 sidecar 的同一份已脱敏诊断再作为**日志行**(不是报告事件)投影成两行:`agent.runtime.error`(身份行:eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef,全部是程序生成或调用方常量)与 `agent.runtime.error.detail`(详情行:hint / summary / detail / metadata,自由文本只出现在这里)。两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,不新增字段来源;落到日志前 summary 按 320 字符、detail / metadata 按(1200 / 200 字符)预算脱敏截断(summary 由调用方给,`direct_tool_bridge` 会传工具错误原文,而 `app_log!` 同时把整行写 stderr,那里没有 `sanitize_diagnostic_message` 兜底)。`sanitize_diagnostic_message` 命中凭据标记时替换的是**整行**,自由文本因此只放详情行:详情行被吃掉也不影响身份行定位事件。 - 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中当前快照中的全部事件,用户可取消不想提交的事件。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。 - 报告面板读取当前错误快照失败时,必须明确显示“错误事件暂不可用,请关闭后重试”,不能把失败误显示为“当前没有待报告的错误”。 - 通知中的“查看并报告”打开面板时必须保留该次通知快照;最新快照读取瞬时失败时使用这份 fallback 继续展示和提交,不能因先清空通知而丢失用户刚看到的事件。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8f55a9280..c2d059a62 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1834,6 +1834,6 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ## 2026-09-21 统一错误事件同时落到 AppData 应用日志 -`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef / summary`)与 `agent.runtime.error.detail`(详情行:`hint / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 +`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / retryable / clientTurnId / elapsedMs / detailRef`)与 `agent.runtime.error.detail`(详情行:`hint / summary / detail / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 -口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`detail` 与 `metadata` 按应用日志预算(1200 / 200 字符)先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位,详情行可能消失,身份行至少要保住事件定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;`summary` 按 320 字符、`detail` 与 `metadata` 按(1200 / 200 字符)预算先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位:所以**自由文本(summary / hint / detail)只放详情行**,身份行只留程序生成与调用方常量字段,详情行被整体脱敏时事件仍能按 eventId / detailRef 定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。