From 9c7cf2601c794a542d6d4f933a3df4e69196493d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 14:57:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=85=A5=E5=8F=82=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E6=A0=A1=E9=AA=8C=E5=8D=87=E7=BA=A7=E4=B8=BA=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E7=BA=A6=E6=9D=9F=EF=BC=8C=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8F=AF=E8=83=BD=E7=BB=95=E8=BF=87=20NotObj?= =?UTF-8?q?ect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ToolArgumentsRejection::check 返回已确认的 &Map - optional_text / required_text / page 改收 &Map,删掉 debug_assert 兜底 - 内部入参解析 helper 同步收 &Map;桥与 MCP 预检的 &Value 入口保持不变 - 更新直连工具桥与单测调用点 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 101 ++++++++++-------- .../src-tauri/src/agent/direct_tools_mcp.rs | 4 +- .../src-tauri/src/agent/tool/arguments.rs | 27 ++--- .../src-tauri/src/agent/tool/error.rs | 11 +- 4 files changed, 78 insertions(+), 65 deletions(-) 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 5b2ddf614..9d8a62c4d 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 @@ -48,7 +48,7 @@ use axum::routing::post; use axum::{Json, Router}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use std::collections::BTreeMap; use std::io::Read; use std::path::{Path, PathBuf}; @@ -797,7 +797,7 @@ impl AccountAssetImportField { } fn import_account_asset_string_array( - arguments: &Value, + arguments: &Map, field: AccountAssetImportField, ) -> Result, ImportAccountAssetsError> { let Some(value) = arguments.get(field.key()).filter(|value| !value.is_null()) else { @@ -856,7 +856,7 @@ fn import_account_asset_string_array( pub(in crate::agent) fn account_asset_import_inputs( arguments: &Value, ) -> Result<(Vec, Vec), ImportAccountAssetsError> { - ToolArgumentsRejection::check(arguments, &["assetIds", "localPaths"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["assetIds", "localPaths"])?; let asset_ids = import_account_asset_string_array(arguments, AccountAssetImportField::AssetIds)?; let local_paths = @@ -962,7 +962,7 @@ impl ToolTextField for ResourceGenerationTextField pub(in crate::agent) fn resource_generation_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check( + let arguments = ToolArgumentsRejection::check( arguments, &["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"], )?; @@ -1199,7 +1199,7 @@ pub(in crate::agent) struct ListRegisteredAssetsInput { pub(in crate::agent) fn list_registered_assets_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check( + let arguments = ToolArgumentsRejection::check( arguments, &[ "kind", @@ -1555,7 +1555,8 @@ pub(in crate::agent) struct ListProjectFilesInput { pub(in crate::agent) fn list_project_files_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check(arguments, &["path", "query", "kind", "offset", "limit"])?; + let arguments = + ToolArgumentsRejection::check(arguments, &["path", "query", "kind", "offset", "limit"])?; let path = optional_text(arguments, ListProjectFilesTextField::Path)?; if let Some(raw) = path.as_deref() { let slash_normalized = raw.replace('\\', "/"); @@ -1763,7 +1764,7 @@ fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Optio pub(in crate::agent) fn write_file_input( arguments: &Value, ) -> Result<(String, String), WriteFileError> { - ToolArgumentsRejection::check(arguments, &["path", "content"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["path", "content"])?; let raw_path = arguments .get("path") .and_then(Value::as_str) @@ -1999,7 +2000,8 @@ pub(in crate::agent) struct ListAccountAssetsInput { pub(in crate::agent) fn list_account_assets_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check(arguments, &["folderId", "query", "offset", "limit"])?; + let arguments = + 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()); @@ -2387,7 +2389,7 @@ pub(in crate::agent) struct RemoveBackgroundInput { pub(in crate::agent) fn remove_background_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check( + let arguments = ToolArgumentsRejection::check( arguments, &[ "sourceLocalAssetId", @@ -2721,14 +2723,14 @@ fn prepare_game_art_request( pub(in crate::agent) fn prepare_game_art_input( arguments: &Value, ) -> Result<(String, DirectTaonierArtPreparationMode), PrepareGameArtError> { - ToolArgumentsRejection::check(arguments, &["brief", "mode"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["brief", "mode"])?; let brief = required_text(arguments, PrepareGameArtTextField::Brief)?; Ok((brief, art_preparation_mode(arguments)?)) } /// `mode` 缺省为 `reuse-or-create`;给了就必须是受支持的模式名。 fn art_preparation_mode( - arguments: &Value, + arguments: &Map, ) -> Result { match arguments.get("mode").filter(|value| !value.is_null()) { None => Ok(DirectTaonierArtPreparationMode::ReuseOrCreate), @@ -2894,7 +2896,7 @@ impl ToolTextField for GenerateImageTextField { } fn generate_image_grid_axis( - arguments: &Value, + arguments: &Map, axis: &'static str, ) -> Result, GenerateImageError> { let Some(value) = arguments.get(axis) else { @@ -2908,7 +2910,7 @@ fn generate_image_grid_axis( } fn bridge_image_generation_kind( - arguments: &Value, + arguments: &Map, ) -> Result { // 缺省与显式 null 用 schema 声明的默认值;给了别的类型不许静默当 image。 let kind = match arguments.get("kind") { @@ -2955,7 +2957,7 @@ fn validate_generate_image_slice_declaration( /// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, /// 避免服务端静默忽略造成“已生效”的误解。 fn normalize_generate_image_screen_color( - arguments: &Value, + arguments: &Map, kind: GameCreationAppAssetKind, ) -> Result, GenerateImageError> { let Some(value) = arguments.get("screenColor") else { @@ -3012,7 +3014,7 @@ pub(in crate::agent) struct GenerateImageInput { pub(in crate::agent) fn generate_image_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check(arguments, GENERATE_IMAGE_ARGUMENTS)?; + let arguments = ToolArgumentsRejection::check(arguments, GENERATE_IMAGE_ARGUMENTS)?; let prompt = required_text(arguments, GenerateImageTextField::Prompt)?; let kind = bridge_image_generation_kind(arguments)?; let aspect_ratio = optional_text(arguments, GenerateImageTextField::AspectRatio)? @@ -3254,7 +3256,8 @@ impl ToolTextField for EditImageTextField { /// /// 工具桥与 MCP 预检共用这一份入参规则;失败文案只写在 [`EditImageError`] 上。 pub(in crate::agent) fn edit_image_arguments(arguments: &Value) -> Result { - ToolArgumentsRejection::check(arguments, &["sourceLocalAssetId", "prompt", "assetName"])?; + let arguments = + ToolArgumentsRejection::check(arguments, &["sourceLocalAssetId", "prompt", "assetName"])?; let source = required_text(arguments, EditImageTextField::SourceLocalAssetId)?; let prompt = required_text(arguments, EditImageTextField::Prompt)?; let asset_name = required_text(arguments, EditImageTextField::AssetName)?; @@ -3324,7 +3327,7 @@ async fn bridge_environment_check( root: &Path, arguments: &Value, ) -> Result { - ToolArgumentsRejection::check(arguments, &[])?; + let arguments = ToolArgumentsRejection::check(arguments, &[])?; enforce_project_permission_policy_rejection(root, "game.run_local")?; let result = crate::environment_check::check_environment(root).await; if result["status"] != "ready" { @@ -3480,7 +3483,7 @@ impl ToolTextField for WebSearchTextField { pub(in crate::agent) fn web_search_input( arguments: &Value, ) -> Result<(String, usize), WebSearchError> { - ToolArgumentsRejection::check(arguments, &["query", "maxResults"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["query", "maxResults"])?; let query = required_text(arguments, WebSearchTextField::Query)?; let max_results = match arguments.get("maxResults") { None => 3u64, @@ -3652,7 +3655,7 @@ pub(in crate::agent) fn editor_execute_code_input( arguments: &Value, editor: EditorKind, ) -> Result { - ToolArgumentsRejection::check(arguments, &["code"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["code"])?; let code = arguments .get("code") .and_then(Value::as_str) @@ -3698,7 +3701,7 @@ fn prepare_editor_execute( pub(in crate::agent) fn cocos_execute_code_input( arguments: &Value, ) -> Result { - ToolArgumentsRejection::check(arguments, &["code"])?; + let arguments = ToolArgumentsRejection::check(arguments, &["code"])?; let code = arguments .get("code") .and_then(Value::as_str) @@ -4611,13 +4614,13 @@ mod tests { fn generate_image_screen_color_is_normalized_and_kind_gated() { // 省略与显式 null 等价,且不触发 kind 门禁。 assert_eq!( - normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image) + normalize_generate_image_screen_color(obj(&json!({})), GameCreationAppAssetKind::Image) .expect("omitted"), None ); assert_eq!( normalize_generate_image_screen_color( - &json!({"screenColor": null}), + obj(&json!({"screenColor": null})), GameCreationAppAssetKind::Image ) .expect("null"), @@ -4627,7 +4630,7 @@ mod tests { for raw in ["auto", "AUTO", " auto ", ""] { assert_eq!( normalize_generate_image_screen_color( - &json!({"screenColor": raw}), + obj(&json!({"screenColor": raw})), GameCreationAppAssetKind::Character ) .expect("auto variants"), @@ -4638,7 +4641,7 @@ mod tests { // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 assert_eq!( normalize_generate_image_screen_color( - &json!({"screenColor": "#cfefff"}), + obj(&json!({"screenColor": "#cfefff"})), GameCreationAppAssetKind::Character ) .expect("lowercase hex"), @@ -4646,7 +4649,7 @@ mod tests { ); assert_eq!( normalize_generate_image_screen_color( - &json!({"screenColor": " #A0BBA0 "}), + obj(&json!({"screenColor": " #A0BBA0 "})), GameCreationAppAssetKind::IconSpritesheet ) .expect("padded hex"), @@ -4656,7 +4659,7 @@ mod tests { for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { assert!( normalize_generate_image_screen_color( - &json!({"screenColor": bad}), + obj(&json!({"screenColor": bad})), GameCreationAppAssetKind::Character ) .is_err(), @@ -4665,14 +4668,14 @@ mod tests { } // 其它 kind 携带该字段直接拒绝,即使取值合法。 let gated = normalize_generate_image_screen_color( - &json!({"screenColor": "#CFEFFF"}), + obj(&json!({"screenColor": "#CFEFFF"})), GameCreationAppAssetKind::Image, ) .expect_err("screenColor must stay scoped to character/icon-spritesheet") .to_user_msg(); assert!(gated.contains("kind=character"), "{gated}"); assert!(normalize_generate_image_screen_color( - &json!({"screenColor": "auto"}), + obj(&json!({"screenColor": "auto"})), GameCreationAppAssetKind::UiDesign ) .is_err()); @@ -4702,6 +4705,10 @@ mod tests { } } use super::*; + + fn obj(value: &Value) -> &Map { + value.as_object().expect("测试入参必须是对象") + } use std::io::{Cursor, Read, Write}; #[tokio::test] @@ -4802,9 +4809,11 @@ mod tests { #[test] fn bridge_argument_bounds_are_deterministic() { - assert!(required_text(&json!({ "brief": "" }), PrepareGameArtTextField::Brief).is_err()); + assert!( + required_text(obj(&json!({ "brief": "" })), PrepareGameArtTextField::Brief).is_err() + ); assert!(required_text( - &json!({ "brief": "字".repeat(DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS + 1) }), + obj(&json!({ "brief": "字".repeat(DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS + 1) })), PrepareGameArtTextField::Brief ) .is_err()); @@ -4819,7 +4828,7 @@ mod tests { DirectTaonierArtPreparationMode::Regenerate ); assert!(DirectTaonierArtPreparationMode::from_tool_value(Some("force")).is_err()); - assert!(art_preparation_mode(&json!({ "mode": 1 })).is_err()); + assert!(art_preparation_mode(obj(&json!({ "mode": 1 }))).is_err()); assert!(resource_generation_input(&json!({ "kind": "video", "mode": "create", @@ -4929,10 +4938,12 @@ mod tests { "{interior_newline}" ); - let whitespace_only = - required_text(&json!({ "brief": "\n\t" }), PrepareGameArtTextField::Brief) - .expect_err("纯空白仍然按空值处理") - .to_user_msg(); + let whitespace_only = required_text( + obj(&json!({ "brief": "\n\t" })), + PrepareGameArtTextField::Brief, + ) + .expect_err("纯空白仍然按空值处理") + .to_user_msg(); assert!( whitespace_only.contains("brief 不能为空"), "{whitespace_only}" @@ -4940,7 +4951,7 @@ mod tests { assert_eq!( required_text( - &json!({ "brief": " 美术包 " }), + obj(&json!({ "brief": " 美术包 " })), PrepareGameArtTextField::Brief ) .expect("首尾普通空格仍正常裁剪"), @@ -4985,9 +4996,9 @@ mod tests { oversized_not_text.chars().count() ); - let inline_not_text = art_preparation_mode(&json!({ + let inline_not_text = art_preparation_mode(obj(&json!({ "mode": vec!["需要回显但必须截断的模式值"; 40] - })) + }))) .expect_err("mode 不是字符串时必须被拒绝") .to_user_msg(); assert!( @@ -4999,18 +5010,20 @@ mod tests { "内联判类型的字段回显也要限长:{inline_not_text}" ); - let brief_not_text = required_text(&json!({ "brief": 5 }), PrepareGameArtTextField::Brief) - .expect_err("brief 不是字符串时必须被拒绝") - .to_user_msg(); + let brief_not_text = + required_text(obj(&json!({ "brief": 5 })), PrepareGameArtTextField::Brief) + .expect_err("brief 不是字符串时必须被拒绝") + .to_user_msg(); assert!( brief_not_text.contains("brief 必须是字符串"), "{brief_not_text}" ); assert!(!brief_not_text.contains("不能为空"), "{brief_not_text}"); - let empty_brief = required_text(&json!({ "brief": "" }), PrepareGameArtTextField::Brief) - .expect_err("空 brief 仍然按空值处理") - .to_user_msg(); + let empty_brief = + required_text(obj(&json!({ "brief": "" })), PrepareGameArtTextField::Brief) + .expect_err("空 brief 仍然按空值处理") + .to_user_msg(); assert!(empty_brief.contains("brief 不能为空"), "{empty_brief}"); let aspect_ratio_not_text = match generate_image_input(&json!({ 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 b7919d678..9d14a7520 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 @@ -825,7 +825,9 @@ 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(|_| ()) + .map_err(|rejection| rejection.to_user_msg()) } /// 与工具桥共用同一份 `agc_list_registered_assets` 入参规则。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/arguments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/arguments.rs index 9e120e3fd..e737bcafa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/arguments.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/arguments.rs @@ -3,7 +3,7 @@ //! 每个工具为自己的文本字段定义一个 `enum` 并实现 [`ToolTextField`],把「空值/超长/ //! 控制字符」三个变体告诉解析器;解析逻辑只有这一份,各工具的变体名与文案仍是自己的。 -use serde_json::Value; +use serde_json::{Map, Value}; use super::error::{bounded_echo, ToolPageRejection}; @@ -34,9 +34,9 @@ pub(crate) trait ToolTextField: Copy { /// 必填文本字段:缺省、`null`、空串都算空值。 /// -/// 契约同 [`optional_text`]:调用方必须先确认入参是对象。 +/// 契约同 [`optional_text`]:调用方必须先拿到 [`ToolArgumentsRejection::check`] 返回的对象。 pub(crate) fn required_text, E>( - arguments: &Value, + arguments: &Map, field: F, ) -> Result { optional_text(arguments, field)?.ok_or_else(|| field.empty()) @@ -44,17 +44,12 @@ pub(crate) fn required_text, E>( /// 可选文本字段:缺省或 `null` 视为未提供;给了就必须在该字段自己的边界内。 /// -/// 调用方必须先用 `ToolArgumentsRejection::check` 确认入参是 JSON 对象:非对象时 -/// `Value::get` 恒为 `None`,这里会静默当成「没给这个字段」,把 `NotObject` 判成 `empty`。 -/// 所有调用点现在都先跑 `check`,`debug_assert` 只保证以后不会有人绕过。 +/// 收 [`Map`] 而不是 [`Value`]:「入参是对象」由 [`ToolArgumentsRejection::check`] 在类型上 +/// 保证,解析器不可能拿到非对象后把 `NotObject` 静默当成「没给这个字段」。 pub(crate) fn optional_text, E>( - arguments: &Value, + arguments: &Map, field: F, ) -> Result, E> { - debug_assert!( - arguments.is_object(), - "optional_text 的调用方必须先跑 ToolArgumentsRejection::check" - ); let Some(value) = arguments.get(field.key()).filter(|value| !value.is_null()) else { return Ok(None); }; @@ -93,18 +88,14 @@ pub(crate) const MAX_PAGE_OFFSET: usize = 1_000_000; /// 调用点必须保证 `default_limit <= max_limit`:否则省略 `limit` 的请求会拿一个本身就越界的 /// 默认值去撞上限、每次都失败。这里用 `debug_assert` 让配错在测试期就炸出来。 /// -/// 同样要求调用方先确认入参是对象:非对象时 `offset`/`limit` 都会被当成没给,静默回落到默认 -/// 分页,把 `NotObject` 掩盖掉。 +/// 收 [`Map`] 而不是 [`Value`]:同 [`optional_text`],对象前提由 +/// [`ToolArgumentsRejection::check`] 在类型上保证。 pub(crate) fn page( - arguments: &Value, + arguments: &Map, default_limit: usize, max_limit: usize, max_offset: Option, ) -> Result { - debug_assert!( - arguments.is_object(), - "page 的调用方必须先跑 ToolArgumentsRejection::check" - ); debug_assert!(default_limit <= max_limit); let offset = arguments .get("offset") 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 43f9ee47f..34826f812 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 @@ -55,8 +55,15 @@ pub(crate) enum ToolArgumentsRejection { } impl ToolArgumentsRejection { - /// 按该工具声明的字段表校验入参形状。 - pub(crate) fn check(arguments: &Value, allowed: &[&str]) -> Result<(), Self> { + /// 按该工具声明的字段表校验入参形状,并返回已确认的对象。 + /// + /// 返回值就是「入参是对象且字段表通过」的凭证:[`crate::agent::tool::arguments::optional_text`] + /// / `required_text` / `page` 只接受这里的 `&Map`,release 下也不存在「漏跑 check 就把 + /// `NotObject` 当成字段缺失」的路径。 + pub(crate) fn check<'a>( + arguments: &'a Value, + allowed: &[&str], + ) -> Result<&'a serde_json::Map, Self> { let object = arguments.as_object().ok_or(Self::NotObject)?; match object .keys()