From 9372845c35c7f6444b83b4ad5324de45fd03afc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 1 Oct 2026 13:30:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=86=85=E7=BD=AE=E5=B7=A5=E5=85=B7=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=94=B9=E6=88=90=20typed=20=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=A9=BF=E5=87=BA=E6=B4=BE=E5=8F=91=E8=BE=B9=E7=95=8C=EF=BC=8C?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=B8=A6=E5=8E=9F=E5=A7=8B=20error=EF=BC=8C?= =?UTF-8?q?=E8=BD=AF=E5=A4=B1=E8=B4=A5=E6=B8=85=E9=9B=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dispatch 返回 Result:不再在工具内部吞掉错误再补一个失败 Value,handle_direct_tool_bridge 在唯一一处 match 里同时组装发给模型的内容与诊断 - 新增 ToolCallError { message, redact_limit, error, images },error 直接存序列化后的 typed 错误;每个内置工具在自己的 agent/tool//error.rs 定义 typed enum,共用 case(参数不是对象 / 未审核字段等)抽到 agent/tool/error.rs 的 ToolFailure - AgentRuntimeErrorEvent 增加 error 字段携带原始 typed 错误,schema 升到 agent-runtime-error.v3;应用日志详情行扩成 message=… error=… metadata=…,各带独立长度预算 - 软失败清零:import_account_assets、run_validation、browser_playtest、environment_check、apply_patch、editor_execute、cocos_execute 原先返回 Ok(失败载荷) 的分支改为 typed 错误,并把 status / dispatched / retryAllowed 等载荷带进错误,模型仍能看到 - 失败证据截图走 ToolFailure::attached_images 与 ToolCallError.images,截图放 #[serde(skip)] 字段,不混进诊断里的 error - bridge_tool_result 去掉 is_error 入参,isError 只在组装处按 Ok/Err 分支写一次;MCP 线上 isError 字段与校验不变 - 修正上一提交遗留的两处 rustfmt:direct_tools_mcp.rs、runtime_tools/context.rs(同一 workspace 整体格式门禁要求) - 同步更新技术方案文档字段表与共享记忆决策记录 验证:cargo check --bin genarrative-ai-game-creator-shell --tests 通过;cargo test --bin genarrative-ai-game-creator-shell -- agent:: 951 passed / 0 failed;npm run check:encoding、git diff --check 通过;Windows 专属 cocos/unity/godot 分支在 Linux 下临时去 cfg 交叉编译通过(无真实 Windows 构建) --- .../src-tauri/src/agent/direct_runtime/mod.rs | 1 + .../src-tauri/src/agent/direct_tool_bridge.rs | 1297 +++++++++-------- .../src-tauri/src/agent/direct_tools_mcp.rs | 3 +- .../src/agent/generation/canvas_generation.rs | 13 +- .../src-tauri/src/agent/runtime_error.rs | 29 +- .../src-tauri/src/agent/runtime_state.rs | 1 + .../src/agent/runtime_tools/context.rs | 7 +- .../src/agent/tool/apply_patch/error.rs | 17 +- .../src/agent/tool/browser_playtest/error.rs | 25 +- .../src/agent/tool/cocos_execute/error.rs | 61 +- .../tool/create_or_derive_resource/error.rs | 59 +- .../src/agent/tool/delivery_status/error.rs | 2 +- .../src/agent/tool/edit_image/error.rs | 15 +- .../src/agent/tool/editor_execute/error.rs | 45 +- .../src/agent/tool/environment_check/error.rs | 21 +- .../src-tauri/src/agent/tool/error.rs | 44 +- .../src/agent/tool/generate_image/error.rs | 6 +- .../agent/tool/import_account_assets/error.rs | 74 +- .../agent/tool/list_account_assets/error.rs | 14 +- .../agent/tool/list_project_files/error.rs | 6 +- .../tool/list_registered_assets/error.rs | 6 +- .../src/agent/tool/prepare_game_art/error.rs | 12 +- .../agent/tool/read_project_context/error.rs | 2 +- .../tool/register_delivery_contract/error.rs | 2 +- .../src/agent/tool/remove_background/error.rs | 2 +- .../src/agent/tool/run_validation/error.rs | 25 +- .../src/agent/tool/update_plan/error.rs | 2 +- .../src/agent/tool/web_search/error.rs | 24 +- .../src/agent/tool/write_file/error.rs | 6 +- .../shared-memory/decision-log.md | 13 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- 31 files changed, 1045 insertions(+), 795 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index d22156f14..1e597ce61 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2013,6 +2013,7 @@ pub(crate) fn record_direct_codex_failure( stage.id(), error_code, &detail, + serde_json::to_value(failure).unwrap_or(serde_json::Value::Null), None, serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index a0edc08a9..2bf34446b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -20,10 +20,10 @@ use crate::agent::tool::error::{ }; use crate::agent::tool::generate_image::error::GenerateImageError; use crate::agent::tool::import_account_assets::error::ImportAccountAssetsError; +use crate::agent::tool::list_account_assets::error::ListAccountAssetsError; use crate::agent::tool::list_project_files::error::{ ListProjectFilesError, LIST_PROJECT_FILES_KINDS, }; -use crate::agent::tool::list_account_assets::error::ListAccountAssetsError; use crate::agent::tool::list_registered_assets::error::ListRegisteredAssetsError; use crate::agent::tool::prepare_game_art::error::{ ArtRegenerationAuthorizationRejection, PrepareGameArtError, @@ -193,7 +193,7 @@ struct DirectToolBridgeRequest { arguments: Value, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] pub(crate) enum DirectResourceGenerationKind { Image, Video, @@ -362,12 +362,11 @@ impl DirectToolBridgeState { brief: &str, ) -> Result { let brief_sha256 = direct_tool_bridge_brief_sha256(brief); - let mut authorization = self - .turn_authorization - .lock() - .map_err(|error| ArtRegenerationAuthorizationRejection::StateUnavailable { + let mut authorization = self.turn_authorization.lock().map_err(|error| { + ArtRegenerationAuthorizationRejection::StateUnavailable { cause: error.to_string(), - })?; + } + })?; let active = authorization .active .as_mut() @@ -396,15 +395,11 @@ impl DirectToolBridgeState { brief_sha256: &str, result: &Value, ) -> Result<(), ArtRegenerationAuthorizationRejection> { - if result.get("isError").and_then(Value::as_bool) != Some(false) { - return Err(ArtRegenerationAuthorizationRejection::CompletedResultNotSuccessful); - } - let mut authorization = self - .turn_authorization - .lock() - .map_err(|error| ArtRegenerationAuthorizationRejection::StateUnavailable { + let mut authorization = self.turn_authorization.lock().map_err(|error| { + ArtRegenerationAuthorizationRejection::StateUnavailable { cause: error.to_string(), - })?; + } + })?; let active = authorization .active .as_mut() @@ -510,28 +505,105 @@ pub(crate) fn compact_mcp_image_data(data: &str) -> Option<(String, &'static str None } -fn bridge_tool_result(text: String, images: Vec, is_error: bool) -> Value { +fn bridge_tool_result(text: String, images: Vec) -> Value { let mut content = vec![json!({ "type": "text", "text": text })]; - content.extend(images.into_iter().filter_map(|data| { - let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png")); - Some(json!({ "type": "image", "data": data, "mimeType": mime_type })) - })); - json!({ "content": content, "isError": is_error }) + content.extend(images.into_iter().map(bridge_tool_image_block)); + json!({ "content": content }) } -/// 工具失败的统一出口:错误类型只负责给出用户文案与截断预算,捕获处不做分类,也不算重试标志。 -fn bridge_tool_failure(root: &Path, error: &impl ToolFailure) -> Value { - bridge_tool_result( - redact_agent_runtime_error(root, &error.to_user_msg(), error.redact_limit()), +fn bridge_tool_image_block(data: String) -> Value { + let (data, mime_type) = compact_mcp_image_data(&data).unwrap_or((data, "image/png")); + json!({ "type": "image", "data": data, "mimeType": mime_type }) +} + +/// 把证据图追加到已经成形的工具结果上,供失败结果复用 `bridge_tool_failure` 的输出。 +fn bridge_append_tool_images(result: &mut Value, images: Vec) { + if images.is_empty() { + return; + } + let Some(content) = result.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + content.extend(images.into_iter().map(bridge_tool_image_block)); +} + +/// 工具失败在 dispatch 边界上的唯一载体:具体类型泛型进、具体字段出,不枚举、不 Box。 +/// +/// `message` / `redact_limit` 来自具体 [`ToolFailure`],`error` 是它的原样序列化。 +/// 结构化事实与给模型的那句话都靠它带出 dispatch,composer 一处写结果与诊断。 +#[derive(Debug)] +pub(crate) struct ToolCallError { + pub message: String, + pub redact_limit: usize, + pub error: Value, + /// 失败现场仍要回给模型的证据图;大多数失败没有。 + pub images: Vec, +} + +impl From<&T> for ToolCallError { + fn from(error: &T) -> Self { + Self { + message: error.to_user_msg(), + redact_limit: error.redact_limit(), + error: serde_json::to_value(error).unwrap_or(Value::Null), + images: error.attached_images(), + } + } +} + +/// 失败结果:`isError` 只在 composer 的分支上出现一次,工具与桥都不再手传这个布尔。 +fn bridge_tool_failure(root: &Path, error: &ToolCallError) -> Value { + let mut result = bridge_tool_result( + redact_agent_runtime_error(root, &error.message, error.redact_limit), Vec::new(), - true, - ) + ); + result["isError"] = Value::Bool(true); + result } -fn bridge_outcome(root: &Path, result: Result) -> Value { - match result { - Ok(value) => value, - Err(error) => bridge_tool_failure(root, &error), +/// 工具桥的唯一出口:派发只把失败原样端出来,写结果、补 MCP 完成包、落诊断都在这里一次完成。 +/// +/// `isError` 由分支决定:正常结果补 `false`,失败结果补 `true`;工具与桥都不再手传这个布尔。 +fn compose_direct_tool_outcome( + state: &DirectToolBridgeState, + tool: &str, + diagnostics_arguments: &str, + dispatch_denied: bool, + outcome: Result, +) -> Value { + match outcome { + Ok(mut value) => { + if let Value::Object(map) = &mut value { + map.insert("isError".to_string(), Value::Bool(false)); + } + value + } + Err(error) => { + // 诊断事件按回合归属:并发排障要能从错误记录认出是哪一轮调的工具。 + // 桥没被回合授权(外部客户端工具桥)或授权状态不可用时如实记为无回合。 + let client_turn_id = state.active_client_turn_id().ok().flatten(); + // `code` 只放稳定的工具名,供并发排障定位"是哪个工具、哪一轮";文案是 typed 错误 + // 写好的那一句,原始 typed 错误整体进 `error`,入参与上下文进 metadata。 + let _ = persist_agent_runtime_error( + &state.root, + client_turn_id.as_deref(), + "agc-tools", + "tool-execution", + tool, + &error.message, + error.error.clone(), + None, + json!({ + "tool": tool, + "arguments": diagnostics_arguments, + "directTurn": state.direct_turn_execution, + "dispatchDenied": dispatch_denied, + }), + ); + let mut value = bridge_tool_failure(&state.root, &error); + bridge_append_tool_images(&mut value, error.images); + value + } } } @@ -710,10 +782,7 @@ fn import_account_asset_string_array( arguments: &Value, field: AccountAssetImportField, ) -> Result, ImportAccountAssetsError> { - let Some(value) = arguments - .get(field.key()) - .filter(|value| !value.is_null()) - else { + let Some(value) = arguments.get(field.key()).filter(|value| !value.is_null()) else { return Ok(Vec::new()); }; let values = value @@ -770,7 +839,8 @@ pub(in crate::agent) fn account_asset_import_inputs( arguments: &Value, ) -> Result<(Vec, Vec), ImportAccountAssetsError> { ToolArgumentsRejection::check(arguments, &["assetIds", "localPaths"])?; - let asset_ids = import_account_asset_string_array(arguments, AccountAssetImportField::AssetIds)?; + let asset_ids = + import_account_asset_string_array(arguments, AccountAssetImportField::AssetIds)?; let local_paths = import_account_asset_string_array(arguments, AccountAssetImportField::LocalPaths)?; if asset_ids.is_empty() && local_paths.is_empty() { @@ -900,9 +970,7 @@ pub(in crate::agent) fn resource_generation_input( DirectResourceGenerationKind::CharacterAnimation, DirectResourceGenerationMode::Create, _, - ) => { - return Err(CreateOrDeriveResourceError::CharacterAnimationRequiresRegisteredSource) - } + ) => return Err(CreateOrDeriveResourceError::CharacterAnimationRequiresRegisteredSource), (_, DirectResourceGenerationMode::Create, Some(_)) => { return Err(CreateOrDeriveResourceError::CreateModeWithSource); } @@ -1029,8 +1097,8 @@ fn bridge_asset_canonical_resource_id(asset: &GameCreationAppAssetManifestEntry) .unwrap_or_else(|| format!("local-asset:{}", asset.id)) } -fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { - bridge_outcome(root, list_registered_assets(root, arguments)) +fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Result { + list_registered_assets(root, arguments).map_err(|error| ToolCallError::from(&error)) } /// `agc_list_registered_assets` 里会做文本边界校验的字段。 @@ -1146,30 +1214,28 @@ fn list_registered_assets( enforce_project_permission_policy_rejection(root, "file.list")?; let manifest = read_existing_manifest_for_project(root) .map_err(|cause| ProjectManifestRejection { cause })?; - let mut assets = manifest - .assets - .iter() - .filter(|asset| kind.is_none_or(|kind| asset.kind == kind)) - .filter(|asset| { - asset_id - .as_ref() - .is_none_or(|asset_id| asset.id == *asset_id) - }) - .collect::>(); - assets.sort_by(|left, right| { - (&left.local_path, &left.id).cmp(&(&right.local_path, &right.id)) - }); - let total = assets.len(); - let resources = assets - .into_iter() - .skip(offset) - .take(limit) - .map(|asset| bridge_registered_resource(asset, include_sequence_frames)) - .collect::>(); - let next_offset = (offset + resources.len() < total).then_some(offset + resources.len()); - let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount) - .then(current_platform_session) - .flatten(); + let mut assets = manifest + .assets + .iter() + .filter(|asset| kind.is_none_or(|kind| asset.kind == kind)) + .filter(|asset| { + asset_id + .as_ref() + .is_none_or(|asset_id| asset.id == *asset_id) + }) + .collect::>(); + assets.sort_by(|left, right| (&left.local_path, &left.id).cmp(&(&right.local_path, &right.id))); + let total = assets.len(); + let resources = assets + .into_iter() + .skip(offset) + .take(limit) + .map(|asset| bridge_registered_resource(asset, include_sequence_frames)) + .collect::>(); + let next_offset = (offset + resources.len() < total).then_some(offset + resources.len()); + let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount) + .then(current_platform_session) + .flatten(); let pending = list_pending_local_project_resource_edits_for_session_at( ListPendingLocalProjectResourceEditsInput { project_path: root @@ -1181,21 +1247,21 @@ fn list_registered_assets( platform_session.as_ref(), ) .map_err(|cause| ListRegisteredAssetsError::PendingEditsUnreadable { cause })? - .into_iter() - .map(|edit| { - json!({ - "operationId": edit.operation_id, - "kind": edit.edit_kind, - "mode": edit.generation_mode, - "sourceResourceId": edit.source_resource_id, - "assetName": edit.asset_name, - "backgroundMode": edit.background_mode, - "screenColor": edit.screen_color, - "phase": edit.phase, - "createdAt": edit.created_at, - }) + .into_iter() + .map(|edit| { + json!({ + "operationId": edit.operation_id, + "kind": edit.edit_kind, + "mode": edit.generation_mode, + "sourceResourceId": edit.source_resource_id, + "assetName": edit.asset_name, + "backgroundMode": edit.background_mode, + "screenColor": edit.screen_color, + "phase": edit.phase, + "createdAt": edit.created_at, }) - .collect::>(); + }) + .collect::>(); Ok(bridge_tool_result( json!({ "status": "completed", @@ -1208,7 +1274,6 @@ fn list_registered_assets( }) .to_string(), Vec::new(), - false, )) } @@ -1365,8 +1430,8 @@ fn list_external_read_files(dir: &Path) -> Result, String> { Ok(files) } -fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { - bridge_outcome(root, list_project_files(root, arguments)) +fn bridge_list_project_files(root: &Path, arguments: &Value) -> Result { + list_project_files(root, arguments).map_err(|error| ToolCallError::from(&error)) } /// `agc_list_project_files` 里会做文本边界校验的字段。 @@ -1507,116 +1572,115 @@ fn list_project_files(root: &Path, arguments: &Value) -> Result>(); - if scope - .as_ref() - .is_some_and(|scope| scope.project_relative.is_none()) - { - let scope = scope.expect("external list scope"); - let mut files = list_external_read_files(&scope.absolute) - .map_err(|cause| ListProjectFilesError::ExternalListingUnreadable { cause })? - .into_iter() - .filter(|(path, _)| { - let (category, _) = bridge_project_file_class(path); - requested_kind == "all" || requested_kind == category - }) - .filter(|(path, _)| { - query - .as_deref() - .is_none_or(|query| path.to_lowercase().contains(query)) - }) - .collect::>(); - files.sort_by(|left, right| left.0.cmp(&right.0)); - let total = files.len(); - let page = files - .drain(..) - .skip(offset) - .take(limit) - .map(|(path, size)| { - let (category, media_type) = bridge_project_file_class(&path); - json!({ - "path": path, - "sizeBytes": size, - "kind": category, - "mediaType": media_type, - "assetImportable": false, - "registered": false, - "localAssetId": Value::Null, - }) - }) - .collect::>(); - let next_offset = (offset + page.len() < total).then_some(offset + page.len()); - return Ok(bridge_tool_result( - json!({ - "status": "completed", - "total": total, - "offset": offset, - "limit": limit, - "nextOffset": next_offset, - "files": page, - "next": "这些路径在当前项目外,可用 agc_read_project_context 读取。" - }) - .to_string(), - Vec::new(), - false, - )); - } - let scope_relative = scope - .and_then(|scope| scope.project_relative) - .filter(|path| !path.is_empty()); - let listed = list_local_project_files_at(root) - .map_err(|cause| ListProjectFilesError::ProjectListingUnreadable { cause })?; - let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); - let mut files = listed - .files + let registered_ids = manifest + .assets + .iter() + .map(|asset| (asset.local_path.clone(), asset.id.clone())) + .collect::>(); + if scope + .as_ref() + .is_some_and(|scope| scope.project_relative.is_none()) + { + let scope = scope.expect("external list scope"); + let mut files = list_external_read_files(&scope.absolute) + .map_err(|cause| ListProjectFilesError::ExternalListingUnreadable { cause })? .into_iter() - .filter(|file| file.kind == "file") - .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path)) - .filter(|file| !should_skip_project_snapshot_path(&file.path)) - .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) - .filter(|file| { - scope_relative.as_ref().is_none_or(|scope| { - file.path == *scope - || scope_prefix - .as_ref() - .is_some_and(|prefix| file.path.starts_with(prefix)) - }) - }) - .filter(|file| { - let (category, _) = bridge_project_file_class(&file.path); + .filter(|(path, _)| { + let (category, _) = bridge_project_file_class(path); requested_kind == "all" || requested_kind == category }) - .filter(|file| { + .filter(|(path, _)| { query .as_deref() - .is_none_or(|query| file.path.to_lowercase().contains(query)) + .is_none_or(|query| path.to_lowercase().contains(query)) }) .collect::>(); - files.sort_by(|left, right| left.path.cmp(&right.path)); + files.sort_by(|left, right| left.0.cmp(&right.0)); let total = files.len(); let page = files .drain(..) .skip(offset) .take(limit) - .map(|file| { - let (category, media_type) = bridge_project_file_class(&file.path); + .map(|(path, size)| { + let (category, media_type) = bridge_project_file_class(&path); json!({ - "path": file.path, - "sizeBytes": file.size, + "path": path, + "sizeBytes": size, "kind": category, "mediaType": media_type, - "assetImportable": bridge_project_file_is_asset_importable(&file.path), - "registered": registered_ids.contains_key(&file.path), - "localAssetId": registered_ids.get(&file.path), + "assetImportable": false, + "registered": false, + "localAssetId": Value::Null, }) }) .collect::>(); let next_offset = (offset + page.len() < total).then_some(offset + page.len()); - Ok(bridge_tool_result( + return Ok(bridge_tool_result( + json!({ + "status": "completed", + "total": total, + "offset": offset, + "limit": limit, + "nextOffset": next_offset, + "files": page, + "next": "这些路径在当前项目外,可用 agc_read_project_context 读取。" + }) + .to_string(), + Vec::new(), + )); + } + let scope_relative = scope + .and_then(|scope| scope.project_relative) + .filter(|path| !path.is_empty()); + let listed = list_local_project_files_at(root) + .map_err(|cause| ListProjectFilesError::ProjectListingUnreadable { cause })?; + let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); + let mut files = listed + .files + .into_iter() + .filter(|file| file.kind == "file") + .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path)) + .filter(|file| !should_skip_project_snapshot_path(&file.path)) + .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) + .filter(|file| { + scope_relative.as_ref().is_none_or(|scope| { + file.path == *scope + || scope_prefix + .as_ref() + .is_some_and(|prefix| file.path.starts_with(prefix)) + }) + }) + .filter(|file| { + let (category, _) = bridge_project_file_class(&file.path); + requested_kind == "all" || requested_kind == category + }) + .filter(|file| { + query + .as_deref() + .is_none_or(|query| file.path.to_lowercase().contains(query)) + }) + .collect::>(); + files.sort_by(|left, right| left.path.cmp(&right.path)); + let total = files.len(); + let page = files + .drain(..) + .skip(offset) + .take(limit) + .map(|file| { + let (category, media_type) = bridge_project_file_class(&file.path); + json!({ + "path": file.path, + "sizeBytes": file.size, + "kind": category, + "mediaType": media_type, + "assetImportable": bridge_project_file_is_asset_importable(&file.path), + "registered": registered_ids.contains_key(&file.path), + "localAssetId": registered_ids.get(&file.path), + }) + }) + .collect::>(); + let next_offset = (offset + page.len() < total).then_some(offset + page.len()); + Ok(bridge_tool_result( json!({ "status": "completed", "total": total, @@ -1628,13 +1692,13 @@ fn list_project_files(root: &Path, arguments: &Value) -> Result Value { - bridge_outcome(root, bridge_write_file_with_permit(root, arguments, None)) +fn bridge_write_file(root: &Path, arguments: &Value) -> Result { + bridge_write_file_with_permit(root, arguments, None) + .map_err(|error| ToolCallError::from(&error)) } fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option { @@ -1769,7 +1833,6 @@ fn bridge_write_file_with_permit( }) .to_string(), Vec::new(), - false, )) } @@ -1811,8 +1874,13 @@ fn bridge_safe_account_asset_projection(asset: &Value) -> Option { })) } -async fn bridge_list_account_assets(state: &DirectToolBridgeState, arguments: &Value) -> Value { - bridge_outcome(&state.root, list_account_assets(state, arguments).await) +async fn bridge_list_account_assets( + state: &DirectToolBridgeState, + arguments: &Value, +) -> Result { + list_account_assets(state, arguments) + .await + .map_err(|error| ToolCallError::from(&error)) } /// `agc_list_account_assets` 里会做文本边界校验的字段。 @@ -1880,8 +1948,8 @@ pub(in crate::agent) fn list_account_assets_input( ) -> Result { ToolArgumentsRejection::check(arguments, &["folderId", "query", "offset", "limit"])?; let folder_id = optional_text(arguments, AccountAssetTextField::FolderId)?; - let query = optional_text(arguments, AccountAssetTextField::Query)? - .map(|value| value.to_lowercase()); + let query = + optional_text(arguments, AccountAssetTextField::Query)?.map(|value| value.to_lowercase()); let page = page( arguments, 100, @@ -1912,41 +1980,41 @@ async fn list_account_assets( let value = list_editor_assets_for_agent_at(&state.root) .await .map_err(|cause| ListAccountAssetsError::AccountAssetsUnreadable { cause })?; - let assets = value - .get("assets") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter_map(|asset| bridge_safe_account_asset_projection(&asset)) - .filter(|asset| { - folder_id.as_deref().is_none_or(|folder| { - asset.get("folderId").and_then(Value::as_str) == Some(folder) - }) + let assets = value + .get("assets") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|asset| bridge_safe_account_asset_projection(&asset)) + .filter(|asset| { + folder_id + .as_deref() + .is_none_or(|folder| asset.get("folderId").and_then(Value::as_str) == Some(folder)) + }) + .filter(|asset| { + query.as_deref().is_none_or(|query| { + let label = asset + .get("label") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + let folder_label = asset + .get("folderLabel") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + label.contains(query) || folder_label.contains(query) }) - .filter(|asset| { - query.as_deref().is_none_or(|query| { - let label = asset - .get("label") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - let folder_label = asset - .get("folderLabel") - .and_then(Value::as_str) - .unwrap_or_default() - .to_lowercase(); - label.contains(query) || folder_label.contains(query) - }) - }) - .collect::>(); - let total = assets.len(); - let page = assets - .into_iter() - .skip(offset) - .take(limit) - .collect::>(); - let next_offset = (offset + page.len() < total).then_some(offset + page.len()); + }) + .collect::>(); + let total = assets.len(); + let page = assets + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + let next_offset = (offset + page.len() < total).then_some(offset + page.len()); Ok(bridge_tool_result( json!({ "status": "completed", @@ -1959,7 +2027,6 @@ async fn list_account_assets( }) .to_string(), Vec::new(), - false, )) } @@ -1971,56 +2038,56 @@ async fn bridge_import_account_assets( let (asset_ids, local_paths) = account_asset_import_inputs(arguments)?; enforce_project_permission_policy_rejection(&state.root, "canvas.asset_import")?; let revision_before = read_game_creator_agent_runtime_project_revision(&state.root) - .map(|revision| revision.revision) - .unwrap_or_default(); + .map(|revision| revision.revision) + .unwrap_or_default(); let mut imported = Vec::new(); let mut failures = Vec::new(); if !asset_ids.is_empty() { - match crate::commands::import_account_editor_assets_for_agent_with_write_permit( - &state.root, - &asset_ids, - write_permit.as_ref(), - ) - .await - { - Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { - let source = read_existing_manifest_for_project(&state.root) - .ok() - .and_then(|manifest| { - manifest.assets.into_iter().find(|item| item.id == asset.id) - }) - .filter(|item| { - item.source.kind == GameCreationAppAssetSourceKind::Canvas - && item.source.canvas_project_id.is_some() - }) - .map(|_| "project-canvas") - .unwrap_or("account"); - json!({ - "id": asset.id, - "localPath": asset.local_path, - "assetKind": asset.asset_kind, - "source": source, + match crate::commands::import_account_editor_assets_for_agent_with_write_permit( + &state.root, + &asset_ids, + write_permit.as_ref(), + ) + .await + { + Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { + let source = read_existing_manifest_for_project(&state.root) + .ok() + .and_then(|manifest| { + manifest.assets.into_iter().find(|item| item.id == asset.id) }) - })), - Err(error) => failures.push(redact_agent_runtime_error(&state.root, &error, 360)), - } + .filter(|item| { + item.source.kind == GameCreationAppAssetSourceKind::Canvas + && item.source.canvas_project_id.is_some() + }) + .map(|_| "project-canvas") + .unwrap_or("account"); + json!({ + "id": asset.id, + "localPath": asset.local_path, + "assetKind": asset.asset_kind, + "source": source, + }) + })), + Err(error) => failures.push(redact_agent_runtime_error(&state.root, &error, 360)), + } } if !local_paths.is_empty() { - match crate::commands::import_local_project_assets_for_agent_with_write_permit( - &state.root, - &local_paths, - write_permit.as_ref(), - ) { - Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { - json!({ - "id": asset.id, - "localPath": asset.local_path, - "assetKind": asset.asset_kind, - "source": "local", - }) - })), - Err(error) => failures.push(redact_agent_runtime_error(&state.root, &error, 360)), - } + match crate::commands::import_local_project_assets_for_agent_with_write_permit( + &state.root, + &local_paths, + write_permit.as_ref(), + ) { + Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { + json!({ + "id": asset.id, + "localPath": asset.local_path, + "assetKind": asset.asset_kind, + "source": "local", + }) + })), + Err(error) => failures.push(redact_agent_runtime_error(&state.root, &error, 360)), + } } // Direct tools run outside the normal Runtime action loop. Keep the // workbench's manifest projection in sync with the durable import so @@ -2044,11 +2111,11 @@ async fn bridge_import_account_assets( "failures": failures, "revisionAdvanceCount": revision_after.saturating_sub(revision_before), }); - Ok(bridge_tool_result( - value.to_string(), - Vec::new(), - status == "failed" || (write_permit.is_some() && status == "partial"), - )) + match status { + "failed" => Err(ImportAccountAssetsError::ImportFailed { report: value }), + "partial" => Err(ImportAccountAssetsError::ImportPartial { report: value }), + _ => Ok(bridge_tool_result(value.to_string(), Vec::new())), + } } fn bridge_completed_resource_result( @@ -2061,11 +2128,9 @@ fn bridge_completed_resource_result( .asset .as_ref() .ok_or(ResourceCompletionRejection::MissingAsset)?; - let (warnings, slice_warnings) = local_project_resource_edit_warnings_at( - root, - &result.operation_id, - ) - .map_err(|cause| ResourceCompletionRejection::WarningsUnreadable { cause })?; + let (warnings, slice_warnings) = + local_project_resource_edit_warnings_at(root, &result.operation_id) + .map_err(|cause| ResourceCompletionRejection::WarningsUnreadable { cause })?; Ok(json!({ "status": "completed", "operationId": result.operation_id, @@ -2099,52 +2164,51 @@ async fn bridge_create_or_derive_resource( let manifest = read_existing_manifest_for_project(&state.root) .map_err(|cause| ProjectManifestRejection { cause })?; let source_asset = input - .source_local_asset_id - .as_deref() - .map(|asset_id| { - manifest - .assets - .iter() - .find(|asset| asset.id == asset_id) - .cloned() - .ok_or(CreateOrDeriveResourceError::SourceAssetNotRegistered) - }) - .transpose()?; + .source_local_asset_id + .as_deref() + .map(|asset_id| { + manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .cloned() + .ok_or(CreateOrDeriveResourceError::SourceAssetNotRegistered) + }) + .transpose()?; let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes())); - let pending = list_pending_local_project_resource_edits_at( - ListPendingLocalProjectResourceEditsInput { + let pending = + list_pending_local_project_resource_edits_at(ListPendingLocalProjectResourceEditsInput { project_path: state .root .to_str() .ok_or(CreateOrDeriveResourceError::ProjectPathUnprojectable)? .to_string(), expected_project_id: manifest.project_id.clone(), - }, - ) - .map_err(|cause| CreateOrDeriveResourceError::PendingEditsUnreadable { cause })?; + }) + .map_err(|cause| CreateOrDeriveResourceError::PendingEditsUnreadable { cause })?; let matching_pending = pending - .into_iter() - .filter(|pending| { - pending.edit_kind == input.kind.edit_kind() - && pending.generation_mode == input.mode.project_mode() - && pending.source_asset_id.as_deref() == input.source_local_asset_id.as_deref() - && pending.asset_name == input.asset_name - && pending.prompt_sha256 == prompt_sha256 - }) - .collect::>(); + .into_iter() + .filter(|pending| { + pending.edit_kind == input.kind.edit_kind() + && pending.generation_mode == input.mode.project_mode() + && pending.source_asset_id.as_deref() == input.source_local_asset_id.as_deref() + && pending.asset_name == input.asset_name + && pending.prompt_sha256 == prompt_sha256 + }) + .collect::>(); if matching_pending.len() > 1 { return Err(CreateOrDeriveResourceError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - with_direct_editor_api_credentials(resume_local_project_resource_edit_at( - ResumeLocalProjectResourceEditInput { - project_path: state.root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id, - operation_id: pending.operation_id, - }, - )) - .await - .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? + with_direct_editor_api_credentials(resume_local_project_resource_edit_at( + ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id, + operation_id: pending.operation_id, + }, + )) + .await + .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? } else { let turn_id = state .active_resource_turn_id() @@ -2157,31 +2221,31 @@ async fn bridge_create_or_derive_resource( .map_err(|cause| CreateOrDeriveResourceError::ProjectRevisionUnavailable { cause })? .revision; let source_resource_id = source_asset - .as_ref() - .map(bridge_asset_canonical_resource_id) - .unwrap_or_else(|| format!("create:{operation_id}")); + .as_ref() + .map(bridge_asset_canonical_resource_id) + .unwrap_or_else(|| format!("create:{operation_id}")); let request = DeriveLocalProjectResourceInput { - project_path: state.root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id, - expected_project_revision: revision, - operation_id, - idempotency_key, - edit_kind: input.kind.edit_kind(), - generation_mode: input.mode.project_mode(), - source_resource_id, - source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()), - source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()), - source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()), - source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()), - producer_task_id: source_asset - .as_ref() - .and_then(|asset| asset.source.task_id.clone()), - source_version_id: None, - prompt: input.prompt.clone(), - asset_name: input.asset_name.clone(), - background_mode: None, - screen_color: None, - }; + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id, + expected_project_revision: revision, + operation_id, + idempotency_key, + edit_kind: input.kind.edit_kind(), + generation_mode: input.mode.project_mode(), + source_resource_id, + source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()), + source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()), + source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()), + source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()), + producer_task_id: source_asset + .as_ref() + .and_then(|asset| asset.source.task_id.clone()), + source_version_id: None, + prompt: input.prompt.clone(), + asset_name: input.asset_name.clone(), + background_mode: None, + screen_color: None, + }; with_direct_editor_api_credentials(derive_local_project_resource_at(request)) .await .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? @@ -2192,7 +2256,7 @@ async fn bridge_create_or_derive_resource( input.mode.as_str(), completed, )?; - Ok(bridge_tool_result(result.to_string(), Vec::new(), false)) + Ok(bridge_tool_result(result.to_string(), Vec::new())) } /// `agc_remove_background` 里会做文本边界校验的字段。 @@ -2278,11 +2342,12 @@ pub(in crate::agent) fn remove_background_input( { None => "complex".to_string(), Some(value) => { - let mode = value - .as_str() - .ok_or_else(|| RemoveBackgroundError::BackgroundModeNotText { - got: value.to_string(), - })?; + let mode = + value + .as_str() + .ok_or_else(|| RemoveBackgroundError::BackgroundModeNotText { + got: value.to_string(), + })?; if !matches!(mode, "complex" | "flat") { return Err(RemoveBackgroundError::BackgroundModeUnsupported { got: mode.to_string(), @@ -2291,7 +2356,10 @@ pub(in crate::agent) fn remove_background_input( mode.to_string() } }; - let screen_color = match arguments.get("screenColor").filter(|value| !value.is_null()) { + let screen_color = match arguments + .get("screenColor") + .filter(|value| !value.is_null()) + { None => None, Some(value) => { let color = @@ -2302,7 +2370,9 @@ pub(in crate::agent) fn remove_background_input( })?; let valid_hex = color.len() == 7 && color.starts_with('#') - && color[1..].chars().all(|character| character.is_ascii_hexdigit()); + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit()); if color != "auto" && !valid_hex { return Err(RemoveBackgroundError::ScreenColorMalformed { got: color.to_string(), @@ -2340,9 +2410,10 @@ async fn bridge_remove_background( }) .await; match result { - Ok(payload) => Ok(bridge_tool_result(payload.to_string(), Vec::new(), false)), - Err(cause) => Err(failure - .unwrap_or(RemoveBackgroundError::CredentialsUnavailable { cause })), + Ok(payload) => Ok(bridge_tool_result(payload.to_string(), Vec::new())), + Err(cause) => { + Err(failure.unwrap_or(RemoveBackgroundError::CredentialsUnavailable { cause })) + } } } @@ -2451,7 +2522,8 @@ async fn remove_background_payload( .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? }; emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); - bridge_completed_resource_result(&state.root, "background-removal", "derive", completed).map_err(Into::into) + bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) + .map_err(Into::into) } fn background_removal_request_fingerprint( @@ -2529,7 +2601,6 @@ async fn bridge_prepare_game_art_validated( }) .to_string(), images, - false, )) } @@ -3050,7 +3121,6 @@ async fn bridge_generate_image( }) .to_string(), images, - false, )) } @@ -3153,13 +3223,19 @@ async fn bridge_browser_playtest( bridge_validation_result( &state.root, result.map_err(|cause| BrowserPlaytestError::PlaytestFailed { cause }), + |report, screenshots| BrowserPlaytestError::PlaytestNotPassed { + report, + screenshots, + }, ) } -/// 验证类工具的成功投影:截图转成 MCP image block,`passed != true` 的既有结果仍按失败标记。 +/// 验证类工具的结果投影:`passed != true` 交给各工具自己的「未通过」变体, +/// 通过时把截图转成 MCP image block 一起回。 fn bridge_validation_result( root: &Path, result: Result, + not_passed: fn(Value, Vec) -> E, ) -> Result { let result = result?; let images = result @@ -3172,8 +3248,10 @@ fn bridge_validation_result( .filter_map(|relative| resolve_local_project_path(root, relative).ok()) .filter_map(|path| bridge_png_content(root, &path).ok()) .collect(); - let failed = result["passed"] != true; - Ok(bridge_tool_result(result.to_string(), images, failed)) + if result["passed"] != true { + return Err(not_passed(result, images)); + } + Ok(bridge_tool_result(result.to_string(), images)) } async fn bridge_environment_check( @@ -3183,8 +3261,10 @@ async fn bridge_environment_check( ToolArgumentsRejection::check(arguments, &[])?; enforce_project_permission_policy_rejection(root, "game.run_local")?; let result = crate::environment_check::check_environment(root).await; - let failed = result["status"] != "ready"; - Ok(bridge_tool_result(result.to_string(), Vec::new(), failed)) + if result["status"] != "ready" { + return Err(EnvironmentCheckError::EnvironmentNotReady { report: result }); + } + Ok(bridge_tool_result(result.to_string(), Vec::new())) } async fn bridge_read_project_context( @@ -3194,15 +3274,17 @@ async fn bridge_read_project_context( let value = super::direct_project_context::read_project_context(root, arguments) .await .map_err(|cause| ReadProjectContextError::ContextReadFailed { cause })?; - Ok(bridge_tool_result(value.to_string(), Vec::new(), false)) + Ok(bridge_tool_result(value.to_string(), Vec::new())) } async fn bridge_apply_patch(root: &Path, arguments: &Value) -> Result { let value = super::direct_patch::apply(root, arguments) .await .map_err(|cause| ApplyPatchError::PatchRejected { cause })?; - let failed = value["status"] != "completed"; - Ok(bridge_tool_result(value.to_string(), Vec::new(), failed)) + if value["status"] != "completed" { + return Err(ApplyPatchError::PatchNotApplied { report: value }); + } + Ok(bridge_tool_result(value.to_string(), Vec::new())) } async fn bridge_register_delivery_contract( @@ -3217,7 +3299,7 @@ async fn bridge_register_delivery_contract( let value = super::direct_delivery::register_contract(&state.root, &session, arguments) .await .map_err(|cause| RegisterDeliveryContractError::ContractRejected { cause })?; - Ok(bridge_tool_result(value.to_string(), Vec::new(), false)) + Ok(bridge_tool_result(value.to_string(), Vec::new())) } async fn bridge_delivery_status( @@ -3236,7 +3318,7 @@ async fn bridge_delivery_status( let value = super::direct_delivery::status(&state.root, &session) .await .map_err(|cause| DeliveryStatusError::StatusUnavailable { cause })?; - Ok(bridge_tool_result(value.to_string(), Vec::new(), false)) + Ok(bridge_tool_result(value.to_string(), Vec::new())) } async fn bridge_update_plan( @@ -3244,27 +3326,24 @@ async fn bridge_update_plan( arguments: &Value, ) -> Result { let root = state.root.clone(); - let session = match tokio::task::spawn_blocking(move || { - super::direct_execution::current(&root) - }) - .await - { - Ok(Ok(session)) => session, - Ok(Err(cause)) => { - return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionUnavailable { cause }, - )) - } - Err(_) => { - return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionTaskLost, - )) - } - }; + let session = + match tokio::task::spawn_blocking(move || super::direct_execution::current(&root)).await { + Ok(Ok(session)) => session, + Ok(Err(cause)) => { + return Err(UpdatePlanError::Gate( + DirectExecutionGateRejection::SessionUnavailable { cause }, + )) + } + Err(_) => { + return Err(UpdatePlanError::Gate( + DirectExecutionGateRejection::SessionTaskLost, + )) + } + }; let value = super::direct_delivery::update_plan(&state.root, &session, arguments) .await .map_err(|cause| UpdatePlanError::PlanRejected { cause })?; - Ok(bridge_tool_result(value.to_string(), Vec::new(), false)) + Ok(bridge_tool_result(value.to_string(), Vec::new())) } fn build_controlled_search_client() -> Result { @@ -3381,11 +3460,14 @@ async fn bridge_web_search_at( } let mut bytes = Vec::new(); let mut response = response; - while let Some(chunk) = response.chunk().await.map_err(|error| { - WebSearchError::ResponseReadFailed { - cause: error.to_string(), - } - })? { + while let Some(chunk) = + response + .chunk() + .await + .map_err(|error| WebSearchError::ResponseReadFailed { + cause: error.to_string(), + })? + { if bytes.len() + chunk.len() > DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESPONSE_BYTES { return Err(WebSearchError::ResponseTooLarge { max_bytes: DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESPONSE_BYTES, @@ -3413,7 +3495,6 @@ async fn bridge_web_search_at( }) .to_string(), Vec::new(), - false, )) } @@ -3470,19 +3551,19 @@ async fn bridge_editor_execute( .await; match result { Ok(Ok(response)) => { - let failed = response["ok"] != true || response["status"] != "completed"; + if response["ok"] != true || response["status"] != "completed" { + return Err(EditorExecuteError::ExecutionNotCompleted { + editor, + report: response, + }); + } Ok(bridge_tool_result( redact_agent_runtime_error(&state.root, &response.to_string(), 32_000), Vec::new(), - failed, )) } Ok(Err(error)) => Err(error), - Err(_) => Ok(bridge_tool_result( - json!({"ok":false,"status":"needs-reconciliation","dispatched":true,"retryAllowed":false,"error":format!("{} 执行任务异常,请人工核对结果", editor.label())}).to_string(), - Vec::new(), - true, - )), + Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }), } } @@ -3604,20 +3685,10 @@ async fn bridge_cocos_call( } let code = cocos_execute_code(state, arguments, operation)?; let Ok(mut uncertain) = state.cocos_execute_uncertain.try_lock() else { - return Ok(bridge_tool_result( - json!({"status":"failed","retryAllowed":false,"message":"已有 Cocos 操作执行中,请等待回执"}).to_string(), - Vec::new(), true)); + return Err(CocosExecuteError::ExecutionBusy); }; if *uncertain { - return Ok(bridge_tool_result( - json!({ - "status": "needs-reconciliation", "retryAllowed": false, - "message": "先前 Cocos execute 结果待核对,当前 bridge 不再发送执行命令" - }) - .to_string(), - Vec::new(), - true, - )); + return Err(CocosExecuteError::ReconciliationPending); } let root = state.root.clone(); let timeout_ms = if operation.is_some() { @@ -3656,10 +3727,10 @@ async fn bridge_cocos_call( } else { "failed".to_string() }; - let is_error = !response.ok || status != "completed"; if status == "needs-reconciliation" { *uncertain = true; } + let completed = response.ok && status == "completed"; // 截图以 MCP image block 返回,不能被文本截断破坏 base64。 let mut images = Vec::new(); if operation == Some("cocos_preview_debug_capture") { @@ -3677,17 +3748,22 @@ async fn bridge_cocos_call( } } } - let text = json!({ + let report = json!({ "status": status, "requestId": response.request_id, "result": result, "error": response.error, - }) - .to_string(); + }); + if !completed { + return Err(CocosExecuteError::ExecutionNotCompleted { + report, + screenshots: images, + }); + } Ok(bridge_tool_result( redact_agent_runtime_project_paths( &state.root, - &text, + &report.to_string(), if operation.is_some() { 2 * 1024 * 1024 } else { @@ -3695,7 +3771,6 @@ async fn bridge_cocos_call( }, ), images, - is_error, )) } failed => { @@ -3714,16 +3789,14 @@ async fn bridge_cocos_call( Ok(Ok(_)) => unreachable!(), }; *uncertain = is_uncertain; - Ok(bridge_tool_result( - json!({ + Err(CocosExecuteError::ExecutionNotCompleted { + report: json!({ "status": if is_uncertain { "needs-reconciliation" } else { "failed" }, "retryAllowed": !is_uncertain, - "message": redact_agent_runtime_error(&state.root, &error, 480), - }) - .to_string(), - Vec::new(), - true, - )) + "message": error, + }), + screenshots: Vec::new(), + }) } } } @@ -3732,6 +3805,9 @@ async fn handle_direct_tool_bridge( State(state): State>, Json(request): Json, ) -> Json { + // 失败诊断要记"传了什么":入参在派发时会被 move 走,先按脱敏口径留一份。 + let diagnostics_arguments = + redact_agent_runtime_error(&state.root, &request.arguments.to_string(), 480); let effect = match request.tool.as_str() { "agc_write_file" | "agc_import_account_assets" => { Some(super::direct_execution::EffectKind::Write) @@ -3769,15 +3845,25 @@ async fn handle_direct_tool_bridge( { Ok(Ok(lease)) => Some(lease), Ok(Err(cause)) => { - return Json(bridge_tool_failure( - &state.root, - &DirectExecutionGateRejection::PermitUnavailable { cause }, + return Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + false, + Err(ToolCallError::from( + &DirectExecutionGateRejection::PermitUnavailable { cause }, + )), )); } Err(_) => { - return Json(bridge_tool_failure( - &state.root, - &DirectExecutionGateRejection::PermitTaskLost, + return Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + false, + Err(ToolCallError::from( + &DirectExecutionGateRejection::PermitTaskLost, + )), )); } } @@ -3795,9 +3881,14 @@ async fn handle_direct_tool_bridge( { Ok(scope) => scope, Err(cause) => { - return Json(bridge_tool_failure( - &state.root, - &DirectExecutionGateRejection::PermitUnavailable { cause }, + return Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + false, + Err(ToolCallError::from( + &DirectExecutionGateRejection::PermitUnavailable { cause }, + )), )); } } @@ -3812,163 +3903,143 @@ async fn handle_direct_tool_bridge( { Ok(permit) => permit, Err(cause) => { - return Json(bridge_tool_failure( - &state.root, - &DirectExecutionGateRejection::PermitUnavailable { cause }, + return Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + false, + Err(ToolCallError::from( + &DirectExecutionGateRejection::PermitUnavailable { cause }, + )), )); } } } else { None }; - // 失败诊断要记"传了什么":入参在派发时会被 move 走,先按脱敏口径留一份。 - let diagnostics_arguments = - redact_agent_runtime_error(&state.root, &request.arguments.to_string(), 480); let dispatch = async { match request.tool.as_str() { // 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。 - "builtin.plugins.tools" => bridge_tool_result( + "builtin.plugins.tools" => Ok(bridge_tool_result( json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(), Vec::new(), - false, - ), - "taonier_prepare_game_art" => bridge_outcome( - &state.root, - bridge_prepare_game_art(&state, &request.arguments).await, - ), - "agc_generate_image" => { - bridge_outcome( - &state.root, - bridge_generate_image(&state, &request.arguments).await, - ) - } - "agc_edit_image" => bridge_outcome( - &state.root, - bridge_edit_image(&state, &request.arguments).await, - ), + )), + "taonier_prepare_game_art" => bridge_prepare_game_art(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_generate_image" => bridge_generate_image(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_edit_image" => bridge_edit_image(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), "agc_list_registered_assets" => { bridge_list_registered_assets(&state.root, &request.arguments) } "agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments), #[cfg(all(windows, feature = "cocos-editor-execute"))] - "agc_cocos_execute" => bridge_outcome( - &state.root, - bridge_cocos_execute(&state, &request.arguments).await, - ), + "agc_cocos_execute" => bridge_cocos_execute(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), #[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))] - "agc_unity_execute" => bridge_outcome( - &state.root, - bridge_unity_execute(&state, &request.arguments).await, - ), + "agc_unity_execute" => bridge_unity_execute(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), #[cfg(all(windows, target_arch = "x86_64", feature = "godot-editor-execute"))] - "agc_godot_execute" => bridge_outcome( - &state.root, - bridge_godot_execute(&state, &request.arguments).await, - ), + "agc_godot_execute" => bridge_godot_execute(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), #[cfg(all(windows, feature = "cocos-editor-execute"))] - operation if cocos_editor_bridge::is_cocos_operation(operation) => bridge_outcome( - &state.root, - bridge_cocos_call(&state, &request.arguments, Some(operation)).await, - ), - "agc_write_file" => { - bridge_outcome( - &state.root, - bridge_write_file_in_blocking_pool( - state.root.clone(), - request.arguments, - write_permit, - ) - .await, - ) + operation if cocos_editor_bridge::is_cocos_operation(operation) => { + bridge_cocos_call(&state, &request.arguments, Some(operation)) + .await + .map_err(|error| ToolCallError::from(&error)) } + "agc_write_file" => bridge_write_file_in_blocking_pool( + state.root.clone(), + request.arguments, + write_permit, + ) + .await + .map_err(|error| ToolCallError::from(&error)), "agc_list_account_assets" => { bridge_list_account_assets(&state, &request.arguments).await } "agc_import_account_assets" => { - bridge_outcome( - &state.root, - bridge_import_account_assets(&state, &request.arguments, write_permit).await, - ) + bridge_import_account_assets(&state, &request.arguments, write_permit) + .await + .map_err(|error| ToolCallError::from(&error)) } "agc_create_or_derive_resource" => { - bridge_outcome( - &state.root, - bridge_create_or_derive_resource(&state, &request.arguments).await, - ) + bridge_create_or_derive_resource(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)) } - "agc_remove_background" => bridge_outcome( - &state.root, - bridge_remove_background(&state, &request.arguments).await, - ), - "agc_browser_playtest" => bridge_outcome( - &state.root, - bridge_browser_playtest(&state, &request.arguments).await, - ), - "agc_environment_check" => bridge_outcome( - &state.root, - bridge_environment_check(&state.root, &request.arguments).await, - ), - "agc_read_project_context" => bridge_outcome( - &state.root, - bridge_read_project_context(&state.root, &request.arguments).await, - ), - "agc_run_validation" if state.direct_turn_execution => bridge_outcome( - &state.root, - bridge_validation_result( - &state.root, - super::direct_validation::run_command(&state.root, &request.arguments) - .await - .map_err(|cause| RunValidationError::ValidationFailed { cause }), - ), - ), - "agc_run_validation" => bridge_tool_failure( + "agc_remove_background" => bridge_remove_background(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_browser_playtest" => bridge_browser_playtest(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_environment_check" => bridge_environment_check(&state.root, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_read_project_context" => bridge_read_project_context(&state.root, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_run_validation" if state.direct_turn_execution => bridge_validation_result( &state.root, + super::direct_validation::run_command(&state.root, &request.arguments) + .await + .map_err(|cause| RunValidationError::ValidationFailed { cause }), + |report, screenshots| RunValidationError::ValidationNotPassed { + report, + screenshots, + }, + ) + .map_err(|error| ToolCallError::from(&error)), + "agc_run_validation" => Err(ToolCallError::from( &RunValidationError::Gate(DirectTurnGateRejection::ValidationRequiresDirectTurn), - ), + )), "agc_register_delivery_contract" | "agc_delivery_status" | "agc_apply_patch" | "agc_update_plan" if !state.direct_turn_execution => { - bridge_tool_failure( - &state.root, + Err(ToolCallError::from( &DirectTurnGateRejection::ToolRequiresDirectTurn { tool: request.tool.clone(), }, - ) + )) } - "agc_apply_patch" => bridge_outcome( - &state.root, - bridge_apply_patch(&state.root, &request.arguments).await, - ), - "agc_update_plan" => bridge_outcome( - &state.root, - bridge_update_plan(&state, &request.arguments).await, - ), - "agc_register_delivery_contract" => bridge_outcome( - &state.root, - bridge_register_delivery_contract(&state, &request.arguments).await, - ), - "agc_delivery_status" => bridge_outcome( - &state.root, - bridge_delivery_status(&state, &request.arguments).await, - ), + "agc_apply_patch" => bridge_apply_patch(&state.root, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_update_plan" => bridge_update_plan(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), + "agc_register_delivery_contract" => { + bridge_register_delivery_contract(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)) + } + "agc_delivery_status" => bridge_delivery_status(&state, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)), "agc_web_search" if state.controlled_web_search => { - bridge_outcome( - &state.root, - bridge_web_search(&state.root, &request.arguments).await, - ) + bridge_web_search(&state.root, &request.arguments) + .await + .map_err(|error| ToolCallError::from(&error)) } - "agc_web_search" => { - bridge_tool_failure(&state.root, &WebSearchError::ControlledSearchDisabled) - } - _ => bridge_tool_failure( - &state.root, + "agc_web_search" => Err(ToolCallError::from( + &WebSearchError::ControlledSearchDisabled, + )), + _ => Err(ToolCallError::from( &UnknownClientToolRejection { tool: request.tool.clone(), }, - ), + )), } }; let result = if let Some(scope) = &paid_scope { @@ -3981,7 +4052,7 @@ async fn handle_direct_tool_bridge( .is_some_and(|scope| scope.refused_before_dispatch()); if let Some((lease, has_evidence, before)) = lease { let root = state.root.clone(); - let passed = result["isError"] != true; + let passed = result.is_ok(); let finished = tokio::task::spawn_blocking(move || { let changed = has_evidence && before @@ -3995,40 +4066,24 @@ async fn handle_direct_tool_bridge( }) .await; if !matches!(finished, Ok(Ok(()))) { - return Json(bridge_tool_failure( - &state.root, - &DirectExecutionGateRejection::ReceiptNotPersisted, + return Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + dispatch_denied, + Err(ToolCallError::from( + &DirectExecutionGateRejection::ReceiptNotPersisted, + )), )); } } - if result.get("isError").and_then(Value::as_bool) == Some(true) { - // 诊断事件按回合归属:并发排障要能从错误记录认出是哪一轮调的工具。 - // 桥没被回合授权(外部客户端工具桥)或授权状态不可用时如实记为无回合。 - let client_turn_id = state.active_client_turn_id().ok().flatten(); - let message = result - .pointer("/content/0/text") - .and_then(Value::as_str) - .unwrap_or("客户端工具执行失败"); - // 失败分类早就由各工具的 typed 错误在产生层给出,这里不再解析文案:`code` 只放稳定 - // 的工具名,供并发排障定位"是哪个工具、哪一轮";文案是 typed 错误写好的那一句, - // 入参与上下文进 metadata。 - let _ = persist_agent_runtime_error( - &state.root, - client_turn_id.as_deref(), - "agc-tools", - "tool-execution", - request.tool.as_str(), - message, - None, - serde_json::json!({ - "tool": request.tool.clone(), - "arguments": diagnostics_arguments, - "directTurn": state.direct_turn_execution, - "dispatchDenied": dispatch_denied, - }), - ); - } - Json(result) + Json(compose_direct_tool_outcome( + &state, + request.tool.as_str(), + &diagnostics_arguments, + dispatch_denied, + result, + )) } pub(crate) async fn start_direct_tool_bridge( @@ -4135,7 +4190,7 @@ pub(crate) async fn direct_execution_fixture( pub(in crate::agent) async fn generate_images_concurrently_for_test( root: &Path, arguments: Vec, -) -> Vec { +) -> Vec> { let state = direct_tool_bridge_state(root.to_path_buf()); futures::future::join_all( arguments @@ -4144,7 +4199,7 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test( ) .await .into_iter() - .map(|result| bridge_outcome(root, result)) + .map(|result| result.map_err(|error| ToolCallError::from(&error))) .collect() } @@ -4652,8 +4707,9 @@ mod tests { if mode == "derive" { arguments["sourceLocalAssetId"] = json!("registered-source"); } - resource_generation_input(&arguments) - .unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{}", error.to_user_msg())); + resource_generation_input(&arguments).unwrap_or_else(|error| { + panic!("{kind} 恰好等于上限必须通过:{}", error.to_user_msg()) + }); arguments["prompt"] = json!("字".repeat(authority + 1)); let error = match resource_generation_input(&arguments) { @@ -4790,10 +4846,9 @@ mod tests { #[tokio::test] async fn bridge_search_rejects_invalid_max_results_type() { let root = tempfile::tempdir().expect("bridge root"); - let error = - bridge_web_search(root.path(), &json!({ "query": "tauri", "maxResults": "3" })) - .await - .expect_err("non-integer maxResults must be rejected"); + let error = bridge_web_search(root.path(), &json!({ "query": "tauri", "maxResults": "3" })) + .await + .expect_err("non-integer maxResults must be rejected"); assert!(error.to_user_msg().contains("maxResults")); } @@ -4835,7 +4890,6 @@ mod tests { .expect("controlled search must complete"); task.abort(); - assert_eq!(response["isError"], false); let result_text = response["content"][0]["text"] .as_str() .expect("search result text"); @@ -5005,8 +5059,8 @@ mod tests { let result = bridge_list_project_files( temporary.path(), &json!({ "kind": "image", "offset": 0, "limit": 10 }), - ); - assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false)); + ) + .expect("list project files"); let payload: Value = serde_json::from_str( result .pointer("/content/0/text") @@ -5040,8 +5094,8 @@ mod tests { let listed = bridge_list_project_files( temporary.path(), &json!({ "path": outside.to_string_lossy(), "limit": 10 }), - ); - assert_eq!(listed.get("isError").and_then(Value::as_bool), Some(false)); + ) + .expect("list outside project files"); let payload: Value = serde_json::from_str( listed .pointer("/content/0/text") @@ -5068,8 +5122,8 @@ mod tests { "kind": "image", "limit": 10 }), - ); - assert_eq!(rooted.get("isError").and_then(Value::as_bool), Some(false)); + ) + .expect("list project-root files"); let rooted_payload: Value = serde_json::from_str( rooted .pointer("/content/0/text") @@ -5147,33 +5201,19 @@ mod tests { .unwrap(); let permit = lease.write_permit().unwrap(); let arguments = json!({"path":"game/index.html", "content":"真实预览"}); - let changed = bridge_outcome( - &root, - bridge_write_file_with_permit(&root, &arguments, Some(&permit)), - ); - assert_eq!(changed["isError"], false); + let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit)) + .expect("host write succeeds"); let payload: Value = serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap(); let revision = payload["revision"].as_u64().unwrap().to_string(); assert_eq!(session.analytics_output_revision(), Some(revision.clone())); - assert_eq!( - bridge_outcome( - &root, - bridge_write_file_with_permit(&root, &arguments, Some(&permit)) - )["isError"], - false - ); - assert_eq!( - bridge_outcome( - &root, - bridge_write_file_with_permit( - &root, - &json!({"path":"../bad.js","content":"bad"}), - Some(&permit) - ) - )["isError"], - true - ); + assert!(bridge_write_file_with_permit(&root, &arguments, Some(&permit)).is_ok()); + assert!(bridge_write_file_with_permit( + &root, + &json!({"path":"../bad.js","content":"bad"}), + Some(&permit) + ) + .is_err()); assert_eq!(session.analytics_output_revision(), Some(revision.clone())); lease.finish(true, true, None).unwrap(); run::direct_finished( @@ -5401,8 +5441,8 @@ mod tests { "path": "game/index.html", "content": "" }), - ); - assert_eq!(result.get("isError").and_then(Value::as_bool), Some(false)); + ) + .expect("direct write succeeds"); let payload: Value = serde_json::from_str( result .pointer("/content/0/text") @@ -5416,30 +5456,26 @@ mod tests { fs::read_to_string(temporary.path().join("game/index.html")).expect("read written"), "" ); - let wrapped = bridge_write_file( + assert!(bridge_write_file( temporary.path(), &json!({ "path": "game/index.html", "content": "Exit code: 0\nWall time: 0.1 seconds\nOutput:\n" }), - ); - assert_eq!(wrapped.get("isError").and_then(Value::as_bool), Some(true)); + ) + .is_err()); assert_eq!( fs::read_to_string(temporary.path().join("game/index.html")).expect("read unchanged"), "" ); - assert!( - bridge_write_file( - temporary.path(), - &json!({ - "path": ".agent/manifest.json", - "content": "{}" - }), - ) - .get("isError") - .and_then(Value::as_bool) - == Some(true) - ); + assert!(bridge_write_file( + temporary.path(), + &json!({ + "path": ".agent/manifest.json", + "content": "{}" + }), + ) + .is_err()); } /// 同一轮里并行的多个文件写必须排队成功,而不是互相报"项目正在被其他写操作占用"。 @@ -5475,10 +5511,9 @@ mod tests { }); for (path, result) in &results { - assert_eq!( - result.get("isError").and_then(Value::as_bool), - Some(false), - "parallel direct write of {path} must succeed: {result}" + assert!( + result.is_ok(), + "parallel direct write of {path} must succeed" ); assert_eq!( fs::read_to_string(root.join(path)).expect("read parallel direct write"), @@ -5512,10 +5547,9 @@ mod tests { ); holder.join().expect("join the short-lived project writer"); - assert_eq!( - result.get("isError").and_then(Value::as_bool), - Some(false), - "the direct write must wait out a short same-process writer: {result}" + assert!( + result.is_ok(), + "the direct write must wait out a short same-process writer" ); assert_eq!( fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"), @@ -5715,14 +5749,11 @@ mod tests { fs::set_permissions(&agent_directory, original) .expect("restore control directory permission"); - if result.get("isError").and_then(Value::as_bool) != Some(true) { + let Err(error) = result else { // 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。 return; - } - let text = result - .pointer("/content/0/text") - .and_then(Value::as_str) - .unwrap_or_default(); + }; + let text = error.message; assert!( !text.contains("项目正在被其他写操作占用"), "a permission denial must not be projected as lock contention: {text}" @@ -5770,7 +5801,7 @@ mod tests { } }; assert!(state.authorize_regeneration_call("另一套风格").is_err()); - let completed = bridge_tool_result("completed".to_string(), Vec::new(), false); + let completed = bridge_tool_result("completed".to_string(), Vec::new()); state .record_completed_regeneration(&turn_id, &brief_sha256, &completed) .expect("cache completed authorized call"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 3e9aa4b7b..f8774f2df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -822,8 +822,7 @@ fn bounded_tool_string(arguments: &Value, field: &str, max_chars: usize) -> Resu fn validate_tool_object_fields(arguments: &Value, allowed: &[&str]) -> Result<(), String> { // 「入参不是对象 / 带了没声明的字段」是内置工具共用的两个 case:文案只写在 // `ToolArgumentsRejection` 上,这里只负责渲染成 MCP 结果文本。 - ToolArgumentsRejection::check(arguments, allowed) - .map_err(|rejection| rejection.to_user_msg()) + ToolArgumentsRejection::check(arguments, allowed).map_err(|rejection| rejection.to_user_msg()) } /// 与工具桥共用同一份 `agc_list_registered_assets` 入参规则。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 6931c6afd..6ea9c704f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -9403,16 +9403,15 @@ mod canvas_generation_tests { ]) ) ).await.expect("both bridge requests must finish without serializing their POSTs"); - let duplicate = results.pop().expect("duplicate request result"); - assert_eq!(duplicate["isError"], true, "{duplicate}"); - assert!(duplicate["content"][0]["text"] - .as_str() - .unwrap() - .contains("任何远端 POST 前拒绝并发请求")); + let duplicate = results + .pop() + .expect("duplicate request result") + .expect_err("duplicate request must be rejected before any remote POST"); + assert!(duplicate.message.contains("任何远端 POST 前拒绝并发请求")); let ids = results .into_iter() .map(|result| { - assert_eq!(result["isError"], false, "{result}"); + let result = result.expect("bridge generation request must succeed"); let text = result["content"][0]["text"] .as_str() .expect("bridge result text"); 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 025e0bac1..4f49580a8 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 @@ -12,7 +12,7 @@ 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.v2"; +pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v3"; /// sidecar 里 `message` 的字符上限。 pub(crate) const AGENT_RUNTIME_ERROR_MAX_MESSAGE_CHARS: usize = 8 * 1024; @@ -23,6 +23,7 @@ pub(crate) const AGENT_RUNTIME_ERROR_MAX_MESSAGE_CHARS: usize = 8 * 1024; /// 这里的预算留出身份字段的位置,保证被截掉的是正文尾部,而不是 `eventId`、`code` /// 或 `detailRef`。 pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_MESSAGE_CHARS: usize = 1_200; +pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_ERROR_CHARS: usize = 400; pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200; static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -38,6 +39,9 @@ pub(crate) struct AgentRuntimeErrorEvent { pub occurred_at_unix_nanos: String, pub elapsed_ms: Option, pub message: String, + /// 序列化后的 typed 错误 enum(一个 case 一个变体)。这是开发者侧的结构化事实; + /// 没有 typed 错误的调用方传 `Value::Null`。 + pub error: Value, pub detail_ref: String, pub persistence_failed: bool, pub metadata: Value, @@ -50,6 +54,7 @@ pub(crate) fn persist_agent_runtime_error( stage: &str, code: &str, message: &str, + error: Value, elapsed_ms: Option, metadata: Value, ) -> Result { @@ -72,6 +77,7 @@ pub(crate) fn persist_agent_runtime_error( "occurredAtUnixNanos": occurred_at_unix_nanos.to_string(), "elapsedMs": elapsed_ms, "message": safe_message, + "error": error, "metadata": metadata, }); // 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData @@ -86,6 +92,7 @@ pub(crate) fn persist_agent_runtime_error( code, &detail_ref, message, + &error, elapsed_ms, &metadata, ); @@ -109,6 +116,7 @@ pub(crate) fn persist_agent_runtime_error( occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(), elapsed_ms, message: message.to_string(), + error, detail_ref, persistence_failed: false, metadata, @@ -138,6 +146,7 @@ pub(crate) fn agent_runtime_error_app_log_lines( code: &str, detail_ref: &str, message: &str, + error: &Value, elapsed_ms: Option, metadata: &Value, ) -> [String; 2] { @@ -158,13 +167,18 @@ pub(crate) fn agent_runtime_error_app_log_lines( message, AGENT_RUNTIME_ERROR_APP_LOG_MESSAGE_CHARS, )); + let error = single_line_log_field(&redact_agent_runtime_error( + root, + &error.to_string(), + AGENT_RUNTIME_ERROR_APP_LOG_ERROR_CHARS, + )); let metadata = single_line_log_field(&redact_agent_runtime_error( root, &metadata.to_string(), AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS, )); let detail_line = format!( - "agent.runtime.error.detail eventId={event_id} message={message} metadata={metadata}" + "agent.runtime.error.detail eventId={event_id} message={message} error={error} metadata={metadata}" ); [identity, detail_line] } @@ -216,13 +230,18 @@ mod tests { "code-generation", "turn-idle-timeout", "本轮没有收到完成事件 C:\\Users\\private\\project https://provider.example/a?token=secret", + serde_json::json!({ "type": "timedOut", "deadline": "idle" }), Some(1200), serde_json::json!({"lastEvent":"item/started"}), ) .expect("persist event"); assert_eq!(event.code, "turn-idle-timeout"); + assert_eq!(event.message, "本轮没有收到完成事件 C:\\Users\\private\\project https://provider.example/a?token=secret"); let path = root.join(&event.detail_ref); let text = std::fs::read_to_string(path).expect("diagnostic"); + // typed 错误原样进 sidecar:开发侧按变体与字段定位,不解析那句中文。 + assert!(text.contains("timedOut"), "{text}"); + assert!(text.contains("idle"), "{text}"); assert!(text.contains("")); assert!(text.contains("")); assert!(!text.contains("token=secret")); @@ -245,6 +264,7 @@ mod tests { "turn-idle-timeout", ".agent/runtime/errors/error-1-1.json", "本轮没有收到完成事件\n附带换行 C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断", + &serde_json::json!({ "type": "timedOut", "deadline": "idle" }), Some(1200), &serde_json::json!({"authorization": "Bearer secret"}), ); @@ -276,6 +296,10 @@ mod tests { ), "{detail}" ); + assert!( + detail.contains("error={\"deadline\":\"idle\",\"type\":\"timedOut\"}"), + "{detail}" + ); assert!(!detail.contains("Bearer secret"), "{detail}"); // 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。 @@ -288,6 +312,7 @@ mod tests { "tool-error", ".agent/runtime/errors/error-3-1.json", "credential rotation failed", + &Value::Null, None, &serde_json::json!({"tool": "agc_tools"}), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 8b329d184..ad78e0a75 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -111,6 +111,7 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at( &state.phase, "agent-runtime-terminal", error, + serde_json::Value::Null, None, serde_json::json!({ "agentId": state.agent_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index 2e13e7c3e..2b97fcbff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -282,10 +282,9 @@ pub(in crate::agent) async fn observe_agent_runtime_account_asset_library( root: &Path, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { - if let Err(rejection) = ToolArgumentsRejection::check( - input, - &["folderId", "query", "offset", "limit"], - ) { + if let Err(rejection) = + ToolArgumentsRejection::check(input, &["folderId", "query", "offset", "limit"]) + { return AgentRuntimeToolObservation { tool: "asset.library.list".to_string(), status: "rejected".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/apply_patch/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/apply_patch/error.rs index 961b9aeb5..4aa3f9c62 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/apply_patch/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/apply_patch/error.rs @@ -3,25 +3,32 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge` 与 `direct_patch`。 use crate::agent::tool::error::ToolFailure; +use serde_json::Value; /// `agc_apply_patch` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -/// -/// 补丁被拒绝(`status != completed`)不是错误:那是补丁工具自己的结果载荷,仍然原样回给 -/// 模型,好让它看到哪一段没打上。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ApplyPatchError { /// 补丁没被受理:入参不合法、作用域越界或执行许可已关闭。 PatchRejected { cause: String }, + /// 补丁受理了但没打上或只打了一部分(`status != completed`)。 + /// + /// `report` 是补丁工具自己的结果载荷(逐文件命中与冲突),原样带给模型, + /// 好让它看到哪一段没打上。 + PatchNotApplied { report: Value }, } impl ToolFailure for ApplyPatchError { fn to_user_msg(&self) -> String { match self { Self::PatchRejected { cause } => cause.to_string(), + Self::PatchNotApplied { report } => format!("补丁未完全应用:{report}"), } } fn redact_limit(&self) -> usize { - 1800 + match self { + Self::PatchNotApplied { .. } => 32_000, + _ => 1800, + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/browser_playtest/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/browser_playtest/error.rs index 4aa97881b..55291cfda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/browser_playtest/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/browser_playtest/error.rs @@ -3,22 +3,43 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge` 与 `direct_validation`。 use crate::agent::tool::error::ToolFailure; +use serde_json::Value; /// `agc_browser_playtest` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum BrowserPlaytestError { /// 浏览器试玩自身失败;原因来自 `direct_validation` 的稳定前缀码。 PlaytestFailed { cause: String }, + /// 试玩跑完了但没通过:`report` 是试玩结果载荷(含逐视口结论与截图路径)。 + /// + /// 截图正文单独带在 `screenshots` 上:它要变成失败结果里的 MCP image block, + /// 但不进错误正文(base64 会把诊断 sidecar 灌爆)。 + PlaytestNotPassed { + report: Value, + #[serde(skip)] + screenshots: Vec, + }, } impl ToolFailure for BrowserPlaytestError { fn to_user_msg(&self) -> String { match self { Self::PlaytestFailed { cause } => cause.to_string(), + Self::PlaytestNotPassed { report, .. } => format!("浏览器试玩未通过:{report}"), } } fn redact_limit(&self) -> usize { - 1800 + match self { + Self::PlaytestNotPassed { .. } => 32_000, + _ => 1800, + } + } + + fn attached_images(&self) -> Vec { + match self { + Self::PlaytestNotPassed { screenshots, .. } => screenshots.clone(), + _ => Vec::new(), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/cocos_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/cocos_execute/error.rs index 161114427..b0146f279 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/cocos_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/cocos_execute/error.rs @@ -2,24 +2,39 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; +use serde_json::Value; /// Cocos 执行工具的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -/// -/// 已有的"待核对""执行中"两种状态不是错误:它们各自带 `retryAllowed` 载荷,仍原样回给模型。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum CocosExecuteError { PluginDisabled, PluginUnavailable, PermissionDenied(ProjectPermissionRejection), Arguments(ToolArgumentsRejection), - UnknownOperation { operation: String }, - SchemaInvalid { cause: String }, - OperationArgumentsInvalid { cause: String }, + UnknownOperation { + operation: String, + }, + SchemaInvalid { + cause: String, + }, + OperationArgumentsInvalid { + cause: String, + }, CodeNotText, - CodeInvalid { cause: String }, + CodeInvalid { + cause: String, + }, + /// 已有 Cocos 操作在执行中:本次不发命令,也不要重放。 + ExecutionBusy, + /// 先前的 execute 结果待核对:当前不再发送执行命令,也不要自动重放。 + ReconciliationPending, + /// execute 已发出但回执不是 `completed`:`report` 带 `status` / `retryAllowed` 等现场事实。 + ExecutionNotCompleted { + report: Value, + #[serde(skip)] + screenshots: Vec, + }, } impl From for CocosExecuteError { @@ -52,6 +67,32 @@ impl ToolFailure for CocosExecuteError { } Self::CodeNotText => "code 必须是 JavaScript 函数体。".to_string(), Self::CodeInvalid { cause } => cause.to_string(), + Self::ExecutionBusy => { + "已有 Cocos 操作执行中,请等待回执;本次调用没有发送执行命令,也不要重放。" + .to_string() + } + Self::ReconciliationPending => { + "先前 Cocos execute 的结果待核对,当前不再发送执行命令,也不要自动重放;\ + 请人工核对编辑器现场。" + .to_string() + } + Self::ExecutionNotCompleted { report, .. } => { + format!("Cocos 执行未完成:{report}") + } + } + } + + fn redact_limit(&self) -> usize { + match self { + Self::ExecutionNotCompleted { .. } => 32_000, + _ => 480, + } + } + + fn attached_images(&self) -> Vec { + match self { + Self::ExecutionNotCompleted { screenshots, .. } => screenshots.clone(), + _ => Vec::new(), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs index fdd570562..f2e74c31f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs @@ -14,25 +14,41 @@ const UNREGISTERED_SOURCE_HINT: &str = "先调用 agc_list_registered_assets 选 /// `agc_create_or_derive_resource` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum CreateOrDeriveResourceError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), Manifest(ProjectManifestRejection), Completion(ResourceCompletionRejection), KindEmpty, - KindTooLong { got_chars: usize, max_chars: usize }, + KindTooLong { + got_chars: usize, + max_chars: usize, + }, KindHasControlCharacters, - KindUnsupported { got: String }, + KindUnsupported { + got: String, + }, ModeEmpty, - ModeTooLong { got_chars: usize, max_chars: usize }, + ModeTooLong { + got_chars: usize, + max_chars: usize, + }, ModeHasControlCharacters, - ModeUnsupported { got: String }, + ModeUnsupported { + got: String, + }, SourceLocalAssetIdEmpty, - SourceLocalAssetIdTooLong { got_chars: usize, max_chars: usize }, + SourceLocalAssetIdTooLong { + got_chars: usize, + max_chars: usize, + }, SourceLocalAssetIdHasControlCharacters, PromptEmpty, - PromptTooLong { got_chars: usize, max_chars: usize }, + PromptTooLong { + got_chars: usize, + max_chars: usize, + }, PromptHasControlCharacters, PromptTooLongForKind { kind: DirectResourceGenerationKind, @@ -40,7 +56,10 @@ pub(crate) enum CreateOrDeriveResourceError { max_chars: usize, }, AssetNameEmpty, - AssetNameTooLong { got_chars: usize, max_chars: usize }, + AssetNameTooLong { + got_chars: usize, + max_chars: usize, + }, AssetNameHasControlCharacters, ImageCreationRequiresRegisteredSource, CharacterAnimationRequiresRegisteredSource, @@ -48,13 +67,25 @@ pub(crate) enum CreateOrDeriveResourceError { DeriveModeWithoutSource, SourceAssetNotRegistered, ProjectPathUnprojectable, - GenerationQueueUnavailable { cause: String }, - PaidDispatchRefused { cause: String }, - PendingEditsUnreadable { cause: String }, + GenerationQueueUnavailable { + cause: String, + }, + PaidDispatchRefused { + cause: String, + }, + PendingEditsUnreadable { + cause: String, + }, DuplicatePendingOperations, - TurnIdUnavailable { cause: String }, - ProjectRevisionUnavailable { cause: String }, - ResourceGenerationFailed { cause: String }, + TurnIdUnavailable { + cause: String, + }, + ProjectRevisionUnavailable { + cause: String, + }, + ResourceGenerationFailed { + cause: String, + }, } impl From for CreateOrDeriveResourceError { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/delivery_status/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/delivery_status/error.rs index ebb34fe66..3aafbe1e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/delivery_status/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/delivery_status/error.rs @@ -5,7 +5,7 @@ use crate::agent::tool::error::{DirectExecutionGateRejection, ToolFailure}; /// `agc_delivery_status` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum DeliveryStatusError { /// 只读状态工具不接受任何参数。 ArgumentsNotAccepted, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/edit_image/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/edit_image/error.rs index 6739d76db..bbe87c541 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/edit_image/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/edit_image/error.rs @@ -6,14 +6,11 @@ use crate::agent::tool::create_or_derive_resource::error::CreateOrDeriveResource use crate::agent::tool::error::{ToolArgumentsRejection, ToolFailure}; /// `agc_edit_image` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum EditImageError { Arguments(ToolArgumentsRejection), SourceLocalAssetIdEmpty, - SourceLocalAssetIdTooLong { - got_chars: usize, - max_chars: usize, - }, + SourceLocalAssetIdTooLong { got_chars: usize, max_chars: usize }, SourceLocalAssetIdHasControlCharacters, PromptEmpty, PromptTooLong { got_chars: usize, max_chars: usize }, @@ -40,7 +37,9 @@ impl ToolFailure for EditImageError { fn to_user_msg(&self) -> String { match self { Self::Arguments(rejection) => rejection.to_user_msg(), - Self::SourceLocalAssetIdEmpty => "编辑图片失败:sourceLocalAssetId 不能为空。".to_string(), + Self::SourceLocalAssetIdEmpty => { + "编辑图片失败:sourceLocalAssetId 不能为空。".to_string() + } Self::SourceLocalAssetIdTooLong { got_chars, max_chars, @@ -55,9 +54,7 @@ impl ToolFailure for EditImageError { got_chars, max_chars, } => format!("编辑图片失败:prompt 有 {got_chars} 个字符,超过上限 {max_chars}。"), - Self::PromptHasControlCharacters => { - "编辑图片失败:prompt 含有控制字符。".to_string() - } + Self::PromptHasControlCharacters => "编辑图片失败:prompt 含有控制字符。".to_string(), Self::AssetNameEmpty => "编辑图片失败:assetName 不能为空。".to_string(), Self::AssetNameTooLong { got_chars, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs index a22ddb6ce..51db61dee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs @@ -2,14 +2,13 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; +use serde_json::Value; /// 两个编辑器执行工具之间的差异:插件身份、给用户看的编辑器名、代码语言名。 /// /// 差异做成枚举,错误变体只带 `EditorKind`,不在文案里手写编辑器名。 -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(serde::Serialize, Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum EditorKind { Unity, Godot, @@ -39,15 +38,30 @@ impl EditorKind { } /// 编辑器执行工具的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum EditorExecuteError { - PluginUnavailable { editor: EditorKind }, + PluginUnavailable { + editor: EditorKind, + }, Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), - CodeNotText { editor: EditorKind }, + CodeNotText { + editor: EditorKind, + }, CodeInvalid, /// 编辑器适配器拒绝了这次执行,或回执不可用。 - EditorRejected { cause: String }, + EditorRejected { + cause: String, + }, + /// 适配器返回了「未完成」的回执:`report` 里带 `dispatched` / `retryAllowed` 等现场事实。 + ExecutionNotCompleted { + editor: EditorKind, + report: Value, + }, + /// 执行任务异常退出,回执不可用:结果待核对,不要自动重放。 + ExecutionUnconfirmed { + editor: EditorKind, + }, } impl From for EditorExecuteError { @@ -75,6 +89,21 @@ impl ToolFailure for EditorExecuteError { } Self::CodeInvalid => "code 不能为空、包含 NUL 或超过 128 KiB。".to_string(), Self::EditorRejected { cause } => cause.to_string(), + Self::ExecutionNotCompleted { editor, report } => { + format!("{} 执行未完成:{report}", editor.label()) + } + Self::ExecutionUnconfirmed { editor } => format!( + "{} 执行任务异常,回执不可用;执行已发出,结果待核对,不要自动重放:\ + {{\"status\":\"needs-reconciliation\",\"dispatched\":true,\"retryAllowed\":false}}", + editor.label() + ), + } + } + + fn redact_limit(&self) -> usize { + match self { + Self::ExecutionNotCompleted { .. } | Self::ExecutionUnconfirmed { .. } => 32_000, + _ => 480, } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/environment_check/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/environment_check/error.rs index ec8ed79d0..8095c054c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/environment_check/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/environment_check/error.rs @@ -2,15 +2,18 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; +use serde_json::Value; -/// `agc_environment_check` 不接受任何参数,所以它自己的失败只有权限门禁一类。 -#[derive(Debug)] +/// `agc_environment_check` 不接受任何参数,所以它自己的失败只有权限门禁与环境未就绪两类。 +#[derive(serde::Serialize, Debug)] pub(crate) enum EnvironmentCheckError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), + /// 环境检查跑完了但结论不是 `ready`:`report` 是检查载荷,原样带给模型。 + EnvironmentNotReady { + report: Value, + }, } impl From for EnvironmentCheckError { @@ -30,6 +33,14 @@ impl ToolFailure for EnvironmentCheckError { match self { Self::Arguments(rejection) => rejection.to_user_msg(), Self::PermissionDenied(rejection) => rejection.to_user_msg(), + Self::EnvironmentNotReady { report } => format!("本地环境未就绪:{report}"), + } + } + + fn redact_limit(&self) -> usize { + match self { + Self::EnvironmentNotReady { .. } => 32_000, + _ => 480, } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 7e177d33a..edf287424 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -7,7 +7,7 @@ use serde_json::Value; /// 工具失败对用户(以及转述给用户的模型)说的那一句话。 -pub(crate) trait ToolFailure { +pub(crate) trait ToolFailure: serde::Serialize { fn to_user_msg(&self) -> String; /// 这句话落进工具结果前允许保留的字符数上限。 @@ -17,13 +17,21 @@ pub(crate) trait ToolFailure { fn redact_limit(&self) -> usize { 480 } + + /// 失败时仍要一并回给模型的证据图(MCP image block 的 base64 正文)。 + /// + /// 只有真正的证据类失败才带:浏览器验证没通过时的双端截图、Cocos 预览截图。 + /// 证据图不进 `serde::Serialize` 的错误正文,避免 base64 正文灌爆诊断 sidecar。 + fn attached_images(&self) -> Vec { + Vec::new() + } } /// 所有内置工具共用的入参形状校验:入参不是对象,或带了没声明的字段。 /// /// 只有这两个 case 是所有工具共享的;具体字段自己的语义(空值、超长、越界……)各工具 /// 用自己 enum 的变体表达。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ToolArgumentsRejection { NotObject, UnknownField { field: String }, @@ -49,9 +57,9 @@ impl ToolFailure for ToolArgumentsRejection { fn to_user_msg(&self) -> String { match self { Self::NotObject => "工具参数必须是对象。".to_string(), - Self::UnknownField { field } => format!( - "不认识的工具参数「{field}」,请只传该工具声明支持过的字段。" - ), + Self::UnknownField { field } => { + format!("不认识的工具参数「{field}」,请只传该工具声明支持过的字段。") + } } } } @@ -59,7 +67,7 @@ impl ToolFailure for ToolArgumentsRejection { /// 项目权限门禁拒绝该动作,或策略本身读不出来。 /// /// 两种失败各占一个变体;产生方是 `project::verification` 的两个门禁函数。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ProjectPermissionRejection { Denied { command_id: String }, PolicyUnavailable { cause: String }, @@ -74,9 +82,9 @@ impl ToolFailure for ProjectPermissionRejection { Self::Denied { command_id } => { format!("项目权限策略拒绝执行:{command_id}") } - Self::PolicyUnavailable { cause } => format!( - "读取项目权限策略失败,无法确认该动作是否被允许,本次操作未执行:{cause}" - ), + Self::PolicyUnavailable { cause } => { + format!("读取项目权限策略失败,无法确认该动作是否被允许,本次操作未执行:{cause}") + } } } } @@ -85,7 +93,7 @@ impl ToolFailure for ProjectPermissionRejection { /// /// 三个列表工具的 `offset`/`limit` 语义完全一致,只是默认值与上限不同;上限随变体带上, /// 所以文案只需要一份。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ToolPageRejection { OffsetNotInteger { got: String }, OffsetTooLarge { got: usize, max: usize }, @@ -99,9 +107,9 @@ impl ToolFailure for ToolPageRejection { Self::OffsetNotInteger { got } => { format!("工具参数 offset 必须是非负整数,当前是「{got}」。") } - Self::OffsetTooLarge { got, max } => format!( - "工具参数 offset={got} 超出上限 {max},请从更小的偏移重新分页。" - ), + Self::OffsetTooLarge { got, max } => { + format!("工具参数 offset={got} 超出上限 {max},请从更小的偏移重新分页。") + } Self::LimitNotInteger { got } => { format!("工具参数 limit 必须是整数,当前是「{got}」。") } @@ -115,7 +123,7 @@ impl ToolFailure for ToolPageRejection { /// 本地项目清单(manifest)读不出来:清单文件损坏、权限不足或项目尚未初始化。 /// /// 读清单是多个工具的公共第一步,失败语义相同,因此文案只在这里写一份。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) struct ProjectManifestRejection { pub(crate) cause: String, } @@ -130,7 +138,7 @@ impl ToolFailure for ProjectManifestRejection { /// /// `agc_create_or_derive_resource` 与 `agc_remove_background` 共用同一个完成投影, /// 失败语义相同,因此文案只在这里写一份。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ResourceCompletionRejection { MissingAsset, WarningsUnreadable { cause: String }, @@ -151,7 +159,7 @@ impl ToolFailure for ResourceCompletionRejection { /// /// 独立客户端 MCP 没有 Direct 回合的执行账本,不能借用其它回合的预算,所以这里直接拒绝, /// 而不是让工具在半途发现账本不存在。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum DirectTurnGateRejection { /// 该工具整体属于客户端 Direct 回合。 ToolRequiresDirectTurn { tool: String }, @@ -176,7 +184,7 @@ impl ToolFailure for DirectTurnGateRejection { /// 客户端工具桥收到没有登记过的工具名。 /// /// 带上收到的名字:模型拼错工具名时要能从错误里看出自己写了什么,而不是只知道"不认识"。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) struct UnknownClientToolRejection { pub(crate) tool: String, } @@ -191,7 +199,7 @@ impl ToolFailure for UnknownClientToolRejection { /// /// 这些都不是某个工具的入参问题,而是宿主这一侧的事实;任何工具都可能碰到,所以在这里 /// 定义一次。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum DirectExecutionGateRejection { /// 执行许可(付费/写入/执行租约)取不到。 PermitUnavailable { cause: String }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/generate_image/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/generate_image/error.rs index e41da9110..62f5125d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/generate_image/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/generate_image/error.rs @@ -3,13 +3,11 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::generation::PLATFORM_ART_ASSET_GENERATION_KINDS; -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; use shared_contracts::game_creation_app::GameCreationAppAssetKind; /// `agc_generate_image` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum GenerateImageError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/import_account_assets/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/import_account_assets/error.rs index ca7507f21..732829122 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/import_account_assets/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/import_account_assets/error.rs @@ -2,29 +2,54 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; +use serde_json::Value; /// `agc_import_account_assets` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ImportAccountAssetsError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), - AssetIdsNotStringArray { got: String }, - AssetIdsTooMany { max: usize }, + AssetIdsNotStringArray { + got: String, + }, + AssetIdsTooMany { + max: usize, + }, AssetIdsEntryEmpty, - AssetIdsEntryTooLong { got_chars: usize, max_chars: usize }, + AssetIdsEntryTooLong { + got_chars: usize, + max_chars: usize, + }, AssetIdsEntryHasControlCharacters, - LocalPathsNotStringArray { got: String }, - LocalPathsTooMany { max: usize }, + LocalPathsNotStringArray { + got: String, + }, + LocalPathsTooMany { + max: usize, + }, LocalPathsEntryEmpty, - LocalPathsEntryTooLong { got_chars: usize, max_chars: usize }, + LocalPathsEntryTooLong { + got_chars: usize, + max_chars: usize, + }, LocalPathsEntryHasControlCharacters, - LocalPathsNotProjectRelative { got: String }, - LocalPathsProtectedControlPath { got: String }, + LocalPathsNotProjectRelative { + got: String, + }, + LocalPathsProtectedControlPath { + got: String, + }, NoImportSource, + /// 一项都没登记上:`report` 是逐项失败载荷,原样带给模型。 + ImportFailed { + report: Value, + }, + /// 有一部分登记上了:`report` 里带成功的项与逐项失败原因,原样带给模型。 + ImportPartial { + report: Value, + }, } impl From for ImportAccountAssetsError { @@ -50,9 +75,7 @@ impl ToolFailure for ImportAccountAssetsError { Self::AssetIdsTooMany { max } => { format!("登记素材失败:assetIds 一次最多包含 {max} 项。") } - Self::AssetIdsEntryEmpty => { - "登记素材失败:assetIds 只能包含非空字符串。".to_string() - } + Self::AssetIdsEntryEmpty => "登记素材失败:assetIds 只能包含非空字符串。".to_string(), Self::AssetIdsEntryTooLong { got_chars, max_chars, @@ -80,15 +103,24 @@ impl ToolFailure for ImportAccountAssetsError { Self::LocalPathsEntryHasControlCharacters => { "登记素材失败:localPaths 中存在含有控制字符的条目。".to_string() } - Self::LocalPathsNotProjectRelative { got } => format!( - "登记素材失败:localPaths 的「{got}」不是受控项目根内的项目相对路径。" - ), - Self::LocalPathsProtectedControlPath { got } => format!( - "登记素材失败:localPaths 的「{got}」命中隐藏、构建或敏感控制路径。" - ), + Self::LocalPathsNotProjectRelative { got } => { + format!("登记素材失败:localPaths 的「{got}」不是受控项目根内的项目相对路径。") + } + Self::LocalPathsProtectedControlPath { got } => { + format!("登记素材失败:localPaths 的「{got}」命中隐藏、构建或敏感控制路径。") + } Self::NoImportSource => { "登记素材失败:至少提供一个非空的 assetIds 或 localPaths 数组。".to_string() } + Self::ImportFailed { report } => format!("登记素材失败:{report}"), + Self::ImportPartial { report } => format!("登记素材部分成功:{report}"), + } + } + + fn redact_limit(&self) -> usize { + match self { + Self::ImportFailed { .. } | Self::ImportPartial { .. } => 32_000, + _ => 480, } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_account_assets/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_account_assets/error.rs index e0c992fc7..1fe0e25de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_account_assets/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_account_assets/error.rs @@ -3,12 +3,12 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolPageRejection, ToolFailure, + ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, ToolPageRejection, }; /// `agc_list_account_assets` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ListAccountAssetsError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), @@ -50,9 +50,9 @@ impl ToolFailure for ListAccountAssetsError { Self::FolderIdTooLong { got_chars, max_chars, - } => format!( - "列出账号素材失败:folderId 有 {got_chars} 个字符,超过上限 {max_chars}。" - ), + } => { + format!("列出账号素材失败:folderId 有 {got_chars} 个字符,超过上限 {max_chars}。") + } Self::FolderIdHasControlCharacters => { "列出账号素材失败:folderId 含有控制字符。".to_string() } @@ -61,9 +61,7 @@ impl ToolFailure for ListAccountAssetsError { got_chars, max_chars, } => format!("列出账号素材失败:query 有 {got_chars} 个字符,超过上限 {max_chars}。"), - Self::QueryHasControlCharacters => { - "列出账号素材失败:query 含有控制字符。".to_string() - } + Self::QueryHasControlCharacters => "列出账号素材失败:query 含有控制字符。".to_string(), Self::AccountAssetsUnreadable { cause } => { format!("列出账号素材失败:读取账号素材列表失败:{cause}") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_project_files/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_project_files/error.rs index d5ff66130..21aafce66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_project_files/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_project_files/error.rs @@ -3,8 +3,8 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ToolArgumentsRejection, - ToolPageRejection, ToolFailure, + ProjectManifestRejection, ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, + ToolPageRejection, }; /// `agc_list_project_files` 允许的 `kind` 过滤值。 @@ -13,7 +13,7 @@ pub(crate) const LIST_PROJECT_FILES_KINDS: &[&str] = &[ ]; /// `agc_list_project_files` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ListProjectFilesError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_registered_assets/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_registered_assets/error.rs index 02d8d1011..3e4ed6f5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_registered_assets/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/list_registered_assets/error.rs @@ -3,13 +3,13 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ToolArgumentsRejection, - ToolPageRejection, ToolFailure, + ProjectManifestRejection, ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, + ToolPageRejection, }; /// `agc_list_registered_assets` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ListRegisteredAssetsError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/prepare_game_art/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/prepare_game_art/error.rs index 12c3113b9..433b49b3b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/prepare_game_art/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/prepare_game_art/error.rs @@ -2,17 +2,14 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; /// 美术重生成只接受客户端当前回合签发的授权;这里列出授权链自己的失败。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ArtRegenerationAuthorizationRejection { StateUnavailable { cause: String }, MissingTurnAuthorization, BoundToAnotherBrief, - CompletedResultNotSuccessful, CompletedResultTurnExpired, CompletedResultMismatch, } @@ -30,9 +27,6 @@ impl ToolFailure for ArtRegenerationAuthorizationRejection { Self::BoundToAnotherBrief => { "美术重生成失败:当前客户端回合已绑定另一项稳定美术重生成请求。".to_string() } - Self::CompletedResultNotSuccessful => { - "美术重生成失败:拒绝缓存未成功的美术重生成结果。".to_string() - } Self::CompletedResultTurnExpired => { "美术重生成失败:美术重生成完成时客户端回合授权已失效。".to_string() } @@ -45,7 +39,7 @@ impl ToolFailure for ArtRegenerationAuthorizationRejection { /// `taonier_prepare_game_art` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum PrepareGameArtError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/read_project_context/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/read_project_context/error.rs index 77510d420..74c0d97ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/read_project_context/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/read_project_context/error.rs @@ -6,7 +6,7 @@ use crate::agent::tool::error::ToolFailure; /// `agc_read_project_context` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum ReadProjectContextError { /// 上下文读取失败:入参不合法、路径不安全、快照复核中断或响应超预算。 /// 具体分类等 `direct_project_context` 给出 typed 出口后再拆变体。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/register_delivery_contract/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/register_delivery_contract/error.rs index 3f21c7433..53a3b243e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/register_delivery_contract/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/register_delivery_contract/error.rs @@ -6,7 +6,7 @@ use crate::agent::tool::error::{DirectExecutionGateRejection, ToolFailure}; /// `agc_register_delivery_contract` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum RegisterDeliveryContractError { /// 宿主交付会话取不到。 Gate(DirectExecutionGateRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs index 7ee148885..b13b71c64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs @@ -9,7 +9,7 @@ use crate::agent::tool::error::{ /// `agc_remove_background` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum RemoveBackgroundError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/run_validation/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/run_validation/error.rs index ab2606279..046ac597f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/run_validation/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/run_validation/error.rs @@ -3,14 +3,24 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge` 与 `direct_validation`。 use crate::agent::tool::error::{DirectTurnGateRejection, ToolFailure}; +use serde_json::Value; /// `agc_run_validation` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum RunValidationError { /// 独立客户端 MCP 没有本回合的验证预算。 Gate(DirectTurnGateRejection), /// 托管验证(命令或试玩)自身失败;原因来自 `direct_validation` 的稳定前缀码。 ValidationFailed { cause: String }, + /// 验证跑了但没通过:`report` 是验证结果载荷(含逐视口结论与截图路径),原样带给模型。 + /// + /// 截图正文单独带在 `screenshots` 上:它要变成失败结果里的 MCP image block, + /// 但不进错误正文(base64 会把诊断 sidecar 灌爆)。 + ValidationNotPassed { + report: Value, + #[serde(skip)] + screenshots: Vec, + }, } impl From for RunValidationError { @@ -24,10 +34,21 @@ impl ToolFailure for RunValidationError { match self { Self::Gate(rejection) => rejection.to_user_msg(), Self::ValidationFailed { cause } => cause.to_string(), + Self::ValidationNotPassed { report, .. } => format!("验证未通过:{report}"), } } fn redact_limit(&self) -> usize { - 1800 + match self { + Self::ValidationNotPassed { .. } => 32_000, + _ => 1800, + } + } + + fn attached_images(&self) -> Vec { + match self { + Self::ValidationNotPassed { screenshots, .. } => screenshots.clone(), + _ => Vec::new(), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/update_plan/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/update_plan/error.rs index 62c0052bb..0a6153966 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/update_plan/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/update_plan/error.rs @@ -5,7 +5,7 @@ use crate::agent::tool::error::{DirectExecutionGateRejection, ToolFailure}; /// `agc_update_plan` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum UpdatePlanError { /// 宿主计划会话取不到。 Gate(DirectExecutionGateRejection), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs index bf07212e5..ab1e4b533 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs @@ -2,12 +2,10 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; /// `agc_web_search` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum WebSearchError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), @@ -49,12 +47,12 @@ impl ToolFailure for WebSearchError { max_chars, } => format!("联网搜索失败:query 有 {got_chars} 个字符,超过上限 {max_chars}。"), Self::QueryHasControlCharacters => "联网搜索失败:query 含有控制字符。".to_string(), - Self::MaxResultsNotInteger { got } => format!( - "联网搜索失败:maxResults 必须是 1 到 5 的整数,当前是「{got}」。" - ), - Self::MaxResultsOutOfRange { got, max } => format!( - "联网搜索失败:maxResults={got} 超出范围,必须在 1 到 {max} 之间。" - ), + Self::MaxResultsNotInteger { got } => { + format!("联网搜索失败:maxResults 必须是 1 到 5 的整数,当前是「{got}」。") + } + Self::MaxResultsOutOfRange { got, max } => { + format!("联网搜索失败:maxResults={got} 超出范围,必须在 1 到 {max} 之间。") + } Self::ClientUnavailable { cause } => { format!("联网搜索失败:创建 AGC 受控搜索连接失败:{cause}") } @@ -64,9 +62,9 @@ impl ToolFailure for WebSearchError { Self::HttpStatusNotSuccess { status } => { format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。") } - Self::ResponseTooLarge { max_bytes } => format!( - "联网搜索失败:AGC 受控搜索响应超过大小上限 {max_bytes} bytes。" - ), + Self::ResponseTooLarge { max_bytes } => { + format!("联网搜索失败:AGC 受控搜索响应超过大小上限 {max_bytes} bytes。") + } Self::ResponseReadFailed { cause } => { format!("联网搜索失败:读取 AGC 受控搜索响应失败:{cause}") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/write_file/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/write_file/error.rs index 4c973974a..018b30606 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/write_file/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/write_file/error.rs @@ -2,12 +2,10 @@ //! //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 -use crate::agent::tool::error::{ - ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure, -}; +use crate::agent::tool::error::{ProjectPermissionRejection, ToolArgumentsRejection, ToolFailure}; /// `agc_write_file` 的每一种失败各占一个变体;文案写在这里,捕获处只调 `to_user_msg`。 -#[derive(Debug)] +#[derive(serde::Serialize, Debug)] pub(crate) enum WriteFileError { Arguments(ToolArgumentsRejection), PermissionDenied(ProjectPermissionRejection), diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9e0c26dad..67b03cfd1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9296,4 +9296,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 决策(schema 与日志):`AGENT_RUNTIME_ERROR_SCHEMA_VERSION` 是 `agent-runtime-error.v2`,sidecar 由 `publicText / recoveryHint / detail` 收成 `message`(无读取方,不需要兼容层)。应用日志详情行由 `hint=… summary=… detail=… metadata=…` 收成 `message=… metadata=…`,身份行不变(本来就只放程序生成与调用方常量字段)。`message` 在应用日志里取原 `detail` 的 1200 字符预算、在 sidecar 里按 8 KiB 上限:用户只上传 AppData 应用日志、拿不到 sidecar,日志这一份必须是信息量最大的那份。 - 决策(保留项):`direct-codex-failure:v2 … retryable=… summary=…;建议:…` 是前端(`features/agent-runtime/model.ts` 的 v2 正则)要解析的用户可见文案,`retryable` / `recovery_hint` 由 typed `DirectTurnError::is_retryable()` / `recovery_hint()` 判定,原样保留,只是不再进统一事件;`direct-codex` 路径把信息量最大的 `failure.to_string()`(typed Display)作为 `message`。`runtime_state.rs` 那条改为只记原始 `error`(投影后的用户文案已写进 `project.jsonl`,不必再存一遍)。 - 改动范围:`agent/runtime_error.rs`、`agent/runtime_state.rs`、`agent/direct_tool_bridge.rs`、`agent/direct_runtime/mod.rs`;同步修正 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 里 2026-09-15 的「统一事件至少包含 …」字段表与 2026-09-21 的日志两行口径。 -- 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell agent::runtime_error::` 3 passed;`cargo test --bin genarrative-ai-game-creator-shell agent::` 950 passed / 1 failed(5 ignored),失败的是已知并发 flaky 用例 `agent::runtime_protocol::steering::goal_contract_steer_transition_tests::concurrent_distinct_frozen_root_steers_create_only_one_replacement`(单跑也时好时坏,与本次改动无关)。 + +## 2026-10-01 工具失败改成 typed 错误穿出 dispatch 边界,统一事件携带原始 error + +- 背景:工具桥的派发边界把失败吞成 `Value`——`bridge_outcome(root, result)` 内部就把 typed 错误压成一句 `bridge_tool_failure` 文案,`isError` 由工具与桥手传;诊断只能从结果值里回读 `/content/0/text` 与(上一阶段临时挂在结果上的)`error` 键。结果是「哪一轮、哪个工具、什么结构化事实」在派发期间被降级成字符串,composer 只能靠 `isError=true` 反推分支。 +- 决策(dispatch 边界):`handle_direct_tool_bridge` 里的 `dispatch` 现在返回 `Result`,失败原样穿出,不再在派发期压成 `Value`。`ToolCallError { message, redact_limit, error }` 是唯一载体:泛型 `impl From<&T>` 把具体错误 enum 的 `to_user_msg()`、`redact_limit()` 和原样序列化一起带出来——不做跨工具大 enum,不 Box(`?Sized` 让 trait 对象也能转)。`bridge_outcome` 随之删除。 +- 决策(composer):工具桥只有一个出口 `compose_direct_tool_outcome(state, tool, arguments, dispatchDenied, outcome)`:`Ok` 补 `isError=false`,`Err` 补 `isError=true` 并落一次诊断。`isError` 由分支决定,工具与桥都不再手传;MCP 完成包仍必须带布尔 `isError`(`direct_tools_mcp.rs::call_client_tool_bridge` 会校验),所以两条分支都写。五条早退(付费 / 写入 / 执行许可取不到、许可任务丢失、结算未落盘 `ReceiptNotPersisted`)也走同一出口:它们以前直接 `return Json(bridge_tool_failure(…))`,不写诊断。 +- 决策(诊断字段):`AgentRuntimeErrorEvent` 新增 `error`——产生失败的 typed 错误 enum 的原样序列化,`Value::Null` 表示该调用方(`agent/runtime_state.rs` 的终态公开消息)没有 typed 错误;`message` 仍是给人(以及转述给用户的模型)的那句话。应用日志详情行由 `message=… metadata=…` 扩成 `message=… error=… metadata=…`,`error` 取 400 字符预算(`AGENT_RUNTIME_ERROR_APP_LOG_ERROR_CHARS`)。`AGENT_RUNTIME_ERROR_SCHEMA_VERSION` 升到 `agent-runtime-error.v3`(sidecar 无读取方,不需要兼容层)。sidecar 里的 `error` 与既有 `metadata` 一样按原文落盘、只在应用日志里脱敏:sidecar 留在项目内不上传,上传的日志那份已经过 `redact_agent_runtime_error`。 +- 决策(周边收口):`bridge_tool_result(text, images)` 去掉 `is_error` 入参,模板只剩 `{ content }`;`record_completed_regeneration` 不再用 `isError` 判「能否缓存这次美术重生成」——走到那里的只有 `Ok`(失败会 `?` 上抛),`ArtRegenerationAuthorizationRejection::CompletedResultNotSuccessful` 因此退役。 +- 决策(软失败清零,同日续做):不接受「工具调用本身成功、载荷里写着失败」的软失败,十处全部改成各工具自己的 typed 变体,载荷原样进变体、由 `to_user_msg()` 拼成给模型的文案:`ImportAccountAssetsError::ImportFailed / ImportPartial`、`RunValidationError::ValidationNotPassed`、`BrowserPlaytestError::PlaytestNotPassed`、`EnvironmentCheckError::EnvironmentNotReady`、`ApplyPatchError::PatchNotApplied`、`EditorExecuteError::ExecutionNotCompleted / ExecutionUnconfirmed`、`CocosExecuteError::ExecutionBusy / ReconciliationPending / ExecutionNotCompleted`。于是 `isError` 与执行租约的 `passed`(`result.is_ok()`)重新对齐:execute 类失败仍落 `ExecutionPhase::Draining`。 +- 决策(证据图):`ToolFailure` 增加 `attached_images() -> Vec`(默认空),`ToolCallError` 增加 `images`;composer 的 `Err` 分支在 `bridge_tool_failure` 的成文结果上追加 MCP image block。带图的两个变体(`ValidationNotPassed` / `PlaytestNotPassed` / `ExecutionNotCompleted`)把截图正文放在 `#[serde(skip)]` 字段里:图要回到结果里,但 base64 不能进 sidecar 的 `error` 正文。`bridge_validation_result` 由泛型 `?` 改成接受一个 `fn(Value, Vec) -> E` 构造器,验证与试玩共用同一份 `passed` / 截图投影。 +- 改动范围:`agent/direct_tool_bridge.rs`、`agent/runtime_error.rs`、`agent/tool/error.rs` 与 23 个 `agent/tool//error.rs`(补 `serde::Serialize`,`EditorKind` 同样补)、`agent/tool/run_validation/error.rs`、`agent/tool/browser_playtest/error.rs`、`agent/tool/environment_check/error.rs`、`agent/tool/apply_patch/error.rs`、`agent/tool/import_account_assets/error.rs`、`agent/tool/editor_execute/error.rs`、`agent/tool/cocos_execute/error.rs`、`agent/tool/prepare_game_art/error.rs`、`agent/generation/canvas_generation.rs`(并发测试改用 `Result`)、`agent/runtime_state.rs`、`agent/direct_runtime/mod.rs`;同步修正 `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的统一事件字段表与应用日志两行口径。 +- 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过(无新增警告);Windows 专属的 Cocos / Unity / Godot 执行路径在 Linux 上临时去掉 `#[cfg(all(windows, …))]` 的 `windows` 条件后,用 `cargo check --features cocos-editor-execute,unity-editor-execute,godot-editor-execute --tests` 交叉编译校验通过,随后原样还原(没有 Windows 真机构建);`cargo test --bin genarrative-ai-game-creator-shell -- agent::` 951 passed / 0 failed(5 ignored);`-- agent::direct_tool_bridge:: agent::direct_tools_mcp:: agent::runtime_error::` 73 passed;`-- agent:: tests::project::` 1076 passed / 2 failed,两条都是并行负载下的已知 flake(`agent::runtime_actions::provider_request_builders::tests::art_director_request_exposes_canvas_only_for_the_keyed_owner_route` 与 `tests::project::background_agent_runtime_can_generate_platform_art_asset`),单跑各自通过;`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e3d4ee5e2..749f49565 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1647,7 +1647,7 @@ DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视 ## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈 -DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / occurredAt / elapsedMs / message / detailRef`,其中 `message` 是产生失败的 typed 错误在失败现场写好、脱敏后的人类可读文案,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。 +DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / occurredAt / elapsedMs / message / error / detailRef`,其中 `message` 是产生失败的 typed 错误在失败现场写好、脱敏后的人类可读文案,`error` 是同一个 typed 错误 enum 的原样序列化(一个 case 一个变体,供开发者按变体与字段定位;没有 typed 错误的调用方写 `null`),`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。 项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示宿主给的安全文案;诊断正文只留在 `detailRef` 指向的有界、脱敏记录里,不进入用户可见文本。 @@ -1797,9 +1797,9 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面 ## 2026-09-21 统一错误事件同时落到 AppData 应用日志 -`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / clientTurnId / elapsedMs / detailRef`)与 `agent.runtime.error.detail`(详情行:`message / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 +`AgentRuntimeErrorEvent` 把失败投影到用户消息、运行面板和项目内 `.agent/runtime/errors/.json` 时,同一份已脱敏诊断还要投影成 AppData `diagnostics/application.log` 的两行:`agent.runtime.error`(身份行:`eventId / source / stage / code / clientTurnId / elapsedMs / detailRef`)与 `agent.runtime.error.detail`(详情行:`message / error / metadata`)。原因是项目内 sidecar 只在项目目录可见,而“报告问题”只上传应用级日志:没有这两行时,用户提交的失败消息里只剩一个 `详情:.agent/runtime/errors/...json` 路径,团队拿不到诊断正文。 -口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;sidecar 里的 `message` 按 8 KiB 上限、应用日志的 `message` 与 `metadata` 按(1200 / 200 字符)预算先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位:所以**自由文本(message)只放详情行**,身份行只留程序生成与调用方常量字段,详情行被整体脱敏时事件仍能按 eventId / detailRef 定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 +口径:两行都由 `agent/runtime_error.rs` 从同一份 diagnosis 生成,字段不退化成第二份来源;sidecar 里的 `message` 按 8 KiB 上限、应用日志的 `message` / `error` / `metadata` 按(1200 / 400 / 200 字符)预算先脱敏再截断,落盘前还会被 `sanitize_diagnostic_message` 二次脱敏并按行截断,因此自由文本字段在行内先压平换行。拆两行是因为整行一旦出现凭据标记会被整体替换成脱敏占位:所以**自由文本(message)只放详情行**,身份行只留程序生成与调用方常量字段,详情行被整体脱敏时事件仍能按 eventId / detailRef 定位。写日志先于写 sidecar:sidecar 失败不能连日志一起丢。 ## 2026-09-23 AGC UI 设计文档 Agent 工具化重写