From 5d9223c32ef05160a2764bec12ad7c3c6f76cc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 22 Sep 2026 17:09:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E7=BB=88=E6=80=81=E7=AD=96?= =?UTF-8?q?=E7=95=A5=E7=8B=AC=E7=AB=8B=E6=88=90=E6=A8=A1=E5=9D=97=EF=BC=9A?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E3=80=81=E5=8E=9F=E5=9B=A0=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E4=B8=8E=20Drop=20=E5=85=9C=E5=BA=95=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent/direct_turn_failure.rs:LlmError → 稳定分类(timeout / model-failed / transport-failed / request-rejected)、判定"终态是不是失败"并给出脱敏截断后的原因、DirectTurnFailureGuard(turn.started 之后武装、写完终态 disarm,Drop 时补 host-dropped 失败终态) - 守卫兜底覆盖 panic / future 被丢弃 / 终态之前的早退;kill -9 与 turn.started 之前的早退写进模块注释,明确不为它们补路径 - agent.rs 注册模块并再导出 - 5 条用例:错误分类映射、只有 failed 终态带载荷、原因脱敏 + 按字符截断、armed 后 Drop 补终态、disarm 后不再产出事件 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_turn_failure.rs | 274 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 05b25f127..2ce5d4655 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,6 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_failure; mod direct_turn_metrics; mod direct_turn_stream; mod direct_validation; @@ -72,6 +73,7 @@ pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; +pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_stream::*; pub(crate) use direct_validation::DirectValidationConfig; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs new file mode 100644 index 000000000..0c5b18af7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -0,0 +1,274 @@ +//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己 +//! 提前收场时补一条失败终态。 +//! +//! 这个模块只有三件事,别再往里加第四件: +//! 1. [`direct_turn_failure_kind`]:把 `LlmError` 归到稳定分类(只给界面选语气); +//! 2. [`direct_turn_failure`]:判定这一轮的终态是不是失败,是的话给出脱敏后的原因; +//! 3. [`DirectTurnFailureGuard`]:`turn.started` 之后武装、写完终态解除的 Drop 兜底。 +//! +//! 失败载荷的**形状**属于线上协议,定义在 `direct_thread_wire.rs`(`DirectTurnFailure`); +//! 这里只负责"什么算失败、原因怎么写、什么时候兜底",不碰事件队列的搬运规则。 + +use std::path::Path; + +use platform_llm::LlmError; + +use super::{ + append_direct_thread_event, direct_tool_call_now_ms, redact_agent_runtime_error, + DirectThreadEvent, DirectTurnFailure, +}; + +/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 +/// 把整段上游报文塞进事件队列。 +const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; + +/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 +const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; +const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = + "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; + +/// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected`。 +/// +/// 分类只影响界面语气,前端不得拿它做流程分支(流程判据只有"收到终态事件"这一条)。 +fn direct_turn_failure_kind(error: &LlmError) -> &'static str { + match error { + LlmError::Timeout { .. } => "timeout", + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => "request-rejected", + LlmError::Connectivity { .. } | LlmError::Transport(_) | LlmError::StreamUnavailable => { + "transport-failed" + } + LlmError::Upstream { .. } | LlmError::EmptyResponse | LlmError::Deserialize(_) => { + "model-failed" + } + } +} + +/// 这一轮的终态是不是「失败」?是的话给出失败载荷(原因已脱敏并截断)。 +/// +/// 失败有两个来源,都必须进 `turn.completed(status="failed")` 的 `failure` 载荷: +/// - `collect_result` 是错误:真失败(模型 / 传输 / 历史落盘),原因直接从错误里取; +/// - `collect_result` 是交付报告、但 `status` 已经判成 `failed`:宿主收束了一个失败的回合, +/// 原因用那份报告本身(它本来就是给用户看的失败说明)。 +/// +/// 其余终态(`completed` / `interrupted` / `aborted`)都不是失败,返回 `None`,事件不带载荷。 +pub(crate) fn direct_turn_failure( + status: &str, + collect_result: Result<&str, &LlmError>, + history_root: &Path, +) -> Option { + let (kind, message) = match collect_result { + Err(error) => ( + direct_turn_failure_kind(error).to_string(), + error.to_string(), + ), + Ok(report) if status == "failed" => ("model-failed".to_string(), report.to_string()), + Ok(_) => return None, + }; + Some(DirectTurnFailure::new( + kind, + redact_agent_runtime_error( + history_root, + &message, + DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, + ), + )) +} + +/// 回合终态兜底守卫:`turn.started` 发出去之后,这一轮在宿主侧只剩两条收场路径——正常路径 +/// 写完终态事件(然后 [`Self::disarm`]),或者这个守卫的 `Drop`。 +/// +/// 兜底覆盖三种"走不到终态"的情况:panic 展开、future 被丢弃(任务 / 进程取消),以及今后在 +/// 终态事件之前新增的 `?` 早退。它们都再也没有机会补终态事件,前端只能永远停在"还在跑"; +/// 这里在 Drop 里补一条 `status="failed"` + `host-dropped` 的终态,让前端拿到收口依据。 +/// +/// 与 `CodexTurnGuard` / `CodexTurnStartGuard` 是**三件事**,不要合并:那两个守卫管的是 +/// app-server 连接与 `turn/start` 请求的回收,Drop 里不产出任何事件。 +/// +/// 已知边界(不为它加路径):宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,前端仍会停在 +/// 运行态;`turn.started` 之前的早退根本不武装这个守卫——没有开始就没有"未收口的回合"。 +pub(crate) struct DirectTurnFailureGuard { + thread_id: String, + user_item_id: Option, + armed: bool, +} + +impl DirectTurnFailureGuard { + /// 武装:调用点必须是 `turn.started` **已经**进入队列之后。 + pub(crate) fn arm(thread_id: String, user_item_id: Option) -> Self { + Self { + thread_id, + user_item_id, + armed: true, + } + } + + /// 解除:终态事件(正常或失败)已经写完,兜底不再需要。 + pub(crate) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for DirectTurnFailureGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + append_direct_thread_event( + &self.thread_id, + DirectThreadEvent::turn_completed_failed( + DirectTurnFailure::new( + DIRECT_TURN_FAILURE_HOST_DROPPED_KIND, + DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE, + ), + direct_tool_call_now_ms(), + ) + .with_user_item_id(self.user_item_id.as_deref()), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{consume_direct_thread, subscribe_direct_thread}; + + fn history_root() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/direct-turn-failure-test") + } + + #[test] + fn llm_error_variants_map_to_stable_kinds() { + assert_eq!( + direct_turn_failure_kind(&LlmError::Timeout { attempts: 3 }), + "timeout" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::InvalidConfig("missing key".into())), + "request-rejected" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::InvalidRequest("bad payload".into())), + "request-rejected" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Connectivity { + attempts: 2, + message: "reset".into(), + }), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Transport("stream closed".into())), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::StreamUnavailable), + "transport-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Upstream { + status_code: 500, + message: "boom".into(), + }), + "model-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::EmptyResponse), + "model-failed" + ); + assert_eq!( + direct_turn_failure_kind(&LlmError::Deserialize("bad json".into())), + "model-failed" + ); + } + + #[test] + fn only_failed_terminals_carry_a_failure_payload() { + // 正常终态:无论交付报告写了什么都不是失败。 + assert_eq!( + direct_turn_failure("completed", Ok("本轮交付已完成"), &history_root()), + None + ); + assert_eq!( + direct_turn_failure("interrupted", Ok("本轮已被终止"), &history_root()), + None + ); + assert_eq!( + direct_turn_failure("aborted", Ok("已结束这一轮占用"), &history_root()), + None + ); + + // 失败且拿得到错误:分类取自错误,原因取自错误文本。 + let error = + LlmError::Transport("DirectProject 收尾历史失败:写入 project.jsonl 失败".into()); + let failure = direct_turn_failure("failed", Err(&error), &history_root()) + .expect("transport error must produce a failure payload"); + assert_eq!(failure.kind, "transport-failed"); + assert!(failure.message.contains("收尾历史失败")); + + // 失败但拿到的是交付报告:宿主已经收束了这一轮,报告本身就是失败说明。 + let failure = direct_turn_failure( + "failed", + Ok("宿主尚未确认交付完成;请核对未完成项。"), + &history_root(), + ) + .expect("failed status must produce a failure payload"); + assert_eq!(failure.kind, "model-failed"); + assert_eq!(failure.message, "宿主尚未确认交付完成;请核对未完成项。"); + } + + #[test] + fn failure_message_is_redacted_and_truncated() { + let root = history_root(); + let with_path = format!("落盘失败:{} 不可写", root.display()); + let failure = direct_turn_failure("failed", Ok(&with_path), &history_root()) + .expect("failed status must produce a failure payload"); + assert!(!failure.message.contains("/tmp/direct-turn-failure-test")); + assert!(failure.message.contains("$PROJECT_ROOT")); + + let long = "x".repeat(4_000); + let failure = direct_turn_failure("failed", Ok(&long), &history_root()) + .expect("failed status must produce a failure payload"); + // 按字符截断,最多再多一个省略号标记。 + assert!(failure.message.chars().count() <= DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS + 1); + assert!(failure.message.ends_with('…')); + } + + /// 兜底:守卫武装后没被解除就 Drop,必须补一条失败终态(panic / future 被丢弃走的就是这条)。 + #[test] + fn armed_guard_appends_host_dropped_terminal_on_drop() { + let thread_id = "test-thread-failure-guard-armed"; + let subscription = subscribe_direct_thread(thread_id); + let guard = DirectTurnFailureGuard::arm( + thread_id.to_string(), + Some("direct-codex:turn-1:user".to_string()), + ); + drop(guard); + + let events = consume_direct_thread(&subscription.subscription_id) + .expect("consume guard terminal") + .events; + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::TurnCompleted { status, failure, user_item_id, .. }] + if status == "failed" + && failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped") + && user_item_id.as_deref() == Some("direct-codex:turn-1:user") + )); + } + + /// 解除之后就闭嘴:正常写完终态的回合不得再多出一条兜底终态。 + #[test] + fn disarmed_guard_appends_nothing() { + let thread_id = "test-thread-failure-guard-disarmed"; + let subscription = subscribe_direct_thread(thread_id); + let mut guard = DirectTurnFailureGuard::arm(thread_id.to_string(), None); + guard.disarm(); + drop(guard); + + assert!(consume_direct_thread(&subscription.subscription_id) + .expect("consume disarmed guard") + .events + .is_empty()); + } +}