From 927616eb594d8c30d0def47087a37248a4334891 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 17 Sep 2026 09:10:54 +0000 Subject: [PATCH] =?UTF-8?q?=E4=B8=BA=E7=AD=96=E5=88=92Agent=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=AE=B5=E5=A2=9E=E5=8A=A0panic=E8=BE=B9=E7=95=8C=20f?= =?UTF-8?q?inish=5Fdesign=5Fcommand=20=E7=9A=84=20run=5Fdesign=5Floop=20?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E5=8C=85=E5=85=A5=20catch=5Funwind=EF=BC=8Cp?= =?UTF-8?q?anic=20=E8=BD=AC=E6=88=90=E6=99=AE=E9=80=9A=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=94=B1=E6=97=A2=E6=9C=89=E9=94=99=E8=AF=AF=E5=88=86=E6=94=AF?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=20=E6=96=B0=E5=A2=9E=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E5=85=AC=E5=BC=80=E6=96=87=E6=A1=88=EF=BC=9A=E7=AD=96=E5=88=92?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=8F=91=E7=94=9F=E5=86=85=E9=83=A8=E9=94=99?= =?UTF-8?q?=E8=AF=AF=EF=BC=8C=E6=9C=AC=E8=BD=AE=E5=B7=B2=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E7=9B=B4=E6=8E=A5=E9=87=8D=E8=AF=95=EF=BC=9B?= =?UTF-8?q?=E8=8B=A5=E5=8F=8D=E5=A4=8D=E5=87=BA=E7=8E=B0=E8=AF=B7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E3=80=82=20panic=20=E8=B4=9F=E8=BD=BD=E4=BB=85?= =?UTF-8?q?=E5=86=99=E5=85=A5=E7=A7=81=E6=9C=89=20design=5Fdebug=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E8=BF=9B=E5=85=A5=E7=94=A8=E6=88=B7=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=B6=88=E6=81=AF=20=E6=96=B0=E5=A2=9E=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20design=5Ftool=5Fpanic=5Fbecomes=5Fvisible?= =?UTF-8?q?=5Fretryable=5Ferror=20=E8=A6=86=E7=9B=96=20panic=20=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E4=B8=8E=E9=87=8D=E8=AF=95=20=E5=AE=9E=E6=96=BD?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=96=87=E6=A1=A3=E6=96=B0=E5=A2=9E=E7=AD=96?= =?UTF-8?q?=E5=88=92=20Agent=20=E8=BF=90=E8=A1=8C=20panic=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=B0=8F=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent/design_runtime.rs | 92 ++++++++++++++++++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 1a5a6e486..c81f84ec1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1,5 +1,6 @@ use super::design_tools::*; use super::*; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; @@ -11,6 +12,9 @@ use uuid::Uuid; const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; +const DESIGN_PANIC_PUBLIC_ERROR: &str = + "策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。"; + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde( tag = "type", @@ -421,6 +425,10 @@ fn execute_design_tool( ) -> Result { let args: Value = serde_json::from_str(&call.arguments) .map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; + #[cfg(test)] + if call.name == "design_test__panic" { + panic!("注入的策划工具 panic"); + } match call.name.as_str() { "get_workflow_status" => Ok(design_workflow_status(session)), "list_resources" => resources.list().map(Value::String), @@ -971,7 +979,18 @@ async fn finish_design_command( Some(design_view(&session, run)), )); if run { - if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await { + // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、 + // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不 + // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。 + let outcome = + std::panic::AssertUnwindSafe(run_design_loop(root, resources, &mut session, &mut emit)) + .catch_unwind() + .await; + let result = match outcome { + Ok(result) => result, + Err(payload) => Err(design_panic_error(root, payload)), + }; + if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); @@ -991,6 +1010,17 @@ async fn finish_design_command( Ok(view) } +// panic 负载可能包含路径或内容片段,公开文案固定;细节只进私有 design_debug。 +fn design_panic_error(root: &Path, payload: Box) -> String { + let detail = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "未知 panic 负载".to_string()); + design_debug(root, "panic", json!({"error": detail})); + DESIGN_PANIC_PUBLIC_ERROR.to_string() +} + pub(crate) async fn continue_design_agent_at( root: &Path, resources: &DesignResources, @@ -1632,6 +1662,66 @@ mod tests { .clone() } + #[tokio::test(flavor = "current_thread")] + async fn design_tool_panic_becomes_visible_retryable_error() { + let (_temp, root, resources) = init_design_project(); + let panic_call = platform_llm::LlmToolCall { + id: "call-panic".into(), + name: "design_test__panic".into(), + arguments: "{}".into(), + }; + let _fake = fake_provider::install( + vec![ + Ok(fake_response("panic-turn", "", vec![panic_call])), + Ok(fake_response("recovery", "已恢复", Vec::new())), + ], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "turn-panic", + DesignInput::Message { + text: "需求".into(), + }, + |_| {}, + ) + .await + .expect("panic 必须转成可恢复视图而不是向上传播"); + assert!(!view.running); + assert!(view.can_retry); + assert_eq!( + view.session.last_error.as_deref(), + Some(DESIGN_PANIC_PUBLIC_ERROR) + ); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!( + !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + }), + "panic 不得写半个工具输出" + ); + + let view = + continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {}) + .await + .expect("panic 后可重试"); + assert!(!view.running); + assert!(!view.can_retry); + assert!(view.session.last_error.is_none()); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending)); + assert!(session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + })); + } + #[test] fn design_request_enables_reasoning_capture_only_for_design_runtime() { let session = new_design_session("project", "quality"); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index dec89f82b..c505ceb32 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -4,6 +4,10 @@ `patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 +## 策划 Agent 运行 panic 边界 + +`finish_design_command` 的运行段包在 `catch_unwind` 边界内:任何运行期 panic 被转成一次普通失败,由既有错误分支从最后一个持久检查点恢复,向会话写入固定公开文案"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。",并以 `running=false、可重试、lastError 有值` 的视图正常返回。panic 负载只写入私有 design_debug,不进入用户可见消息;continue、recover_uncertain 与 decide 三个入口共享同一边界。边界不改变工具错误、Provider 瞬态重试和批次不确定恢复的既有语义。 + ## 资源画布交互与工作台状态同步 - 工作台向窗口标题栏发布正在运行的项目时,输入未变化不得形成重复发布与清理的渲染循环;打开项目动作始终使用当前工作台处理逻辑,退出工作台后清除其标题栏状态。