From a983597f133c3e2a7dab615c97a15702002695be Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Tue, 21 Jul 2026 19:24:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Agent=20=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E4=B8=A5=E6=A0=BC=20Schema=20=E5=85=BC?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src-tauri/src/agent.rs | 6 +- .../src-tauri/src/agent_native_tools.rs | 339 +++++++++++++++--- 2 files changed, 302 insertions(+), 43 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index b6c87603e..1cb8d1faa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -28984,9 +28984,9 @@ fn apply_agent_runtime_autonomous_source_schema_limits_with_max( ( "project.patchset", vec![ - "/properties/input/properties/changes/items/oneOf/0/properties/content", - "/properties/input/properties/changes/items/oneOf/1/properties/oldText", - "/properties/input/properties/changes/items/oneOf/1/properties/newText", + "/properties/input/properties/changes/items/properties/content", + "/properties/input/properties/changes/items/properties/oldText", + "/properties/input/properties/changes/items/properties/newText", ], ), ]; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index d59162317..9f96f6f83 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -362,17 +362,21 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( format!("Agent 原生工具协议错误:{} reason 不能为空", call.name), )); } + let mut input = arguments.input; if runtime_tool.as_deref() == Some("agent.delegate") { - validate_native_agent_delegate_input(&arguments.input)?; + validate_native_agent_delegate_input(&input)?; + } + if runtime_tool.as_deref() == Some("project.patchset") { + input = normalize_native_project_patchset_input(input)?; } let action = if let Some(tool) = runtime_tool { AgentRuntimeToolAction { tool, reason: Some(arguments.reason), - input: arguments.input, + input, } } else if let Some(tool) = mcp_tool { - if !arguments.input.is_object() { + if !input.is_object() { return Err(protocol_error( AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, format!("Agent 原生 MCP 工具 {} input 必须是 object", call.name), @@ -384,7 +388,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls( input: json!({ "server": tool.server_id, "tool": tool.name, - "arguments": arguments.input, + "arguments": input, }), } } else { @@ -506,6 +510,153 @@ fn validate_native_agent_delegate_input( Ok(()) } +fn normalize_native_project_patchset_input( + input: Value, +) -> Result { + const CHANGE_FIELDS: [&str; 7] = [ + "operation", + "path", + "content", + "expectedSha256", + "oldText", + "newText", + "expectedReplacements", + ]; + let object = input.as_object().ok_or_else(|| { + protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:project.patchset input 必须是 object", + ) + })?; + if object.len() != 1 || !object.contains_key("changes") { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:project.patchset input 字段无效", + )); + } + let changes = object + .get("changes") + .and_then(Value::as_array) + .ok_or_else(|| { + protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + "Agent 原生工具协议错误:project.patchset changes 必须是数组", + ) + })?; + let mut normalized = Vec::with_capacity(changes.len()); + for (index, change) in changes.iter().enumerate() { + let change = change.as_object().ok_or_else(|| { + protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}] 必须是 object", + index + 1 + ), + ) + })?; + if change.len() != CHANGE_FIELDS.len() + || CHANGE_FIELDS + .iter() + .any(|field| !change.contains_key(*field)) + || change + .keys() + .any(|field| !CHANGE_FIELDS.contains(&field.as_str())) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}] 字段无效", + index + 1 + ), + )); + } + let required_string = |field: &str| { + change.get(field).and_then(Value::as_str).ok_or_else(|| { + protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须是字符串", + index + 1 + ), + ) + }) + }; + let require_null = |fields: &[&str]| { + if let Some(field) = fields + .iter() + .find(|field| !change.get(**field).is_some_and(Value::is_null)) + { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须为 null", + index + 1 + ), + )); + } + Ok(()) + }; + let operation = required_string("operation")?; + let path = required_string("path")?; + normalized.push(match operation { + "create" => { + require_null(&[ + "expectedSha256", + "oldText", + "newText", + "expectedReplacements", + ])?; + json!({ + "operation": operation, + "path": path, + "content": required_string("content")?, + }) + } + "update" => { + require_null(&["content"])?; + let expected_replacements = change + .get("expectedReplacements") + .and_then(Value::as_u64) + .ok_or_else(|| { + protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}].expectedReplacements 必须是整数", + index + 1 + ), + ) + })?; + json!({ + "operation": operation, + "path": path, + "expectedSha256": required_string("expectedSha256")?, + "oldText": required_string("oldText")?, + "newText": required_string("newText")?, + "expectedReplacements": expected_replacements, + }) + } + "delete" => { + require_null(&["content", "oldText", "newText", "expectedReplacements"])?; + json!({ + "operation": operation, + "path": path, + "expectedSha256": required_string("expectedSha256")?, + }) + } + _ => { + return Err(protocol_error( + AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema, + format!( + "Agent 原生工具协议错误:project.patchset changes[{}].operation 无效", + index + 1 + ), + )); + } + }); + } + Ok(json!({ "changes": normalized })) +} + fn validate_native_delegate_string( value: Option<&Value>, field: &str, @@ -892,7 +1043,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "properties": { "commandId": { "type": "string", "enum": ["game.static_smoke"] } } }), "preview.validate" => json!({ - "type": "object", "required": ["viewports", "expectedText", "settleMs", "failOnConsoleError"], "additionalProperties": false, + "type": "object", "required": ["viewports", "expectedText", "settleMs", "failOnConsoleError", "playtestScenario"], "additionalProperties": false, "properties": { "viewports": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "type": "string", "enum": ["desktop", "mobile"] } }, "expectedText": string_array_schema(16), @@ -1023,41 +1174,18 @@ fn project_patchset_input_schema() -> Value { "changes": { "type": "array", "minItems": 1, "maxItems": 12, "items": { - "oneOf": [ - { - "type": "object", - "required": ["operation", "path", "content"], - "additionalProperties": false, - "properties": { - "operation": { "type": "string", "enum": ["create"] }, - "path": { "type": "string", "minLength": 1 }, - "content": { "type": "string" } - } - }, - { - "type": "object", - "required": ["operation", "path", "expectedSha256", "oldText", "newText", "expectedReplacements"], - "additionalProperties": false, - "properties": { - "operation": { "type": "string", "enum": ["update"] }, - "path": { "type": "string", "minLength": 1 }, - "expectedSha256": { "type": "string", "minLength": 64, "maxLength": 64 }, - "oldText": { "type": "string", "minLength": 1 }, - "newText": { "type": "string" }, - "expectedReplacements": { "type": "integer", "minimum": 1 } - } - }, - { - "type": "object", - "required": ["operation", "path", "expectedSha256"], - "additionalProperties": false, - "properties": { - "operation": { "type": "string", "enum": ["delete"] }, - "path": { "type": "string", "minLength": 1 }, - "expectedSha256": { "type": "string", "minLength": 64, "maxLength": 64 } - } - } - ] + "type": "object", + "required": ["operation", "path", "content", "expectedSha256", "oldText", "newText", "expectedReplacements"], + "additionalProperties": false, + "properties": { + "operation": { "type": "string", "enum": ["create", "update", "delete"] }, + "path": { "type": "string", "minLength": 1 }, + "content": { "type": ["string", "null"] }, + "expectedSha256": { "type": ["string", "null"], "minLength": 64, "maxLength": 64 }, + "oldText": { "type": ["string", "null"], "minLength": 1 }, + "newText": { "type": ["string", "null"] }, + "expectedReplacements": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 } + } } } } @@ -1068,6 +1196,60 @@ fn project_patchset_input_schema() -> Value { mod tests { use super::*; + fn empty_catalog() -> GameCreatorMcpCatalog { + GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + } + } + + fn collect_openai_strict_schema_issues(schema: &Value, path: &str, issues: &mut Vec) { + let Some(object) = schema.as_object() else { + return; + }; + for keyword in ["oneOf", "anyOf", "allOf", "not"] { + if object.contains_key(keyword) { + issues.push(format!( + "strict schema contains unsupported {keyword} at {path}" + )); + } + } + if let Some(properties) = object.get("properties").and_then(Value::as_object) { + if object.get("additionalProperties") != Some(&Value::Bool(false)) { + issues.push(format!( + "strict object must reject additional properties at {path}" + )); + } + let property_names = properties.keys().cloned().collect::>(); + let required_names = object + .get("required") + .and_then(Value::as_array) + .map(|required| { + required + .iter() + .filter_map(Value::as_str) + .map(ToString::to_string) + .collect::>() + }); + if required_names.as_ref() != Some(&property_names) { + issues.push(format!( + "strict object must require every property at {path}: required={required_names:?}, properties={property_names:?}" + )); + } + for (name, child) in properties { + collect_openai_strict_schema_issues( + child, + &format!("{path}/properties/{name}"), + issues, + ); + } + } + if let Some(items) = object.get("items") { + collect_openai_strict_schema_issues(items, &format!("{path}/items"), issues); + } + } + fn valid_delegate_input(repair_of_delegation_id: Value, run_id: Value) -> Value { json!({ "agentId": "specialist", @@ -1125,4 +1307,81 @@ mod tests { assert!(description.contains("repairOfDelegationId 指向原 delivery")); assert!(description.contains("runId 必须为 null")); } + + #[test] + fn strict_native_function_schemas_match_openai_subset() { + let functions = build_agent_runtime_native_function_tools(&empty_catalog()) + .expect("build native function tools"); + let mut issues = Vec::new(); + for function in functions.iter().filter(|function| function.strict) { + collect_openai_strict_schema_issues(&function.parameters, &function.name, &mut issues); + } + assert!(issues.is_empty(), "{}", issues.join("\n")); + } + + #[test] + fn native_project_patchset_normalizes_nullable_strict_shape() { + let arguments = json!({ + "reason": "原子应用三类变更", + "input": { + "changes": [ + { + "operation": "create", + "path": "game/new.txt", + "content": "created", + "expectedSha256": null, + "oldText": null, + "newText": null, + "expectedReplacements": null + }, + { + "operation": "update", + "path": "game/main.txt", + "content": null, + "expectedSha256": "a".repeat(64), + "oldText": "before", + "newText": "after", + "expectedReplacements": 1 + }, + { + "operation": "delete", + "path": "game/old.txt", + "content": null, + "expectedSha256": "b".repeat(64), + "oldText": null, + "newText": null, + "expectedReplacements": null + } + ] + } + }); + let parsed = parse_agent_runtime_native_tool_calls( + &[LlmToolCall { + id: "patchset-call".to_string(), + name: native_runtime_function_name("project.patchset") + .expect("native patchset function name"), + arguments: serde_json::to_string(&arguments).expect("serialize arguments"), + }], + &empty_catalog(), + ) + .expect("parse strict patchset call"); + + assert_eq!( + parsed.plan.actions[0].input, + json!({ + "changes": [ + {"operation": "create", "path": "game/new.txt", "content": "created"}, + { + "operation": "update", + "path": "game/main.txt", + "expectedSha256": "a".repeat(64), + "oldText": "before", + "newText": "after", + "expectedReplacements": 1 + }, + {"operation": "delete", "path": "game/old.txt", "expectedSha256": "b".repeat(64)} + ] + }) + ); + } }