diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index df99a183c..91e8f5e2d 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1485,8 +1485,15 @@ for (const snippet of [ 'assertSafeGameCreatorConfigDestination', 'readGameCreatorWizardConfigState', '$security.SetAccessRuleProtection($true, $false)', + '$targetItem = Get-Item -LiteralPath $target -Force', + '$targetItem.SetAccessControl($security)', + '$verified = $targetItem.GetAccessControl()', '$rules.Count -ne 1', '[System.Security.AccessControl.FileSystemRights]::FullControl', + "runChildCapture('powershell.exe'", + "'-NoProfile'", + "'-Command'", + 'windowsPrivateAclScript', 'await secureWindowsPath(temporaryPath, { isDirectory: false })', 'await temporaryFile.writeFile', ]) { @@ -1496,6 +1503,11 @@ for (const snippet of [ ); } } +if (/\bGet-Acl\b/u.test(configWizardSource)) { + throw new Error( + 'AI game creator config wizard must not rely on Get-Acl module auto-loading', + ); +} await runConfigWizardRegressionChecks(); await runHiddenInputRegressionChecks(); diff --git a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs index 0d0522f22..dee615c1e 100644 --- a/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs +++ b/apps/ai-game-creator-shell/scripts/game-creator-config-wizard.mjs @@ -51,9 +51,10 @@ $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( [System.Security.AccessControl.AccessControlType]::Allow ) $security.AddAccessRule($rule) | Out-Null -(Get-Item -LiteralPath $target -Force).SetAccessControl($security) +$targetItem = Get-Item -LiteralPath $target -Force +$targetItem.SetAccessControl($security) -$verified = Get-Acl -LiteralPath $target +$verified = $targetItem.GetAccessControl() $owner = $verified.GetOwner([System.Security.Principal.SecurityIdentifier]) $rules = @($verified.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) if (-not $owner.Equals($currentSid) -or -not $verified.AreAccessRulesProtected -or $rules.Count -ne 1) { 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 95e7ab68a..4be2f67d0 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 @@ -811,7 +811,101 @@ fn plan_update_schema() -> Value { }) } -fn action_function_parameters(input_schema: Value) -> Value { +fn rebase_action_input_schema_refs_in_scope(value: &mut Value, has_local_resource_id: bool) { + let Value::Object(object) = value else { + return; + }; + + // `$id` 会建立独立 schema resource;其内部 fragment 应继续相对该 resource + // 解析,不能按外层 function parameters 根重定位。 + let has_local_resource_id = has_local_resource_id || object.contains_key("$id"); + let reference = object + .get("$ref") + .and_then(Value::as_str) + .map(ToString::to_string); + if let Some(reference) = reference { + // 只有空 fragment 和 JSON Pointer fragment 相对当前 document 根。 + // `#Mode` 是命名 anchor,外部 URI 也有自己的解析范围,必须保持原样。 + if !has_local_resource_id && (reference == "#" || reference.starts_with("#/")) { + let rebased = if reference == "#" { + "#/properties/input".to_string() + } else { + format!("#/properties/input{}", &reference[1..]) + }; + object.insert("$ref".to_string(), Value::String(rebased)); + } + } + + // 只进入 JSON Schema 明确定义为 subschema 的位置。default、const、examples、 + // enum 等关键词承载普通 JSON 数据,其中即使出现 `$ref` 也不能改写。 + for keyword in [ + "additionalProperties", + "unevaluatedProperties", + "propertyNames", + "additionalItems", + "unevaluatedItems", + "contains", + "not", + "if", + "then", + "else", + "contentSchema", + ] { + if let Some(child) = object.get_mut(keyword) { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + + for keyword in ["allOf", "anyOf", "oneOf", "prefixItems"] { + if let Some(Value::Array(children)) = object.get_mut(keyword) { + for child in children { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + } + + // draft-07 的 tuple validation 允许 items 为 schema 数组;新版本则为单 schema。 + if let Some(items) = object.get_mut("items") { + match items { + Value::Array(children) => { + for child in children { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + child => rebase_action_input_schema_refs_in_scope(child, has_local_resource_id), + } + } + + for keyword in [ + "$defs", + "definitions", + "properties", + "patternProperties", + "dependentSchemas", + ] { + if let Some(Value::Object(children)) = object.get_mut(keyword) { + for child in children.values_mut() { + rebase_action_input_schema_refs_in_scope(child, has_local_resource_id); + } + } + } + + // draft-07 dependencies 的 value 可能是 subschema,也可能是属性名数组。 + if let Some(Value::Object(dependencies)) = object.get_mut("dependencies") { + for dependency in dependencies.values_mut().filter(|value| value.is_object()) { + rebase_action_input_schema_refs_in_scope(dependency, has_local_resource_id); + } + } +} + +fn rebase_action_input_schema_refs(value: &mut Value) { + rebase_action_input_schema_refs_in_scope(value, false); +} + +fn action_function_parameters(mut input_schema: Value) -> Value { + // MCP 的 input schema 会被包进 action.input。局部 JSON Pointer 仍从整个 + // function parameters 根解析,因此必须同步重定位;否则 #/$defs/... 会悬空。 + rebase_action_input_schema_refs(&mut input_schema); json!({ "type": "object", "required": ["reason", "input"], @@ -1369,6 +1463,108 @@ mod tests { assert!(issues.is_empty(), "{}", issues.join("\n")); } + #[test] + fn action_function_parameters_rebases_local_schema_refs_after_wrapping() { + let parameters = action_function_parameters(json!({ + "type": "object", + "$defs": { + "Mode": {"type": "string", "enum": ["fast", "safe"]}, + "Options": { + "type": "object", + "properties": {"mode": {"$ref": "#/$defs/Mode"}}, + "required": ["mode"], + "additionalProperties": false + } + }, + "properties": { + "options": {"$ref": "#/$defs/Options"}, + "recursive": {"$ref": "#"}, + "anchor": {"$ref": "#Mode"}, + "scoped": { + "$id": "nested.json", + "$defs": {"Value": {"type": "string"}}, + "properties": {"value": {"$ref": "#/$defs/Value"}} + }, + "external": {"$ref": "https://schemas.example/tool.json"} + }, + "required": ["options"], + "additionalProperties": false + })); + + let input = ¶meters["properties"]["input"]; + assert_eq!( + input["properties"]["options"]["$ref"], + "#/properties/input/$defs/Options" + ); + assert_eq!( + input["$defs"]["Options"]["properties"]["mode"]["$ref"], + "#/properties/input/$defs/Mode" + ); + assert_eq!( + input["properties"]["recursive"]["$ref"], + "#/properties/input" + ); + assert_eq!(input["properties"]["anchor"]["$ref"], "#Mode"); + assert_eq!( + input["properties"]["scoped"]["properties"]["value"]["$ref"], + "#/$defs/Value" + ); + assert_eq!( + input["properties"]["external"]["$ref"], + "https://schemas.example/tool.json" + ); + for reference in [ + input["properties"]["options"]["$ref"] + .as_str() + .expect("options ref"), + input["$defs"]["Options"]["properties"]["mode"]["$ref"] + .as_str() + .expect("mode ref"), + input["properties"]["recursive"]["$ref"] + .as_str() + .expect("recursive ref"), + ] { + assert!( + parameters + .pointer(reference.trim_start_matches('#')) + .is_some(), + "rebased ref must resolve: {reference}" + ); + } + } + + #[test] + fn action_function_parameters_preserves_refs_inside_schema_data_keywords() { + let parameters = action_function_parameters(json!({ + "type": "object", + "$defs": { + "Value": {"type": "string"} + }, + "properties": { + "value": { + "$ref": "#/$defs/Value", + "default": {"$ref": "#/literal-default"}, + "const": { + "nested": [{"$ref": "#/literal-const"}] + }, + "examples": [ + {"$ref": "#/literal-example"}, + [{"$ref": "#/nested-literal-example"}] + ] + } + }, + "required": ["value"], + "additionalProperties": false + })); + + let value = ¶meters["properties"]["input"]["properties"]["value"]; + assert_eq!(value["$ref"], "#/properties/input/$defs/Value"); + assert_eq!(value["default"]["$ref"], "#/literal-default"); + assert_eq!(value["const"]["nested"][0]["$ref"], "#/literal-const"); + assert_eq!(value["examples"][0]["$ref"], "#/literal-example"); + assert_eq!(value["examples"][1][0]["$ref"], "#/nested-literal-example"); + } + #[test] fn native_project_patchset_normalizes_nullable_strict_shape() { let arguments = json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 7561cb15b..36ce07533 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -53,7 +53,7 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { - validate_game_creator_llm_web_search_config(llm, config_path)?; + let api_kind = validate_game_creator_llm_web_search_config(llm, config_path)?; let api_key = trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?; let base_url = @@ -61,6 +61,8 @@ fn build_game_creator_platform_llm_config( let model = trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?; validate_game_creator_llm_timing_config(llm, config_path)?; + let anthropic_strict_tool_support = + game_creator_supports_anthropic_strict_tools(api_kind, &base_url, &model); LlmConfig::new( LlmProvider::OpenAiCompatible, base_url, @@ -70,9 +72,54 @@ fn build_game_creator_platform_llm_config( llm.max_retries, llm.retry_backoff_ms, ) + .map(|config| config.with_anthropic_strict_tool_support(anthropic_strict_tool_support)) .map_err(|error| format!("LLM 配置无效:{error}")) } +fn game_creator_supports_anthropic_strict_tools( + api_kind: LlmApiKind, + base_url: &str, + model: &str, +) -> bool { + if api_kind != LlmApiKind::Anthropic { + return false; + } + + // 兼容网关即使复用了 Anthropic messages 协议,也不能据此推断 structured + // outputs 能力。只对无凭据、无自定义端口/路径的官方 HTTPS endpoint 开启。 + let Ok(endpoint) = url::Url::parse(base_url) else { + return false; + }; + if endpoint.scheme() != "https" + || endpoint.host_str() != Some("api.anthropic.com") + || endpoint.port().is_some() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.path() != "/" + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return false; + } + + // Claude API 的 structured outputs 从 Claude 4.5 起可用。仅识别官方 Claude + // family 的版本化 model id;不凭 `latest`、第三方别名或未知产品名猜能力。 + let normalized = model.trim().to_ascii_lowercase(); + let mut parts = normalized.split('-'); + if parts.next() != Some("claude") || !matches!(parts.next(), Some("opus" | "sonnet" | "haiku")) + { + return false; + } + let Some(major) = parts.next().and_then(|value| value.parse::().ok()) else { + return false; + }; + let minor = parts + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + major > 4 || (major == 4 && minor >= 5) +} + pub(crate) fn build_game_creator_llm_client_from_config() -> Result { let app_config = load_game_creator_app_config()?; build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm") @@ -174,7 +221,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, error: Some(error), agents: Vec::new(), - } + }; } }; let global_route_shape_error = @@ -1586,3 +1633,54 @@ pub(crate) fn game_creator_config_file_label(file_name: &str) -> String { .map(|directory| directory.join(file_name).display().to_string()) .unwrap_or_else(|| file_name.to_string()) } + +#[cfg(test)] +mod anthropic_strict_capability_tests { + use super::*; + + fn anthropic_config(base_url: &str, model: &str) -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + api_key: "test-key".to_string(), + base_url: base_url.to_string(), + model: model.to_string(), + api_kind: "anthropic".to_string(), + web_search_enabled: false, + ..GameCreatorLlmConfig::default() + } + } + + #[test] + fn official_supported_claude_model_opts_in_to_anthropic_strict_tools() { + for model in [ + "claude-sonnet-4-5-20250929", + "claude-opus-4-6", + "claude-haiku-5", + ] { + let config = build_game_creator_platform_llm_config( + &anthropic_config("https://api.anthropic.com", model), + "llm", + ) + .expect("supported official Anthropic config"); + assert!(config.anthropic_strict_tool_support(), "model={model}"); + } + } + + #[test] + fn old_models_and_compatible_or_lookalike_endpoints_keep_strict_disabled() { + for (base_url, model) in [ + ("https://api.anthropic.com", "claude-3-5-sonnet-latest"), + ("https://api.anthropic.com", "claude-sonnet-latest"), + ("https://minimax.example.com", "claude-sonnet-4-5"), + ("https://api.anthropic.com.example.com", "claude-sonnet-4-5"), + ("http://api.anthropic.com", "claude-sonnet-4-5"), + ] { + let config = + build_game_creator_platform_llm_config(&anthropic_config(base_url, model), "llm") + .expect("non-capable Anthropic config remains usable without strict"); + assert!( + !config.anthropic_strict_tool_support(), + "base_url={base_url}, model={model}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs index 066a6a37a..2c5f26c40 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/mcp.rs @@ -899,13 +899,47 @@ fn normalize_game_creator_mcp_catalog_tool( }) } +fn normalize_game_creator_mcp_server_tools( + server_id: &str, + config: &GameCreatorMcpServerConfig, + listed_tools: Vec, +) -> Result, String> { + if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER { + return Err(format!( + "MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}", + listed_tools.len() + )); + } + let mut server_tools = Vec::new(); + let mut server_tool_names = BTreeSet::new(); + for tool in listed_tools { + if !game_creator_mcp_tool_is_enabled(config, tool.name.as_ref()) { + continue; + } + if tool.task_support() == TaskSupport::Required { + return Err(format!( + "MCP tool {server_id}/{} 要求 task-mode,当前切片未支持", + tool.name + )); + } + if !server_tool_names.insert(tool.name.to_string()) { + return Err(format!( + "MCP server {server_id} 返回重复 tool identity:{}", + tool.name + )); + } + server_tools.push(normalize_game_creator_mcp_catalog_tool( + server_id, config, tool, + )?); + } + server_tools.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(server_tools) +} + pub(crate) async fn read_game_creator_mcp_catalog_at( root: &Path, ) -> Result { let config = load_game_creator_app_config()?; - let mut servers = Vec::new(); - let mut tools = Vec::new(); - let mut catalog_identity = Vec::new(); let server_reads = config .mcp_servers .into_iter() @@ -999,37 +1033,33 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( )); } }; - if listed_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER { - return Err(format!( - "MCP server {server_id} 返回 {} 个工具,超过单 server 上限 {GAME_CREATOR_MCP_MAX_TOOLS_PER_SERVER}", - listed_tools.len() - )); - } - let mut server_tools = Vec::new(); - let mut server_tool_names = BTreeSet::new(); - for tool in listed_tools { - if !game_creator_mcp_tool_is_enabled(&server_config, tool.name.as_ref()) { - continue; - } - if tool.task_support() == TaskSupport::Required { - return Err(format!( - "MCP tool {server_id}/{} 要求 task-mode,当前切片未支持", - tool.name + let server_tools = match normalize_game_creator_mcp_server_tools( + &server_id, + &server_config, + listed_tools, + ) { + Ok(server_tools) => server_tools, + Err(error) if server_config.required => return Err(error), + Err(error) => { + return Ok(( + GameCreatorMcpServerStatus { + server_id, + enabled: true, + required: false, + transport: server_config.transport, + connected: false, + server_name, + server_version, + instructions: instructions.clone(), + instructions_chars: instructions.chars().count(), + tool_count: 0, + error: Some(sanitize_game_creator_mcp_error(root, &error, 240)), + }, + Vec::new(), + None, )); } - if !server_tool_names.insert(tool.name.to_string()) { - return Err(format!( - "MCP server {server_id} 返回重复 tool identity:{}", - tool.name - )); - } - server_tools.push(normalize_game_creator_mcp_catalog_tool( - &server_id, - &server_config, - tool, - )?); - } - server_tools.sort_by(|left, right| left.name.cmp(&right.name)); + }; let catalog_identity = serde_json::json!({ "serverId": server_id, "configFingerprint": entry.config_fingerprint, @@ -1054,25 +1084,117 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( drop(entry); Ok((status, server_tools, Some(catalog_identity))) }); + let mut server_results = Vec::new(); for server_read in futures::future::join_all(server_reads).await { - let (server, server_tools, identity) = server_read?; - servers.push(server); - tools.extend(server_tools); - if let Some(identity) = identity { - catalog_identity.push(identity); + server_results.push(server_read?); + } + + let collect_catalog_parts = + |results: &[( + GameCreatorMcpServerStatus, + Vec, + Option, + )], + included_optional_servers: &BTreeSet| { + let mut candidate_servers = Vec::with_capacity(results.len()); + let mut candidate_tools = Vec::new(); + let mut candidate_identity = Vec::new(); + for (status, server_tools, identity) in results { + let included = status.required + || included_optional_servers.contains(status.server_id.as_str()); + let mut candidate_status = status.clone(); + if !included && candidate_status.connected { + candidate_status.connected = false; + candidate_status.tool_count = 0; + } + candidate_servers.push(candidate_status); + if included && status.connected { + candidate_tools.extend(server_tools.iter().cloned()); + if let Some(identity) = identity { + candidate_identity.push(identity.clone()); + } + } + } + (candidate_servers, candidate_tools, candidate_identity) + }; + + let catalog_prompt_bytes = |candidate_servers: Vec, + candidate_tools: Vec, + candidate_identity: &[serde_json::Value]| + -> Result { + let candidate = GameCreatorMcpCatalog { + fingerprint: game_creator_mcp_sha256(candidate_identity)?, + servers: candidate_servers, + tools: candidate_tools, + }; + Ok(render_game_creator_mcp_catalog_for_prompt(&candidate)? + .into_bytes() + .len()) + }; + + let mut included_optional_servers = BTreeSet::new(); + let (required_servers, required_tools, required_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); + if required_tools.len() > GAME_CREATOR_MCP_MAX_TOOLS { + return Err(format!( + "required MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}", + required_tools.len() + )); + } + let required_catalog_bytes = + catalog_prompt_bytes(required_servers, required_tools, &required_identity)?; + if required_catalog_bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES { + return Err(format!( + "required MCP catalog 为 {required_catalog_bytes} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}" + )); + } + + for index in 0..server_results.len() { + let status = &server_results[index].0; + if status.required || !status.connected { + continue; + } + let server_id = status.server_id.clone(); + included_optional_servers.insert(server_id.clone()); + let (candidate_servers, candidate_tools, candidate_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); + let candidate_tool_count = candidate_tools.len(); + let candidate_bytes = if candidate_tool_count <= GAME_CREATOR_MCP_MAX_TOOLS { + Some(catalog_prompt_bytes( + candidate_servers, + candidate_tools, + &candidate_identity, + )?) + } else { + None + }; + let capacity_error = if candidate_tool_count > GAME_CREATOR_MCP_MAX_TOOLS { + Some(format!( + "MCP server {server_id} 使目录工具总数达到 {candidate_tool_count},超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}" + )) + } else if candidate_bytes.is_some_and(|bytes| bytes > GAME_CREATOR_MCP_MAX_CATALOG_BYTES) { + Some(format!( + "MCP server {server_id} 使目录超过 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES} bytes 上限" + )) + } else { + None + }; + if let Some(error) = capacity_error { + included_optional_servers.remove(server_id.as_str()); + let status = &mut server_results[index].0; + status.connected = false; + status.tool_count = 0; + status.error = Some(sanitize_game_creator_mcp_error(root, &error, 240)); } } + + let (servers, mut tools, catalog_identity) = + collect_catalog_parts(&server_results, &included_optional_servers); tools.sort_by(|left, right| { left.server_id .cmp(&right.server_id) .then_with(|| left.name.cmp(&right.name)) }); - if tools.len() > GAME_CREATOR_MCP_MAX_TOOLS { - return Err(format!( - "MCP catalog 共 {} 个工具,超过上限 {GAME_CREATOR_MCP_MAX_TOOLS}", - tools.len() - )); - } let fingerprint = game_creator_mcp_sha256(&catalog_identity)?; let catalog = GameCreatorMcpCatalog { fingerprint, @@ -1080,12 +1202,7 @@ pub(crate) async fn read_game_creator_mcp_catalog_at( tools, }; let catalog_bytes = render_game_creator_mcp_catalog_for_prompt(&catalog)?.into_bytes(); - if catalog_bytes.len() > GAME_CREATOR_MCP_MAX_CATALOG_BYTES { - return Err(format!( - "MCP catalog 为 {} bytes,超过上限 {GAME_CREATOR_MCP_MAX_CATALOG_BYTES}", - catalog_bytes.len() - )); - } + debug_assert!(catalog_bytes.len() <= GAME_CREATOR_MCP_MAX_CATALOG_BYTES); Ok(catalog) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 4f098396f..47ffc7c36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -222,6 +222,120 @@ async fn mcp_optional_tools_list_failure_is_bounded_but_required_fails() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn mcp_optional_invalid_tool_catalog_is_isolated_but_required_fails() { + let root = unique_project_path(); + init_local_game_project_at(&root, "mcp-invalid-catalog", "MCP 非法目录项目") + .expect("initialize invalid MCP catalog project"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create invalid MCP catalog config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + + for (fixture_arg, expected_error) in [ + ("--oversized-input-schema", "input schema 超过上限"), + ("--duplicate-tool", "重复 tool identity"), + ("--require-task-mode", "要求 task-mode"), + ] { + let fixture = serde_json::json!({ + "required": false, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", fixture_arg] + }); + write_mcp_transport_test_config(&config_dir, "optional-invalid-fixture", fixture.clone()); + + let catalog = read_game_creator_mcp_catalog_at(&root) + .await + .expect("optional invalid tool catalog must stay in server status"); + assert!(catalog.tools.is_empty()); + let server = catalog.servers.first().expect("optional invalid status"); + assert!(!server.connected); + assert!( + server + .error + .as_deref() + .is_some_and(|error| { error.contains(expected_error) }), + "fixture={fixture_arg} status={server:?}" + ); + + shutdown_game_creator_mcp_clients_for_tests().await; + let mut required_fixture = fixture; + required_fixture["required"] = serde_json::Value::Bool(true); + write_mcp_transport_test_config(&config_dir, "required-invalid-fixture", required_fixture); + let error = read_game_creator_mcp_catalog_at(&root) + .await + .expect_err("required invalid tool catalog must fail the catalog"); + assert!( + error.contains(expected_error), + "fixture={fixture_arg} error={error}" + ); + shutdown_game_creator_mcp_clients_for_tests().await; + } + + drop(config_guard); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test] +async fn mcp_optional_server_is_isolated_when_aggregate_catalog_exceeds_tool_limit() { + let root = unique_project_path(); + init_local_game_project_at(&root, "mcp-aggregate-limit", "MCP 聚合上限项目") + .expect("initialize MCP aggregate limit project"); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create MCP aggregate limit config dir"); + let config_guard = use_test_runtime_config_dir(config_dir.clone()); + let fixture = mcp_fixture_script_path(); + let config = serde_json::json!({ + "mcpServers": { + "required-alpha": { + "required": true, + "transport": "stdio", + "command": "node", + "args": [fixture, "stdio", "--tool-count=64"] + }, + "required-beta": { + "required": true, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"] + }, + "optional-gamma": { + "required": false, + "transport": "stdio", + "command": "node", + "args": [mcp_fixture_script_path(), "stdio", "--tool-count=64"] + } + } + }); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::to_vec_pretty(&config).expect("serialize MCP aggregate limit config"), + ) + .expect("write MCP aggregate limit config"); + + let catalog = read_game_creator_mcp_catalog_at(&root) + .await + .expect("optional aggregate overflow must stay in server status"); + assert_eq!(catalog.tools.len(), 128); + let optional = catalog + .servers + .iter() + .find(|server| server.server_id == "optional-gamma") + .expect("optional aggregate overflow status"); + assert!(!optional.connected); + assert_eq!(optional.tool_count, 0); + assert!(optional + .error + .as_deref() + .is_some_and(|error| error.contains("工具总数") && error.contains("超过上限"))); + + shutdown_game_creator_mcp_clients_for_tests().await; + drop(config_guard); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn mcp_catalog_refreshes_independent_servers_in_parallel() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs b/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs index aaa811adf..0dd384e63 100644 --- a/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs +++ b/apps/ai-game-creator-shell/src-tauri/test-fixtures/mcp-server.mjs @@ -6,6 +6,13 @@ const args = process.argv.slice(2); const mode = args[0] ?? 'stdio'; const failList = args.includes('--fail-list'); const includeUnannotated = args.includes('--include-unannotated'); +const duplicateTool = args.includes('--duplicate-tool'); +const oversizedInputSchema = args.includes('--oversized-input-schema'); +const requireTaskMode = args.includes('--require-task-mode'); +const toolCountArgument = args.find((value) => value.startsWith('--tool-count=')); +const toolCount = toolCountArgument + ? Number(toolCountArgument.slice('--tool-count='.length)) + : null; const listDelayArgument = args.find((value) => value.startsWith('--list-delay-ms='), ); @@ -89,6 +96,28 @@ const tools = [ }, ]; +if (duplicateTool) { + tools.push({ ...tools[0] }); +} + +if (oversizedInputSchema) { + tools[0].inputSchema.properties.query.description = 'x'.repeat(70 * 1024); +} + +if (requireTaskMode) { + tools[0].execution = { taskSupport: 'required' }; +} + +if (Number.isInteger(toolCount) && toolCount > tools.length) { + for (let index = tools.length; index < toolCount; index += 1) { + tools.push({ + ...tools[0], + name: `lookup-${index}`, + title: `Lookup ${index}`, + }); + } +} + if (includeUnannotated) { tools.push({ name: 'mutate-unannotated', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d689dbe0a..a09a0a9a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -190,6 +190,13 @@ - 验证方式:以当前 run 成功 `preview.validate` revision N 后断言 iframe 自动出现且 server 归 Tauri registry;再完成 revision N+1,断言 server 进程和 loopback origin 不变、iframe 重新加载新内容且所有响应为 `no-store`。Runner registry 单独 running 不得让页面显示预览;相同 / 更低 revision 不得刷新;停止预览后顶部必须显示“预览未启动”;构建产物和安装信息必须为 `0.1.1`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 +## 2026-08-03 开放 Issue 115、118、127、128 的修复边界 + +- AGC 的 MCP 目录以 server 为隔离单元:可选 server 的连接、tools/list、工具归一化或聚合容量失败只关闭该 server,required server 仍失败关闭;MCP schema 包入原生 action 后只沿 subschema 关键词重定位当前 document 根的 JSON Pointer fragment,`default / const / examples / enum` 等数据值、命名 anchor 与 `$id` resource 内 fragment 保持不变。Anthropic strict 不由 `apiKind` 单独推断:AGC 只对官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成专用传输 schema,剔除不受支持的约束但不修改调用方原 schema;未知关键词、不可解析 / 递归 `$ref` 或请求复杂度超限时保持 non-strict。最后一个工具设置 ephemeral prompt-cache breakpoint;usage 统计把 cache creation / read token 一并计入 prompt 和 total。 +- Windows 私有 ACL 检查复用 `Get-Item` 对象的 `GetAccessControl()`,避免从 PowerShell 7 启动时继承的模块路径让 Windows PowerShell 5.1 的 `Get-Acl` 加载不兼容模块;静态配置门禁禁止重新引入该命令。 +- 编辑器持久化的 `prompt` 统一表示规范化用户意图;provider `actual_prompt` 只保留在 resource / asset 审计字段,系统 prompt 不进入跨资源检索字段。角色和图标的透明图、切片继承源用户 prompt;本次只修新写入,不迁移历史记录,不修改 SpacetimeDB schema。 +- 画布收到生成完成等较新权威快照时,必须把同项目待保存或在途的本地布局重放到新 revision:后端资源与生成终态优先,本地布局编辑优先;后端新增项合入,后端删除项和用户本地删除项均不得复活,合并后立即进入既有串行 CAS 保存队列。 + ## 2026-07-30 Provider 503 等待与耗尽状态使用严格字段派生的安全摘要 - 背景:game-chat 的进度卡只显示“等待 Provider upstream-5xx 瞬态故障退避到期”,没有 HTTP 状态、重试次数或等待时间;重试耗尽后,Runtime 和持久 conversation 又可能直接展示 `fingerprint/chars` 或 ` [redacted sensitive context]`,用户既无法判断是否在恢复,也看不到可操作的失败原因。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a372e3770..514c71889 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4029,3 +4029,20 @@ - 处理:凡 api-server 通过 `include_str!` 使用仓库根目录资源,都要在 `deploy/container/api-server.Dockerfile` 的 builder 阶段显式复制对应权威目录;不要再复制一份内容到 crate 内形成平行事实源。 - 验证:除本地 Cargo 测试外,检查 Dockerfile 构建上下文覆盖所有 `include_str!` 相对路径;新增或移动嵌入资源时同步更新容器 COPY 和接入文档。 - 关联:`deploy/container/api-server.Dockerfile`、`server-rs/crates/api-server/src/external_mcp.rs`、`server-rs/crates/api-server/src/external_skill_api.rs`、`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 权威画布快照不能清掉本地待保存或在途布局(2026-08-03) + +- 现象:用户拖动、缩放、改层序、背景色或 viewport 后,生成完成回包立即覆盖画布;450ms 防抖尚未触发或布局保存仍在途时,编辑静默丢失,undo 也可能被生成保护项阻断。 +- 原因:服务端 revision 只能排序已提交事实,本地未落库布局没有 revision;直接清空 pending save 并整体应用权威快照等同于把“服务端更新更晚”误判成“服务端知道本地编辑”。 +- 处理:保留同项目最新本地 dirty snapshot,权威回包先更新资源和生成终态,再按稳定 item ID 合并本地布局字段并基于新 revision 保存。旧权威项在新快照缺失表示后端删除,不能从 pending 或在途旧输入复活;新权威项必须合入,本地删除的旧项不能从权威回包复活。 +- 验证:分别覆盖防抖 pending、真实在途成功与 409、后端新增、后端删除、本地删除、viewport、背景色和生成面板完成态。 + +## Provider schema 能力不能从统一工具标记直接推断(2026-08-03) + +- 现象:把 OpenAI 风格 `strict` 原样透传给完整 Anthropic 工具目录,单个 schema 不支持的约束或全请求工具 / optional / union 上限会让整次 planning 返回 400。 +- 处理:能力不能从 `apiKind=anthropic` 推断;只对已验证 endpoint/model 显式开启,AGC 当前仅自动识别官方 HTTPS endpoint 与 Claude 4.5+ 版本化 model id,旧模型、未知别名和第三方兼容网关默认关闭。协议适配层用官方支持关键词白名单生成 Anthropic 专用传输 schema,对已知不支持约束仅从传输副本剔除,未知关键词、不可解析 / 递归 `$ref` 和复杂度超限均失败关闭为 non-strict,不删工具或修改调用方原 schema。真实 live 样例应包含 `$defs/$ref` 嵌套 schema,并使用官方 Anthropic endpoint,第三方兼容网关不能替代官方能力证据。 + +## 可选 MCP server 的坏目录不能拖垮全部工具(2026-08-03) + +- 现象:可选 server 已成功连接,但返回超限 schema、重复 tool identity 或要求未支持 task-mode 时,整个 MCP catalog 和本轮 Agent planning 一起失败。 +- 处理:连接、tools/list、工具归一化与聚合容量都使用同一 required / optional 边界。optional 将该 server 投影为 `connected=false + error + tool_count=0`,required 保持失败关闭;被包入 `action.input` 的 `$ref` 只重定位当前 document 根的 `#` / `#/...` JSON Pointer,命名 anchor、外部 URI 与带 `$id` 的 schema resource 内 fragment 不得改写。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index ef0e0f8ff..fa82c0075 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -61,7 +61,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本,仍必须以后续显式读取或生成完成返回的后端快照作为项目真相。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本。生成完成或显式读取返回较新权威快照时,若同项目仍有防抖待保存或在途保存的本地布局,前端必须以新快照的资源和生成终态为权威,只重放本地几何、层序、分组、隐藏、锁定、翻转、viewport、背景色和生成面板编辑,并立即基于新 revision 入队保存;后端新增项必须合入,后端已删除的旧项不得被本地旧快照复活,本地在请求期间删除的旧项也不得复活。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 7001cbd70..1fa52c0f1 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -248,7 +248,7 @@ Agent Runtime 负责: - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`:planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort`,`agentLlm..reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high`,`default` 表示不向 Provider 发送推理档位。 - 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。 -- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`,无 `strict`)与对象形态 `tool_choice`(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。 +- 2026-07-12 补充,2026-07-15 由 V1.17 更新,2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新,2026-08-03 收紧 strict 边界:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`(schema 字段名为 `input_schema`)。`strict` 能力不从 `apiKind` 推断:AGC 只对无凭据 / 自定义端口 / 路径的官方 HTTPS endpoint 和 Claude 4.5+ 版本化 model id 显式开启,旧模型、未知别名和兼容网关默认关闭。开启后使用官方支持关键词白名单生成 Anthropic 专用传输 schema,已知不支持约束只从传输副本剔除,调用方原 schema 保持不变;未知关键词、不可解析 / 递归 `$ref` 和 strict 工具 / optional / union 请求级复杂度超限时该工具保持 non-strict,不能因完整 AGC 工具集超限让整次请求被上游拒绝。工具数组最后一项携带 `cache_control: {"type":"ephemeral"}` 作为 prompt cache breakpoint;非流式和流式 usage 都将 `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` 合并为 prompt tokens。`tool_choice` 使用对象形态(`Auto → {"type":"auto"}`、`Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`。planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 仍在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools;协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`。 - 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step` 与 `action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。 - 2026-07-15 V1.17 公共审计收紧:`thinking_summary` event 只保存固定摘要、正文 SHA-256 与字符数,legacy `plan` event 只保存步骤数;结构化计划审计只保存 explanation 的哈希与字符数,以及 step 标题哈希、状态和数量。模型 thinking、legacy plan 标题、repair 错误和调用体只允许出现在对应私有 Runtime 上下文或有界 repair 请求中,不得复制到公共 event、task 或 Agent DB 正文字段。 - 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。 @@ -717,7 +717,7 @@ game-project/ - 2026-07-15 当前正式 `openai_chat / gpt-5.5` 路由的三轮真实联网专项均 FAIL:上游接受搜索开启请求并完成 lifecycle,但模型没有获得原生搜索能力,无法命中动态 GitHub release baseline。客户端能力已落地但该路由不可启用;最终复验使用正式 AppData 同级的 `0600` 私有配置副本,源配置 inode/nlink/timestamps/hash 前后完全一致,隔离 Runner/AppData/项目和全部泄漏门禁均安全收束。 - 2026-07-15 起,同一 Runtime 文档的“V1.21 单 Agent token-aware 持久上下文压缩”作为长会话预算事实源。全局/per-Agent LLM 配置提供 context window、自动压缩阈值和工具输出 token 限额;后台 planning 超阈值时只压缩旧 conversation/observation prefix,Goal、任务、计划、steer、pending、verification 与副作用身份逐字段保留。开发 Agent 窗口与 `agc:chat` 提供同一安全 `/compact`,正式用户首页不新增控制项。私有 sidecar、context bundle 绑定、Provider orphan barrier、公共零正文和 30 轮真实长链路按 Runtime V1.1 的 V1.21 章节验收。 - 2026-07-15 V1.21 已落地并完成真实验收:`context-compaction` suite 在正式 `openai_chat / gpt-5.5` 路由上完成 30/30 轮、两次压缩 revision、一次 Runner pidfd 强杀恢复和早期显式约束召回;最大估算输入 29134/64000,32 组 Provider lifecycle 唯一闭合,重复 assistant/audit、工具重放以及公共正文、summary、API Key、诱饵、项目路径和正式配置路径泄漏均为 0。首轮第 22 轮 Provider transport 失败按单次请求终态停止且零重放,新 disposable 项目完整重跑后 PASS。 -- 2026-07-15 起,同一 Runtime 文档的“V1.22 Runner-owned MCP 动态工具”作为外部工具扩展事实源。AppData 配置管理 STDIO / Streamable HTTP server、Bearer/static header、工具 allow/deny 与 `auto / confirm / writes / deny` 审批;独立 Runner 持有连接并把过滤后的真实 tool schema 和 server instructions 送入 planning。模型通过现有 `submit_agent_tool_plan` 请求 `mcp.call`,调用继续复用 durable pending action、确认、steer、Goal、reconciliation 和 V1.21 token 预算;完整结果只落私有 sidecar,正式用户首页不新增 MCP 调试配置。本切片不宣称 OAuth、resources/prompts、sampling、elicitation 或 MCP task-mode 已实现。 +- 2026-07-15 起,同一 Runtime 文档的“V1.22 Runner-owned MCP 动态工具”作为外部工具扩展事实源。AppData 配置管理 STDIO / Streamable HTTP server、Bearer/static header、工具 allow/deny 与 `auto / confirm / writes / deny` 审批;独立 Runner 持有连接并把过滤后的真实 tool schema 和 server instructions 送入 planning。模型通过现有 `submit_agent_tool_plan` 请求 `mcp.call`,调用继续复用 durable pending action、确认、steer、Goal、reconciliation 和 V1.21 token 预算;完整结果只落私有 sidecar,正式用户首页不新增 MCP 调试配置。可选 server 的 tools/list、schema 归一化、重复 tool identity、单 server 或聚合目录容量、未支持 task-mode 错误只隔离该 server,目录状态记录 `connected=false + error` 且不暴露其工具;required server 对相同错误继续失败关闭。MCP input schema 包入原生 action 的 `input` 属性时,只把当前 schema document 根的 `#` 与 `#/...` JSON Pointer 重定位到 `#/properties/input...`;命名 anchor、外部引用和带 `$id` 的独立 schema resource 内 fragment 保持不变。本切片不宣称 OAuth、resources/prompts、sampling、elicitation 或 MCP task-mode 已实现。 - 2026-07-15 V1.22 已落地并完成真实验收:开发配置窗可管理 server、敏感凭据、工具过滤和审批并通过 Runner 查看有界目录,`agc:chat` / `agc:swarm` 可用 `/mcp` 查询状态。正式 `openai_chat / gpt-5.5` 路由真实调用 STDIO/Streamable HTTP lookup 和确认后的 mutate,正常 run 的 action/sidecar/receipt 各 3 且最终 assistant 唯一;第二 run 在 HTTP mutate 副作用后强杀 Runner,只进入 1 次 reconciliation,调用、sidecar、receipt 和 assistant 均未重放。公共 arguments、结果正文、instructions、凭据和项目/配置路径泄漏为 0,一次性现场已清理。 - 2026-07-16 起,同一 Runtime 文档的“V1.23 单 Agent 持久用户输入请求”作为 Needs input 事实源。Agent 可在计划未完成时通过 `user.input_request` 提出 1-3 个结构化问题,Runtime 保持同一 run 并暂停;Project Supervisor、开发 Agent 窗口和 `agc:chat` 从私有 sidecar 展示并提交答案。普通 steer、工具确认和最终回复不再承担问题回答语义,问题/答案正文不进入公共审计。 - 2026-07-16 V1.23 已完成真实验收:正式 `openai_chat / gpt-5.5` 路由在 Project Supervisor 上产生 1 个含 2 选项的 Needs input,等待期 Runner pidfd 强杀恢复未增加 Provider 请求,回答后同 Session/run 完成唯一最终回复。问题/回答各一条,重复消息、公共正文、密钥、路径和报告泄漏均为 0,隔离现场已清理。 @@ -802,7 +802,7 @@ game-project/ - Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 - 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 - 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 -- 2026-07-27 Windows DACL 启动回归修正:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析失败退出。 +- 2026-07-27 Windows DACL 启动回归修正,2026-08-03 补充 pwsh 模块隔离:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记,并复用 `Get-Item` 返回的 `FileSystemInfo.GetAccessControl()` 读取 ACL,不调用会因父 PowerShell 7 `PSModulePath` 污染而自动加载不兼容模块的 `Get-Acl`;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析或模块加载失败退出。 - 2026-07-31 macOS 临时路径回归修正:配置目的地安全检查返回解析过现存父目录的真实路径,回归 fixture 的期望值也必须先使用平台原生 `realpath` 规范化临时根目录。macOS 下 `/var/folders/...` 与 `/private/var/folders/...` 是同一目录身份,不得用未规范化字符串阻断 `agc:typecheck`。CI 还必须使用“真实目录 + 符号链接父目录 + 不存在叶目录”确定性复现该语义;Windows 使用 junction 覆盖驱动器号、大小写与链接路径差异。fixture 的规范化与断言必须位于同一 `try/finally` 清理边界内。 - 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。 - 2026-07-28 Windows `tool-plan` 成功响应交接修正:相对目录句柄下安装 handoff 账本改用 `NtSetInformationFile(FileRenameInformation)`;`SetFileInformationByHandle(FileRenameInfo)` 不接受当前实现所需的非空 `RootDirectory`,会稳定返回 `ERROR_INVALID_PARAMETER (87)` 并让总控首轮进入 `needs-reconciliation`。实现继续绑定已验证的父目录句柄和相对 hash 文件名,不退化为绝对路径 rename;“按句柄安装”归入 `tool-plan-storage`。总控对 reconciliation 提供“已核对,结束旧任务”,取消后有 pending task 时只等待 Runner 续跑,队列为空时才允许显式 retry;自主构建 Supervisor 的 retry source 从原 Run Profile 绑定恢复并重新验证为可信 GUI / CLI 根入口,不降级成普通后台任务来源。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 61ed7e2aa..440bd9b5d 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -635,7 +635,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、真实操作 `task_id`、可选后台归组 `group_task_id`、拆分批次预期数量 `group_task_expected_asset_count`、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。`prompt` 固定表示规范化后的用户原始意图,供跨资源搜索和用户侧元数据使用;provider 实际返回的改写只写 `actual_prompt`,提交给 provider 的系统 / 工程化 prompt 不得写入 `prompt`。角色透明图、图标透明图和自动切片等派生产物继承源用户 prompt,并用 `source_resource_id`、`generation_inputs_json`、provider / asset kind 表达处理来源。归组字段追加在表尾并默认 `None`,只用于稳定派生任务的后台分组,不替代 `task_id`;批次是否初始完整以独立完成事实为准,不按当前剩余素材数反推。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_asset_group_source_provenance` diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 1bbc80437..7a4d13361 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1976,7 +1976,7 @@ pub(crate) async fn generate_editor_image_for_owner( caller.owner_user_id.as_str(), generated.task_id.as_str(), image, - submitted_prompt.as_str(), + role_setting.as_str(), generated.actual_prompt.as_deref(), storage_profile.asset_kind, "character-images", @@ -2158,7 +2158,7 @@ pub(crate) async fn generate_editor_image_for_owner( ); } image = restored_removal_image; - output_prompt = "去除纯色背景".to_string(); + output_prompt = role_setting.clone(); output_actual_prompt = None; output_provider = removal_provider.to_string(); output_generation_inputs = apply_editor_matting_metadata_to_generation_inputs( @@ -4926,6 +4926,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "图标素材参考图", )?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; + let user_prompt = icon_descriptions.join("\n"); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), true); // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 @@ -5002,7 +5003,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( EditorScreenBackgroundDecisionInput { kind: EditorScreenBackgroundDecisionKind::IconSpritesheet, screen_color: requested_screen_color.clone(), - prompt: icon_descriptions.join("\n"), + prompt: user_prompt.clone(), icon_descriptions: icon_descriptions.clone(), reference_count, source_image_data_url: None, @@ -5081,7 +5082,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller.owner_user_id.as_str(), generated.task_id.as_str(), image, - prompt.as_str(), + user_prompt.as_str(), generated.actual_prompt.as_deref(), EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", @@ -5100,7 +5101,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), width: source_width, height: source_height, - prompt: prompt.clone(), + prompt: user_prompt.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), task_id: generated.task_id.clone(), @@ -5266,7 +5267,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( owner_user_id.as_str(), generated.task_id.as_str(), &image, - "去除纯色背景", + user_prompt.as_str(), None, EDITOR_ICON_SPRITESHEET_ASSET_KIND, "icon-spritesheets", @@ -5289,7 +5290,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), width: spritesheet_width, height: spritesheet_height, - prompt: "去除纯色背景".to_string(), + prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: removal_provider.to_string(), @@ -5332,7 +5333,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .map(|resource| resource.resource_id.clone()), task_id: generated.task_id.clone(), group_task_id: None, - prompt: "自动拆分图集".to_string(), + prompt: user_prompt.clone(), actual_prompt: None, model: generation_options.model.to_string(), provider: "Genarrative".to_string(), @@ -8495,7 +8496,7 @@ async fn persist_editor_generated_image_data( task_id: &str, image: GeneratedImageAssetDataUrl, prompt: &str, - actual_prompt: Option<&str>, + _actual_prompt: Option<&str>, asset_kind: &str, path_kind: &str, file_stem: &str, @@ -8565,7 +8566,9 @@ async fn persist_editor_generated_image_data( AssetObjectAccessPolicy::Private, head.content_type.or(Some(persisted_mime_type)), head.content_length, - Some(actual_prompt.unwrap_or(prompt).to_string()), + // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider + // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 + Some(prompt.to_string()), asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), @@ -11597,6 +11600,43 @@ mod tests { assert!(prompt.contains("角色设定:菜市场卖菜大妈")); } + #[test] + fn editor_generated_asset_persistence_keeps_user_prompt_separate_from_system_prompt() { + let source = include_str!("editor_project.rs"); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &[ + "image,\n role_setting.as_str(),\n generated.actual_prompt.as_deref(),", + "output_prompt = role_setting.clone();", + ], + ); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + &[ + "let user_prompt = icon_descriptions.join(\"\\n\");", + "image,\n user_prompt.as_str(),\n generated.actual_prompt.as_deref(),", + "prompt: user_prompt.clone(),", + "&image,\n user_prompt.as_str(),\n None,", + ], + ); + assert_function_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["Some(prompt.to_string())"], + ); + assert_function_not_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["actual_prompt.unwrap_or(prompt)"], + ); + } + #[test] fn editor_canvas_generation_completion_inserts_result_layer_and_keeps_composer_closed() { let layers = json!([ diff --git a/server-rs/crates/platform-llm/README.md b/server-rs/crates/platform-llm/README.md index e379a8588..353b55661 100644 --- a/server-rs/crates/platform-llm/README.md +++ b/server-rs/crates/platform-llm/README.md @@ -30,7 +30,7 @@ | --- | --- | --- | --- | --- | | `OpenAiChat` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `choices[0].message.tool_calls` | `delta.tool_calls[].index`;首片提供 id/name,后续拼接 arguments | | `OpenAiResponses` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `output[].type=function_call` | `output_index`;`output_item.added` 提供身份,`function_call_arguments.delta` 拼接,`.done` 覆盖完整参数 | -| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层和 `strict` | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数 | +| `Anthropic` | 顶层 `tools[]` 为 `name` / `description` / `input_schema`,没有 `function` 包装层;只有 endpoint/model 配置显式声明支持且 schema / 请求复杂度满足 Anthropic 当前边界时才发送 `strict: true`。strict 传输 schema 会剥离不支持的约束,调用方原 schema 保持不变;最后一项带 ephemeral cache breakpoint | `{ "type": "auto" }` / `{ "type": "any" }`;`Required` 映射为 `any` | `content[].type=tool_use`,`input` 序列化为 `arguments` | content block `index`;`content_block_start` 提供身份,`input_json_delta` 拼接参数;`message_start/message_delta` 合并 cache/input/output usage | Responses 如果只发送 `response.completed` 或 `response.incomplete`,解析器会从其中的 `response.output[]` 恢复 `function_call`;恢复时使用 output 数组下标作为 slot。`response.incomplete` 表示上游没有完成本轮生成:其中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,纯正文则保留为可用的降级结果。三种协议的并行工具调用只在平台层做 slot 聚合,不代表工具会在平台层并发执行。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 207614051..51857995b 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -59,6 +59,7 @@ pub struct LlmConfig { max_retries: u32, retry_backoff_ms: u64, official_fallback: bool, + anthropic_strict_tool_support: bool, } // 首版只冻结当前项目已稳定使用的 system/user/assistant 三种消息角色。 @@ -417,12 +418,22 @@ struct AnthropicInputMessage { content: String, } -// Anthropic 工具与 OpenAI 的差异:schema 字段名为 input_schema,且没有 function 包装层与 strict。 +// Anthropic 工具与 OpenAI 的差异:schema 字段名为 input_schema,且没有 function 包装层。 #[derive(Serialize)] struct AnthropicTool { name: String, description: String, input_schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + strict: Option, + #[serde(skip_serializing_if = "Option::is_none")] + cache_control: Option, +} + +#[derive(Serialize)] +struct AnthropicCacheControl { + #[serde(rename = "type")] + cache_type: &'static str, } // Anthropic 的 tool_choice 必须是对象,发送裸字符串会被上游拒绝。 @@ -611,9 +622,25 @@ struct AnthropicUsage { #[serde(default)] input_tokens: u64, #[serde(default)] + cache_creation_input_tokens: u64, + #[serde(default)] + cache_read_input_tokens: u64, + #[serde(default)] output_tokens: u64, } +fn map_anthropic_usage(usage: AnthropicUsage) -> LlmTokenUsage { + let prompt_tokens = usage + .input_tokens + .saturating_add(usage.cache_creation_input_tokens) + .saturating_add(usage.cache_read_input_tokens); + LlmTokenUsage { + prompt_tokens, + completion_tokens: usage.output_tokens, + total_tokens: prompt_tokens.saturating_add(usage.output_tokens), + } +} + struct OpenAiCompatibleSseParser { buffer: String, raw_text: String, @@ -994,6 +1021,7 @@ impl LlmConfig { max_retries, retry_backoff_ms, official_fallback: false, + anthropic_strict_tool_support: false, }) } @@ -1002,6 +1030,15 @@ impl LlmConfig { self } + /// 显式声明当前 Anthropic endpoint 与 model 组合支持 strict tool use。 + /// + /// 该能力不能由 `api_kind` 推断:旧 Claude 模型和 Anthropic-compatible + /// 网关未必接受 `strict: true`。因此默认关闭,仅允许已验证的配置启用。 + pub fn with_anthropic_strict_tool_support(mut self, supported: bool) -> Self { + self.anthropic_strict_tool_support = supported; + self + } + pub fn with_raw_log_dir(mut self, raw_log_dir: impl Into) -> Self { self.raw_log_dir = raw_log_dir.into(); self @@ -1055,6 +1092,10 @@ impl LlmConfig { self.official_fallback } + pub fn anthropic_strict_tool_support(&self) -> bool { + self.anthropic_strict_tool_support + } + pub fn chat_completions_url(&self) -> String { format!( "{}/{}", @@ -2107,7 +2148,23 @@ where } if let Some(event_usage) = event_usage { - accumulation.usage = Some(event_usage); + accumulation.usage = Some(match accumulation.usage.take() { + Some(previous) => { + let prompt_tokens = previous.prompt_tokens.max(event_usage.prompt_tokens); + let completion_tokens = previous + .completion_tokens + .max(event_usage.completion_tokens); + LlmTokenUsage { + prompt_tokens, + completion_tokens, + total_tokens: previous + .total_tokens + .max(event_usage.total_tokens) + .max(prompt_tokens.saturating_add(completion_tokens)), + } + } + None => event_usage, + }); } // 工具调用只累加,不进 on_delta:调用方的流式通道仍然只承载文本。 @@ -2242,6 +2299,7 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) request, fallback_model, stream, + config.anthropic_strict_tool_support(), )), } } @@ -2268,7 +2326,12 @@ fn build_anthropic_messages_request_body( request: &LlmRunRequest, fallback_model: &str, stream: bool, + strict_tool_support: bool, ) -> AnthropicMessagesRequestBody { + // capability 绑定 LlmConfig 的 endpoint/model 组合;请求级 model override 没有经过 + // 同一轮能力确认,即使协议仍是 Anthropic 也必须 fail closed。 + let strict_tool_support = + strict_tool_support && request.resolved_model(fallback_model) == fallback_model; let system = request .messages .iter() @@ -2290,13 +2353,26 @@ fn build_anthropic_messages_request_body( .collect(); let tools = (!request.function_tools.is_empty()).then(|| { + let strict_schemas = + anthropic_strict_transport_schemas(&request.function_tools, strict_tool_support); + let last_index = request.function_tools.len().saturating_sub(1); request .function_tools .iter() - .map(|function| AnthropicTool { - name: function.name.clone(), - description: function.description.clone(), - input_schema: function.parameters.clone(), + .enumerate() + .map(|(index, function)| { + let strict_schema = strict_schemas[index].as_ref(); + AnthropicTool { + name: function.name.clone(), + description: function.description.clone(), + input_schema: strict_schema + .cloned() + .unwrap_or_else(|| function.parameters.clone()), + strict: strict_schema.is_some().then_some(true), + cache_control: (index == last_index).then_some(AnthropicCacheControl { + cache_type: "ephemeral", + }), + } }) .collect() }); @@ -2316,6 +2392,388 @@ fn build_anthropic_messages_request_body( } } +#[derive(Default)] +struct AnthropicStrictSchemaComplexity { + optional_parameters: usize, + union_parameters: usize, +} + +// Anthropic 官方 SDK 同样会先把调用方 schema 转成服务端可编译的传输 schema,再用 +// 原 schema 做本地校验。这里绝不修改 LlmFunctionTool.parameters,只剥离 strict grammar +// 不支持的约束。未显式列入支持或可安全剥离集合的 keyword 一律拒绝 strict,避免新 keyword +// 穿透有限黑名单后把整轮请求变成 400。 +fn anthropic_strict_transport_schema(schema: &serde_json::Value) -> Option { + const BASIC_TYPES: &[&str] = &[ + "object", "array", "string", "integer", "number", "boolean", "null", + ]; + const SUPPORTED_FORMATS: &[&str] = &[ + "date-time", + "time", + "date", + "duration", + "email", + "hostname", + "uri", + "ipv4", + "ipv6", + "uuid", + ]; + + fn transform_schema_map( + object: &serde_json::Map, + ) -> Option> { + let mut transformed = serde_json::Map::new(); + for (keyword, value) in object { + match keyword.as_str() { + "type" => { + let supported = match value { + serde_json::Value::String(value) => { + BASIC_TYPES.contains(&value.as_str()) + } + serde_json::Value::Array(values) => { + !values.is_empty() + && values.iter().all(|value| { + value.as_str().is_some_and(|value| BASIC_TYPES.contains(&value)) + }) + && values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + .len() + == values.len() + } + _ => false, + }; + if !supported { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "properties" | "$defs" | "definitions" => { + let children = value.as_object()?; + let mut transformed_children = serde_json::Map::new(); + for (name, child) in children { + transformed_children + .insert(name.clone(), anthropic_strict_transport_schema(child)?); + } + transformed.insert( + keyword.clone(), + serde_json::Value::Object(transformed_children), + ); + } + "items" => { + transformed.insert( + keyword.clone(), + anthropic_strict_transport_schema(value)?, + ); + } + "anyOf" | "allOf" => { + let children = value.as_array().filter(|children| !children.is_empty())?; + let transformed_children = children + .iter() + .map(anthropic_strict_transport_schema) + .collect::>>()?; + // Anthropic 明确不支持 allOf 中的 $ref;该组合不能靠删除 $ref + // 降级,否则会丢失整个结构定义。 + if keyword == "allOf" + && transformed_children.iter().any(anthropic_schema_uses_ref) + { + return None; + } + transformed.insert( + keyword.clone(), + serde_json::Value::Array(transformed_children), + ); + } + "$ref" => { + if !value + .as_str() + .is_some_and(|reference| reference.starts_with("#/")) + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "required" => { + let values = value.as_array()?; + let names = values + .iter() + .map(serde_json::Value::as_str) + .collect::>>()?; + if names.iter().collect::>().len() + != names.len() + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "additionalProperties" => { + if value != &serde_json::Value::Bool(false) { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "enum" => { + let values = value.as_array().filter(|values| !values.is_empty())?; + if values.len() > 100 + || values + .iter() + .any(|value| value.is_array() || value.is_object()) + { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "const" => { + if value.is_array() || value.is_object() { + return None; + } + transformed.insert(keyword.clone(), value.clone()); + } + "title" | "description" => { + value.as_str()?; + transformed.insert(keyword.clone(), value.clone()); + } + "default" => { + // default 是普通 JSON 数据,不递归解释其中可能出现的 `$ref`。 + transformed.insert(keyword.clone(), value.clone()); + } + "format" => { + if value + .as_str() + .is_some_and(|format| SUPPORTED_FORMATS.contains(&format)) + { + transformed.insert(keyword.clone(), value.clone()); + } + } + "minItems" => { + if value.as_u64().is_some_and(|minimum| minimum <= 1) { + transformed.insert(keyword.clone(), value.clone()); + } + } + // 这些是 Anthropic strict grammar 不支持的约束,或(如 pattern)带有 + // 本适配器未完整校验的编译器子集。传输时剥离;调用方持有的原 schema + // 不变,仍可用于 ToolHost 入参校验。 + "minimum" + | "maximum" + | "exclusiveMinimum" + | "exclusiveMaximum" + | "multipleOf" + | "minLength" + | "maxLength" + | "maxItems" + | "uniqueItems" + | "contains" + | "minContains" + | "maxContains" + | "minProperties" + | "maxProperties" + | "pattern" + // Anthropic 未列这些 annotation 为传输 schema 支持项;安全删除不会 + // 改变结构,且不会递归误读其中的普通 JSON 数据。 + | "examples" + | "$comment" + | "deprecated" + | "readOnly" + | "writeOnly" => {} + // `$id`、`$anchor`、dependentRequired 等所有未声明 keyword 都在这里 + // fail closed,不能靠 apiKind 或“看起来像 JSON Schema”发送 strict。 + _ => return None, + } + } + Some(transformed) + } + + Some(serde_json::Value::Object(transform_schema_map( + schema.as_object()?, + )?)) +} + +fn anthropic_schema_uses_ref(schema: &serde_json::Value) -> bool { + let Some(object) = schema.as_object() else { + return false; + }; + if object.contains_key("$ref") { + return true; + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .any(anthropic_schema_uses_ref) + || ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object)) + .flat_map(|children| children.values()) + .any(anthropic_schema_uses_ref) + || ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array)) + .flat_map(|children| children.iter()) + .any(anthropic_schema_uses_ref) +} + +fn collect_anthropic_strict_schema_complexity( + schema: &serde_json::Value, + complexity: &mut AnthropicStrictSchemaComplexity, +) -> bool { + let Some(object) = schema.as_object() else { + return false; + }; + if object + .get("type") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| types.len() > 1) + || object.contains_key("anyOf") + { + complexity.union_parameters = complexity.union_parameters.saturating_add(1); + } + let is_object_schema = object.get("type").and_then(serde_json::Value::as_str) == Some("object") + || object + .get("type") + .and_then(serde_json::Value::as_array) + .is_some_and(|types| types.iter().any(|value| value.as_str() == Some("object"))) + || object.contains_key("properties"); + if is_object_schema + && object.get("additionalProperties") != Some(&serde_json::Value::Bool(false)) + { + return false; + } + if let Some(properties) = object + .get("properties") + .and_then(serde_json::Value::as_object) + { + let required = object + .get("required") + .and_then(serde_json::Value::as_array) + .map(|values| { + values + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + if required.len() + != object + .get("required") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .unwrap_or_default() + || required.iter().any(|name| !properties.contains_key(*name)) + { + return false; + } + complexity.optional_parameters = complexity.optional_parameters.saturating_add( + properties + .keys() + .filter(|name| !required.contains(name.as_str())) + .count(), + ); + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) + && ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_object)) + .flat_map(|children| children.values()) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) + && ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| object.get(keyword).and_then(serde_json::Value::as_array)) + .flat_map(|children| children.iter()) + .all(|child| collect_anthropic_strict_schema_complexity(child, complexity)) +} + +fn anthropic_strict_schema_refs_are_supported(schema: &serde_json::Value) -> bool { + fn visit( + value: &serde_json::Value, + root: &serde_json::Value, + active_refs: &mut std::collections::BTreeSet, + ) -> bool { + match value { + serde_json::Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(serde_json::Value::as_str) { + // Anthropic strict 不支持递归 schema;为避免把命名 anchor 或外部 + // resource 误判为可编译,只接受能在当前 document 内解析的 Pointer。 + if !reference.starts_with("#/") { + return false; + } + let Some(target) = root.pointer(reference.trim_start_matches('#')) else { + return false; + }; + if !active_refs.insert(reference.to_string()) { + return false; + } + let target_is_supported = visit(target, root, active_refs); + active_refs.remove(reference); + if !target_is_supported { + return false; + } + } + ["items"] + .into_iter() + .filter_map(|keyword| object.get(keyword)) + .all(|child| visit(child, root, active_refs)) + && ["properties", "$defs", "definitions"] + .into_iter() + .filter_map(|keyword| { + object.get(keyword).and_then(serde_json::Value::as_object) + }) + .flat_map(|children| children.values()) + .all(|child| visit(child, root, active_refs)) + && ["anyOf", "allOf"] + .into_iter() + .filter_map(|keyword| { + object.get(keyword).and_then(serde_json::Value::as_array) + }) + .flat_map(|children| children.iter()) + .all(|child| visit(child, root, active_refs)) + } + _ => false, + } + } + + visit(schema, schema, &mut std::collections::BTreeSet::new()) +} + +fn anthropic_strict_transport_schemas( + functions: &[LlmFunctionTool], + strict_tool_support: bool, +) -> Vec> { + const MAX_STRICT_TOOLS: usize = 20; + const MAX_OPTIONAL_PARAMETERS: usize = 24; + const MAX_UNION_PARAMETERS: usize = 16; + + let mut strict_count = 0usize; + let mut optional_parameters = 0usize; + let mut union_parameters = 0usize; + functions + .iter() + .map(|function| { + if !strict_tool_support || !function.strict || strict_count >= MAX_STRICT_TOOLS { + return None; + } + let transport_schema = anthropic_strict_transport_schema(&function.parameters)?; + let mut complexity = AnthropicStrictSchemaComplexity::default(); + if !anthropic_strict_schema_refs_are_supported(&transport_schema) + || !collect_anthropic_strict_schema_complexity(&transport_schema, &mut complexity) + || optional_parameters.saturating_add(complexity.optional_parameters) + > MAX_OPTIONAL_PARAMETERS + || union_parameters.saturating_add(complexity.union_parameters) + > MAX_UNION_PARAMETERS + { + return None; + } + strict_count = strict_count.saturating_add(1); + optional_parameters = + optional_parameters.saturating_add(complexity.optional_parameters); + union_parameters = union_parameters.saturating_add(complexity.union_parameters); + Some(transport_schema) + }) + .collect() +} + fn map_chat_completions_input_messages( messages: &[LlmMessage], ) -> Vec { @@ -2692,11 +3150,7 @@ fn parse_anthropic_response( text: content, finish_reason: parsed.stop_reason, response_id: parsed.id, - usage: parsed.usage.map(|usage| LlmTokenUsage { - prompt_tokens: usage.input_tokens, - completion_tokens: usage.output_tokens, - total_tokens: usage.input_tokens.saturating_add(usage.output_tokens), - }), + usage: parsed.usage.map(map_anthropic_usage), tool_calls, }) } @@ -3219,6 +3673,21 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .unwrap_or_default(); match event_type { + "message_start" => Ok(parsed + .get("message") + .and_then(|message| message.get("usage")) + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LlmError::Deserialize(format!( + "解析 LLM Anthropic message_start usage 失败:{error}" + )) + })? + .map(|usage| ParsedStreamEvent { + usage: Some(map_anthropic_usage(usage)), + ..Default::default() + })), // tool_use block 的 id 与 name 只在 content_block_start 出现;此时 input 恒为空对象, // 不能拿它初始化参数,否则会和后续 input_json_delta 拼出非法 JSON。 "content_block_start" => { @@ -3292,11 +3761,23 @@ fn parse_anthropic_sse_event(data: &str) -> Result, Ll .and_then(|value| value.get("stop_reason")) .and_then(serde_json::Value::as_str) .map(str::to_string); + let usage = parsed + .get("usage") + .cloned() + .map(serde_json::from_value::) + .transpose() + .map_err(|error| { + LlmError::Deserialize(format!( + "解析 LLM Anthropic message_delta usage 失败:{error}" + )) + })? + .map(map_anthropic_usage); Ok(Some(ParsedStreamEvent { is_completion: stop_reason .as_deref() .is_some_and(|reason| !reason.trim().is_empty()), finish_reason: stop_reason, + usage, ..Default::default() })) } @@ -3468,6 +3949,27 @@ mod tests { assert!(config.with_official_fallback(true).official_fallback()); } + #[test] + fn llm_config_anthropic_strict_tool_support_is_opt_in() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://api.anthropic.com".to_string(), + "secret".to_string(), + "claude-sonnet-4-5".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid"); + + assert!(!config.anthropic_strict_tool_support()); + assert!( + config + .with_anthropic_strict_tool_support(true) + .anthropic_strict_tool_support() + ); + } + #[test] fn run_request_defaults_to_openai_responses_api_kind() { let request = LlmRunRequest::single_turn("系统", "用户"); @@ -3507,7 +4009,12 @@ mod tests { LlmFunctionTool::new( "get_weather", "查询天气", - serde_json::json!({ "type": "object", "properties": { "city": { "type": "string" } } }), + serde_json::json!({ + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + }), ) .with_strict(true), ]) @@ -3520,13 +4027,240 @@ mod tests { assert_eq!(json["tools"][0]["name"], "get_weather"); assert_eq!(json["tools"][0]["description"], "查询天气"); assert_eq!(json["tools"][0]["input_schema"]["type"], "object"); - // Anthropic 没有 parameters / strict 字段,映射时必须丢弃。 + // apiKind 不能证明 endpoint/model 支持 strict,未显式声明 capability 时必须关闭。 assert!(json["tools"][0].get("parameters").is_none()); assert!(json["tools"][0].get("strict").is_none()); + assert_eq!( + json["tools"][0]["cache_control"], + serde_json::json!({"type": "ephemeral"}) + ); // tool_choice 必须是对象;Required 对应 Anthropic 的 any。 assert_eq!(json["tool_choice"], serde_json::json!({ "type": "any" })); } + #[test] + fn anthropic_strict_uses_transformed_schema_without_mutating_the_original() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://example.com/anthropic".to_string(), + "secret".to_string(), + "model-a".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid") + .with_anthropic_strict_tool_support(true); + let request = LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_function_tools(vec![ + LlmFunctionTool::new( + "bounded_text", + "包含 Anthropic strict 暂不支持的长度约束", + serde_json::json!({ + "type": "object", + "properties": { + "value": {"type": "string", "minLength": 1}, + "steps": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": {"type": "string", "maxLength": 240} + } + }, + "required": ["value", "steps"], + "additionalProperties": false + }), + ) + .with_strict(true), + LlmFunctionTool::new( + "plain_text", + "支持严格模式的简单 schema", + serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]); + + let json = serde_json::to_value(build_request_body(&request, &config, false)) + .expect("body should serialize"); + assert_eq!(json["tools"][0]["strict"], true); + assert!( + json["tools"][0]["input_schema"]["properties"]["value"] + .get("minLength") + .is_none() + ); + assert_eq!( + request.function_tools[0].parameters["properties"]["value"]["minLength"], + 1 + ); + assert_eq!( + json["tools"][0]["input_schema"]["properties"]["steps"]["minItems"], + 1 + ); + assert!( + json["tools"][0]["input_schema"]["properties"]["steps"] + .get("maxItems") + .is_none() + ); + assert_eq!( + request.function_tools[0].parameters["properties"]["steps"]["maxItems"], + 8 + ); + assert!(json["tools"][0].get("cache_control").is_none()); + assert_eq!(json["tools"][1]["strict"], true); + assert_eq!( + json["tools"][1]["cache_control"], + serde_json::json!({"type": "ephemeral"}) + ); + } + + #[test] + fn anthropic_request_model_override_does_not_reuse_config_scoped_strict_capability() { + let config = LlmConfig::new( + LlmProvider::OpenAiCompatible, + "https://api.anthropic.com".to_string(), + "secret".to_string(), + "claude-sonnet-4-5".to_string(), + DEFAULT_REQUEST_TIMEOUT_MS, + DEFAULT_MAX_RETRIES, + DEFAULT_RETRY_BACKOFF_MS, + ) + .expect("config should be valid") + .with_anthropic_strict_tool_support(true); + let request = LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_model("claude-3-5-sonnet-latest") + .with_function_tools(vec![ + LlmFunctionTool::new( + "plain_text", + "simple schema", + serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]); + + let json = serde_json::to_value(build_request_body(&request, &config, false)) + .expect("body should serialize"); + assert!(json["tools"][0].get("strict").is_none()); + } + + #[test] + fn anthropic_strict_rejects_unclosed_objects_recursive_or_missing_refs_and_complex_enums() { + let schemas = [ + serde_json::json!({"type": "object"}), + serde_json::json!({ + "type": "object", + "$defs": { + "Node": { + "type": "object", + "properties": {"next": {"$ref": "#/$defs/Node"}}, + "additionalProperties": false + } + }, + "properties": {"node": {"$ref": "#/$defs/Node"}}, + "required": ["node"], + "additionalProperties": false + }), + serde_json::json!({ + "type": "object", + "properties": {"value": {"$ref": "#/$defs/Missing"}}, + "required": ["value"], + "additionalProperties": false + }), + serde_json::json!({ + "type": "object", + "properties": {"value": {"enum": [{"nested": true}]}}, + "required": ["value"], + "additionalProperties": false + }), + ]; + for schema in schemas { + let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)]; + assert_eq!(anthropic_strict_transport_schemas(&tools, true), vec![None]); + } + + let valid_ref = LlmFunctionTool::new( + "valid_ref", + "valid ref", + serde_json::json!({ + "type": "object", + "$defs": {"Value": {"type": "string"}}, + "properties": {"value": {"$ref": "#/$defs/Value"}}, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true); + assert!(anthropic_strict_transport_schemas(&[valid_ref], true)[0].is_some()); + } + + #[test] + fn anthropic_strict_rejects_unknown_or_scope_changing_keywords() { + for (keyword, value) in [ + ("$id", serde_json::json!("nested.json")), + ("$anchor", serde_json::json!("node")), + ("dependentRequired", serde_json::json!({"value": ["other"]})), + ] { + let mut schema = serde_json::json!({ + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + "additionalProperties": false + }); + schema + .as_object_mut() + .expect("schema should be an object") + .insert(keyword.to_string(), value); + let tools = vec![LlmFunctionTool::new("unsafe", "unsafe", schema).with_strict(true)]; + assert_eq!( + anthropic_strict_transport_schemas(&tools, true), + vec![None], + "{keyword} must fail closed" + ); + } + } + + #[test] + fn anthropic_strict_does_not_interpret_refs_inside_default_data() { + let tool = LlmFunctionTool::new( + "default_payload", + "default payload", + serde_json::json!({ + "type": "object", + "properties": { + "value": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {"$ref": "#/literal-data"} + } + }, + "required": ["value"], + "additionalProperties": false + }), + ) + .with_strict(true); + + let transformed = anthropic_strict_transport_schemas(&[tool], true)[0] + .clone() + .expect("data-valued ref must not disable strict"); + assert_eq!( + transformed["properties"]["value"]["default"]["$ref"], + "#/literal-data" + ); + } + #[test] fn anthropic_request_body_omits_tool_fields_without_tools() { let config = LlmConfig::new( @@ -4937,7 +5671,7 @@ mod tests { MockResponse { status_line: "200 OK", content_type: "application/json; charset=utf-8", - body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"output_tokens":3}}"#.to_string(), + body: r#"{"id":"msg_01","model":"claude-test","content":[{"type":"text","text":"Anthropic 成功"}],"stop_reason":"end_turn","usage":{"input_tokens":5,"cache_creation_input_tokens":4,"cache_read_input_tokens":3,"output_tokens":3}}"#.to_string(), extra_headers: Vec::new(), }, ); @@ -4966,9 +5700,9 @@ mod tests { assert_eq!( response.usage, Some(LlmTokenUsage { - prompt_tokens: 5, + prompt_tokens: 12, completion_tokens: 3, - total_tokens: 8, + total_tokens: 15, }) ); assert_eq!(request_json["model"], serde_json::json!("test-model")); @@ -4985,9 +5719,10 @@ mod tests { status_line: "200 OK", content_type: "text/event-stream; charset=utf-8", body: concat!( + "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":5,\"cache_creation_input_tokens\":4,\"cache_read_input_tokens\":3,\"output_tokens\":0}}}\n\n", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"你\"}}\n\n", "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"好\"}}\n\n", - "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"}}\n\n", + "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":3}}\n\n", "data: {\"type\":\"message_stop\"}\n\n" ) .to_string(), @@ -5013,6 +5748,14 @@ mod tests { response.response_id.as_deref(), Some("req_anthropic_stream_01") ); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 12, + completion_tokens: 3, + total_tokens: 15, + }) + ); } // 以下三个流式工具用例使用取自真实端点的 checked-in SSE fixture:Anthropic 与 diff --git a/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs b/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs index d40ee8def..fe35e7891 100644 --- a/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs +++ b/server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs @@ -3,10 +3,10 @@ //! 仓库根目录没有 Cargo.toml,必须显式指定 workspace manifest: //! //! ```powershell -//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.minimaxi.com/anthropic' +//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.anthropic.com' //! $env:PLATFORM_LLM_LIVE_API_KEY = '...' -//! $env:PLATFORM_LLM_LIVE_MODEL = 'MiniMax-M3' -//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' # 或 openai_chat / openai_responses +//! $env:PLATFORM_LLM_LIVE_MODEL = '<当前支持 strict tool use 的 Claude 模型>' +//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' //! cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture //! ``` //! @@ -107,7 +107,8 @@ async fn live_stream_run_returns_native_tool_calls() { 0, 1_000, ) - .expect("live config should be valid"); + .expect("live config should be valid") + .with_anthropic_strict_tool_support(api_kind == LlmApiKind::Anthropic); let client = LlmClient::new(config).expect("live client should be created"); let request = LlmRunRequest::new(vec![ @@ -116,15 +117,27 @@ async fn live_stream_run_returns_native_tool_calls() { ]) .with_api_kind(api_kind) .with_max_output_tokens(512) - .with_function_tools(vec![LlmFunctionTool::new( - "get_weather", - "查询指定城市的当前天气。", - serde_json::json!({ - "type": "object", - "properties": { "city": { "type": "string" } }, - "required": ["city"] - }), - )]) + .with_function_tools(vec![ + LlmFunctionTool::new( + "get_weather", + "查询指定城市的当前天气。", + serde_json::json!({ + "type": "object", + "$defs": { + "WeatherRequest": { + "type": "object", + "properties": { "city": { "type": "string" } }, + "required": ["city"], + "additionalProperties": false + } + }, + "properties": { "request": { "$ref": "#/$defs/WeatherRequest" } }, + "required": ["request"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]) .with_tool_choice(LlmToolChoice::Required); let mut streamed_chars = 0usize; @@ -157,7 +170,10 @@ async fn live_stream_run_returns_native_tool_calls() { let arguments: serde_json::Value = serde_json::from_str(&call.arguments).expect("参数必须是完整 JSON"); assert!( - arguments.get("city").is_some(), - "参数应包含 city,实际为 {arguments}" + arguments + .get("request") + .and_then(|request| request.get("city")) + .is_some(), + "参数应包含 request.city,实际为 {arguments}" ); } diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 03f7d4439..75d7a4036 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -5,7 +5,10 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; -import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; +import type { + EditorProjectLayerSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { DEFAULT_CANVAS_BACKGROUND_COLOR, normalizeCanvasBackgroundHex, @@ -15,7 +18,10 @@ import type { CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; -import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; +import { + mergeAuthoritativeCanvasLayoutWithPendingLocalLayout, + useImageCanvasProjectPersistence, +} from './useImageCanvasProjectPersistence'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn()); @@ -84,6 +90,245 @@ function createDeferred() { return { promise, resolve, reject }; } +function createCompletedEditorProjectSnapshot( + revision = 1, +): EditorProjectSnapshot { + return { + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:01.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-generated', + resourceId: 'resource-generated', + title: '生成结果', + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 420, + originalHeight: 420, + zIndex: 1, + sourceType: 'generated', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '后端完成生成器', + status: 'idle', + composerOpen: true, + generatedLayerId: 'layer-generated', + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 420, + originalHeight: 420, + }, + }, + }, + ], + resources: [ + { + resourceId: 'resource-generated', + projectId: 'editor-project-default', + imageSrc: '/generated/result.png', + objectKey: 'generated/result.png', + assetObjectId: 'asset-object-result', + width: 420, + height: 420, + sourceType: 'generated', + }, + ], + updatedAt: '2026-06-12T00:00:01.000Z', + }; +} + +it('merges pending geometry and dialog edits while retaining backend additions and deletions', () => { + const authoritativeItems: EditorProjectLayerSnapshot[] = [ + { + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#FFFFFF', + }, + { + layerId: 'layer-existing', + resourceId: 'resource-existing-v2', + title: '后端标题', + x: 10, + y: 20, + zIndex: 1, + sourceType: 'generated', + objectKey: 'generated/new.png', + }, + { + layerId: 'layer-deleted-locally', + resourceId: 'resource-deleted-locally', + title: '本地已删除', + x: 0, + y: 0, + zIndex: 2, + sourceType: 'generated', + }, + { + layerId: 'layer-generated-by-backend', + resourceId: 'resource-generated-by-backend', + title: '后端新增结果', + x: 40, + y: 50, + zIndex: 3, + sourceType: 'generated', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:dialog-1', + resourceId: 'generation-dialog:dialog-1', + dialog: { + id: 'dialog-1', + mode: 'generate', + prompt: '后端原提示词', + status: 'idle', + composerOpen: false, + generatedLayerId: 'layer-generated-by-backend', + placeholder: { + x: 40, + y: 50, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }, + ]; + const pendingItems: EditorProjectLayerSnapshot[] = [ + { + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#AABBCC', + }, + { + layerId: 'layer-existing', + resourceId: 'resource-existing-v1', + title: '本地标题', + x: 88, + y: 99, + zIndex: 7, + sourceType: 'generated', + objectKey: 'generated/old.png', + }, + { + layerId: 'layer-deleted-by-backend', + resourceId: 'resource-deleted-by-backend', + title: '后端已删除', + x: 1, + y: 2, + zIndex: 8, + sourceType: 'generated', + }, + { + layerId: 'layer-added-locally', + resourceId: 'resource-added-locally', + title: '本地新增', + x: 3, + y: 4, + zIndex: 9, + sourceType: 'generated', + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:dialog-1', + resourceId: 'generation-dialog:dialog-1', + dialog: { + id: 'dialog-1', + mode: 'generate', + prompt: '请求在途期间的新提示词', + status: 'generating', + composerOpen: true, + placeholder: { + x: 88, + y: 99, + width: 320, + height: 320, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }, + ]; + + const merged = mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({ + authoritativeItems, + pendingItems, + previousAuthoritativeItemIds: new Set([ + 'canvas-settings:default', + 'layer-existing', + 'layer-deleted-locally', + 'layer-deleted-by-backend', + 'generation-dialog:dialog-1', + ]), + }); + + expect(merged).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'canvas-settings:default', + canvasBackgroundColor: '#AABBCC', + }), + expect.objectContaining({ + layerId: 'layer-existing', + resourceId: 'resource-existing-v2', + title: '本地标题', + x: 88, + y: 99, + zIndex: 7, + objectKey: 'generated/new.png', + }), + expect.objectContaining({ + layerId: 'layer-generated-by-backend', + resourceId: 'resource-generated-by-backend', + }), + expect.objectContaining({ + layerId: 'layer-added-locally', + resourceId: 'resource-added-locally', + }), + expect.objectContaining({ + layerId: 'generation-dialog:dialog-1', + dialog: expect.objectContaining({ + prompt: '请求在途期间的新提示词', + status: 'idle', + composerOpen: true, + generatedLayerId: 'layer-generated-by-backend', + placeholder: expect.objectContaining({ x: 88, y: 99 }), + }), + }), + ]), + ); + expect(merged.some((item) => item.layerId === 'layer-deleted-locally')).toBe( + false, + ); + expect( + merged.some((item) => item.layerId === 'layer-deleted-by-backend'), + ).toBe(false); +}); + function ProjectPersistenceHarness({ canAccessProtectedData = true, currentUserId = 'user-test', @@ -330,6 +575,16 @@ function ProjectPersistenceHarness({ > append generated +