diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 7102839ca..59f16c386 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -9,6 +9,9 @@ default = [] game-chat-release = [] [build-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri-build = { version = "2.6.2", features = [] } [dependencies] diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index d860e1e6a..2b9088e97 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,3 +1,68 @@ +#[path = "build_support/runtime_prompt_bundle.rs"] +mod runtime_prompt_bundle; + +use std::collections::BTreeSet; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn seed_task_group_id( + group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, +) -> &'static str { + use shared_contracts::game_creation_app::GameCreationAppAgentGroup; + match group { + GameCreationAppAgentGroup::Design => "design", + GameCreationAppAgentGroup::Art => "art", + GameCreationAppAgentGroup::Code => "code", + GameCreationAppAgentGroup::Balance => "balance", + GameCreationAppAgentGroup::Audio => "audio", + GameCreationAppAgentGroup::Publishing => "publishing", + } +} + +fn validate_seed_task_catalog(compiled: &runtime_prompt_bundle::CompiledPromptBundle) { + let actual = compiled + .specialist_nodes + .iter() + .map(|node| { + ( + node.task_id.clone(), + node.group_id.clone(), + node.role.clone(), + ) + }) + .collect::>(); + let expected = shared_contracts::game_creation_app::new_game_creation_app_seed_tasks() + .into_iter() + .map(|task| { + ( + task.id, + seed_task_group_id(&task.group).to_string(), + task.role, + ) + }) + .collect::>(); + if actual != expected { + panic!( + "Prompt Bundle agentCatalog 与正式 seed DAG 的 taskId/group/role 不一致\nactual={actual:#?}\nexpected={expected:#?}" + ); + } +} + fn main() { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), + ); + let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); + let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) + .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); + validate_seed_task_catalog(&compiled); + for dependency in &compiled.dependencies { + println!("cargo:rerun-if-changed={}", dependency.display()); + } + let output_path = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR must be available")) + .join("agent_runtime_prompt_bundle.rs"); + fs::write(&output_path, compiled.rust_source) + .unwrap_or_else(|error| panic!("写入 Prompt Bundle 生成代码失败:{error}")); tauri_build::build() } diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs new file mode 100644 index 000000000..4f4c2c2df --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/runtime_prompt_bundle.rs @@ -0,0 +1,843 @@ +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +#[derive(Debug)] +pub struct CompiledPromptBundle { + pub rust_source: String, + pub dependencies: Vec, + pub specialist_nodes: Vec, +} + +#[derive(Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct CompiledAgentNode { + pub task_id: String, + pub group_id: String, + pub role: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PromptBundleManifest { + schema_version: u32, + id: String, + version: String, + #[serde(deserialize_with = "deserialize_unique_string_map")] + sections: BTreeMap, + compositions: PromptCompositions, + variants: PromptVariants, + #[serde(default)] + role_overlays: Vec, + agent_catalog: AgentCatalog, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PromptCompositions { + runtime: Vec, + supervisor: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PromptVariants { + platform: PlatformVariants, + visual_contract: VisualContractVariants, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlatformVariants { + default: String, + linux: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct VisualContractVariants { + editor_configured: String, + editor_unavailable: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct RoleOverlay { + agent_id: String, + root_source: Option, + sections: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgentCatalog { + supervisor: AgentGroup, + groups: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgentGroup { + id: String, + label: String, + role: String, + brief_path_name: String, + roles: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgentRole { + id: String, + role: String, + task_id: String, + tool_id: String, + brief_path_name: String, +} + +fn deserialize_unique_string_map<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct UniqueStringMapVisitor; + + impl<'de> Visitor<'de> for UniqueStringMapVisitor { + type Value = BTreeMap; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a string map without duplicate keys") + } + + fn visit_map(self, mut access: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = BTreeMap::new(); + while let Some((key, value)) = access.next_entry::()? { + if values.insert(key.clone(), value).is_some() { + return Err(serde::de::Error::custom(format!( + "duplicate section id: {key}" + ))); + } + } + Ok(values) + } + } + + deserializer.deserialize_map(UniqueStringMapVisitor) +} + +pub fn compile_manifest(manifest_path: &Path) -> Result { + let manifest_text = fs::read_to_string(manifest_path) + .map_err(|error| format!("读取 Prompt Bundle manifest 失败:{error}"))?; + let manifest: PromptBundleManifest = serde_json::from_str(&manifest_text) + .map_err(|error| format!("解析 Prompt Bundle manifest 失败:{error}"))?; + if manifest.schema_version != 1 { + return Err(format!( + "Prompt Bundle schemaVersion 不受支持:{}", + manifest.schema_version + )); + } + validate_identifier(&manifest.id, "bundle id", true)?; + validate_nonempty(&manifest.version, "bundle version")?; + + let base = manifest_path + .parent() + .ok_or_else(|| "Prompt Bundle manifest 缺少父目录".to_string())?; + let canonical_base = fs::canonicalize(base) + .map_err(|error| format!("解析 Prompt Bundle 根目录失败:{error}"))?; + let mut dependencies = vec![manifest_path.to_path_buf()]; + let mut section_paths = BTreeSet::new(); + let mut sections = BTreeMap::new(); + if manifest.sections.is_empty() { + return Err("Prompt Bundle sections 不能为空".to_string()); + } + for (section_id, relative_path) in &manifest.sections { + validate_identifier(section_id, "section id", false)?; + validate_section_path(relative_path)?; + let portable_path = portable_section_path_key(relative_path); + if !section_paths.insert(portable_path) { + return Err(format!("Prompt Bundle section 路径重复:{relative_path}")); + } + validate_no_symlink_components(base, relative_path)?; + let path = base.join(relative_path); + let canonical_path = fs::canonicalize(&path) + .map_err(|error| format!("解析 Prompt section 路径失败 {relative_path}:{error}"))?; + if !canonical_path.starts_with(&canonical_base) { + return Err(format!( + "Prompt section 逃逸 Bundle 根目录:{relative_path}" + )); + } + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取 Prompt section 元数据失败 {relative_path}:{error}"))?; + if !metadata.file_type().is_file() || metadata.file_type().is_symlink() { + return Err(format!("Prompt section 必须是普通文件:{relative_path}")); + } + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取 Prompt section 失败 {relative_path}:{error}"))?; + if content.trim().is_empty() { + return Err(format!("Prompt section 不能为空:{section_id}")); + } + dependencies.push(path); + sections.insert(section_id.clone(), content); + } + validate_registered_markdown_files(base, §ion_paths)?; + + validate_composition( + "runtime", + &manifest.compositions.runtime, + §ions, + &["$header", "$isolatedAgentTemplates", "$platform"], + )?; + validate_composition( + "supervisor", + &manifest.compositions.supervisor, + §ions, + &["$base", "$visualContract"], + )?; + validate_section_reference( + &manifest.variants.platform.default, + §ions, + "variants.platform.default", + )?; + validate_section_reference( + &manifest.variants.platform.linux, + §ions, + "variants.platform.linux", + )?; + if manifest.variants.platform.default == manifest.variants.platform.linux { + return Err("platform default/linux 必须引用不同 section".to_string()); + } + validate_section_reference( + &manifest.variants.visual_contract.editor_configured, + §ions, + "variants.visualContract.editorConfigured", + )?; + validate_section_reference( + &manifest.variants.visual_contract.editor_unavailable, + §ions, + "variants.visualContract.editorUnavailable", + )?; + if manifest.variants.visual_contract.editor_configured + == manifest.variants.visual_contract.editor_unavailable + { + return Err("visual contract 两个变体必须引用不同 section".to_string()); + } + + validate_agent_catalog(&manifest.agent_catalog)?; + let catalog_task_ids = manifest + .agent_catalog + .supervisor + .roles + .iter() + .chain( + manifest + .agent_catalog + .groups + .iter() + .flat_map(|group| group.roles.iter()), + ) + .map(|role| role.task_id.as_str()) + .collect::>(); + let mut overlays = BTreeSet::new(); + for overlay in &manifest.role_overlays { + validate_identifier(&overlay.agent_id, "role overlay agentId", false)?; + if !catalog_task_ids.contains(overlay.agent_id.as_str()) { + return Err(format!( + "role overlay 引用了未知 agentId:{}", + overlay.agent_id + )); + } + if let Some(root_source) = &overlay.root_source { + validate_identifier(root_source, "role overlay rootSource", false)?; + } + if overlay.sections.is_empty() { + return Err(format!( + "role overlay {} sections 不能为空", + overlay.agent_id + )); + } + let overlay_key = (overlay.agent_id.clone(), overlay.root_source.clone()); + if !overlays.insert(overlay_key) { + return Err(format!("role overlay 重复:{}", overlay.agent_id)); + } + if manifest.role_overlays.iter().any(|other| { + other.agent_id == overlay.agent_id + && other.root_source != overlay.root_source + && (other.root_source.is_none() || overlay.root_source.is_none()) + }) { + return Err(format!("role overlay selector 重叠:{}", overlay.agent_id)); + } + validate_section_sequence( + &format!("role overlay {}", overlay.agent_id), + &overlay.sections, + §ions, + )?; + } + + let mut used_sections = manifest + .compositions + .runtime + .iter() + .chain(manifest.compositions.supervisor.iter()) + .filter(|item| !item.starts_with('$')) + .cloned() + .collect::>(); + used_sections.extend([ + manifest.variants.platform.default.clone(), + manifest.variants.platform.linux.clone(), + manifest.variants.visual_contract.editor_configured.clone(), + manifest.variants.visual_contract.editor_unavailable.clone(), + ]); + used_sections.extend( + manifest + .role_overlays + .iter() + .flat_map(|overlay| overlay.sections.iter().cloned()), + ); + for section_id in sections.keys() { + if !used_sections.contains(section_id) { + return Err(format!( + "Prompt section 未被任何 composition 使用:{section_id}" + )); + } + } + let rust_source = render_rust(&manifest, §ions); + let specialist_nodes = manifest + .agent_catalog + .groups + .iter() + .flat_map(|group| { + group.roles.iter().map(|role| CompiledAgentNode { + task_id: role.task_id.clone(), + group_id: group.id.clone(), + role: role.role.clone(), + }) + }) + .collect(); + Ok(CompiledPromptBundle { + rust_source, + dependencies, + specialist_nodes, + }) +} + +fn validate_composition( + name: &str, + composition: &[String], + sections: &BTreeMap, + required_markers: &[&str], +) -> Result<(), String> { + if composition.is_empty() { + return Err(format!("Prompt composition {name} 不能为空")); + } + let allowed_markers = required_markers.iter().copied().collect::>(); + let mut seen = BTreeSet::new(); + for item in composition { + if item.starts_with('$') { + if !allowed_markers.contains(item.as_str()) { + return Err(format!("Prompt composition {name} 包含未知 marker:{item}")); + } + } else { + validate_section_reference(item, sections, &format!("composition {name}"))?; + } + if !seen.insert(item.as_str()) { + return Err(format!("Prompt composition {name} 包含重复项:{item}")); + } + } + for marker in required_markers { + if !seen.contains(marker) { + return Err(format!("Prompt composition {name} 缺少 marker:{marker}")); + } + } + Ok(()) +} + +fn validate_section_sequence( + name: &str, + sequence: &[String], + sections: &BTreeMap, +) -> Result<(), String> { + let mut seen = BTreeSet::new(); + for section in sequence { + validate_section_reference(section, sections, name)?; + if !seen.insert(section) { + return Err(format!("{name} 包含重复 section:{section}")); + } + } + Ok(()) +} + +fn validate_section_reference( + section: &str, + sections: &BTreeMap, + owner: &str, +) -> Result<(), String> { + if sections.contains_key(section) { + Ok(()) + } else { + Err(format!("{owner} 引用了未知 section:{section}")) + } +} + +fn validate_section_path(path: &str) -> Result<(), String> { + if path.is_empty() + || path.contains('\\') + || !path.ends_with(".md") + || path.split('/').any(|segment| { + segment.is_empty() + || !segment.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + }) + { + return Err(format!("Prompt section 路径无效:{path}")); + } + let parsed = Path::new(path); + if parsed.is_absolute() + || parsed + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("Prompt section 路径必须是受限相对路径:{path}")); + } + Ok(()) +} + +fn portable_section_path_key(path: &str) -> String { + path.to_ascii_lowercase() +} + +fn validate_registered_markdown_files( + base: &Path, + registered_paths: &BTreeSet, +) -> Result<(), String> { + let mut discovered_paths = BTreeSet::new(); + collect_markdown_files(base, base, &mut discovered_paths)?; + for path in discovered_paths { + if !registered_paths.contains(&path) { + return Err(format!( + "Prompt Bundle 存在未登记的 Markdown section:{path}" + )); + } + } + Ok(()) +} + +fn collect_markdown_files( + base: &Path, + directory: &Path, + discovered_paths: &mut BTreeSet, +) -> Result<(), String> { + let entries = + fs::read_dir(directory).map_err(|error| format!("扫描 Prompt Bundle 目录失败:{error}"))?; + for entry in entries { + let entry = entry.map_err(|error| format!("读取 Prompt Bundle 目录项失败:{error}"))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("读取 Prompt Bundle 目录项元数据失败:{error}"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Prompt Bundle 目录不得包含 symlink:{}", + path.strip_prefix(base).unwrap_or(&path).to_string_lossy() + )); + } + if metadata.is_dir() { + collect_markdown_files(base, &path, discovered_paths)?; + continue; + } + if !metadata.is_file() + || !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + continue; + } + let relative = path + .strip_prefix(base) + .map_err(|_| format!("Prompt Bundle 文件逃逸根目录:{}", path.display()))?; + let relative = relative + .components() + .map(|component| match component { + Component::Normal(value) => value + .to_str() + .map(str::to_owned) + .ok_or_else(|| "Prompt Bundle 文件名必须是 UTF-8".to_string()), + _ => Err(format!( + "Prompt Bundle 文件路径无效:{}", + relative.display() + )), + }) + .collect::, _>>()? + .join("/"); + validate_section_path(&relative)?; + discovered_paths.insert(portable_section_path_key(&relative)); + } + Ok(()) +} + +fn validate_no_symlink_components(base: &Path, relative_path: &str) -> Result<(), String> { + let mut current = base.to_path_buf(); + for component in Path::new(relative_path).components() { + let Component::Normal(component) = component else { + return Err(format!("Prompt section 路径无效:{relative_path}")); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t) + .map_err(|error| format!("读取 Prompt section 路径失败 {relative_path}:{error}"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Prompt section 路径不得包含 symlink:{relative_path}" + )); + } + } + Ok(()) +} + +fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> { + if catalog.supervisor.roles.len() != 1 { + return Err("agentCatalog.supervisor 必须且只能包含一个 role".to_string()); + } + if catalog.groups.is_empty() { + return Err("agentCatalog.groups 不能为空".to_string()); + } + let mut group_brief_names = BTreeSet::new(); + for group in std::iter::once(&catalog.supervisor).chain(catalog.groups.iter()) { + if !group_brief_names.insert(group.brief_path_name.as_str()) { + return Err(format!( + "agent group briefPathName 重复:{}", + group.brief_path_name + )); + } + } + let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]); + for group in &catalog.groups { + let generated = rust_identifier(&group.id); + if !generated + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphabetic() || character == '_') + { + return Err(format!( + "agent group 无法生成合法 Rust 标识符:{}", + group.id + )); + } + if !generated_names.insert(generated.clone()) { + return Err(format!("agent group 生成 Rust 标识符重复:{generated}")); + } + } + let mut group_ids = BTreeSet::new(); + let mut task_ids = BTreeSet::new(); + let mut tool_ids = BTreeSet::new(); + validate_agent_group( + &catalog.supervisor, + &mut group_ids, + &mut task_ids, + &mut tool_ids, + )?; + for group in &catalog.groups { + validate_agent_group(group, &mut group_ids, &mut task_ids, &mut tool_ids)?; + } + let mut aliases = BTreeMap::new(); + for group in &catalog.groups { + for role in &group.roles { + let alias = agent_role_alias_id(&group.id, &role.role); + if task_ids.contains(&alias) && alias != role.task_id { + return Err(format!( + "agent role alias 与其它 taskId 冲突:{alias} -> {}", + role.task_id + )); + } + if let Some(existing_task_id) = aliases.insert(alias.clone(), role.task_id.clone()) { + if existing_task_id != role.task_id { + return Err(format!( + "agent role alias 重复映射:{alias} -> {existing_task_id} / {}", + role.task_id + )); + } + } + } + } + Ok(()) +} + +fn agent_role_alias_id(group: &str, role: &str) -> String { + format!("{group}-{role}") + .to_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { + character + } else { + '-' + } + }) + .collect() +} + +fn validate_agent_group( + group: &AgentGroup, + group_ids: &mut BTreeSet, + task_ids: &mut BTreeSet, + tool_ids: &mut BTreeSet, +) -> Result<(), String> { + validate_identifier(&group.id, "agent group id", false)?; + validate_nonempty(&group.label, "agent group label")?; + validate_nonempty(&group.role, "agent group role")?; + validate_file_name(&group.brief_path_name, "agent group briefPathName")?; + if !group_ids.insert(group.id.clone()) { + return Err(format!("agent group id 重复:{}", group.id)); + } + if group.roles.is_empty() { + return Err(format!("agent group {} roles 不能为空", group.id)); + } + let mut role_ids = BTreeSet::new(); + let mut role_brief_names = BTreeSet::new(); + for role in &group.roles { + validate_identifier(&role.id, "agent role id", false)?; + validate_nonempty(&role.role, "agent role name")?; + validate_identifier(&role.task_id, "agent role taskId", false)?; + validate_tool_id(&role.tool_id)?; + validate_file_name(&role.brief_path_name, "agent role briefPathName")?; + if !role_ids.insert(role.id.clone()) { + return Err(format!("agent role id 重复:{} / {}", group.id, role.id)); + } + if !role_brief_names.insert(role.brief_path_name.clone()) { + return Err(format!( + "agent role briefPathName 重复:{} / {}", + group.id, role.brief_path_name + )); + } + if !task_ids.insert(role.task_id.clone()) { + return Err(format!("agent taskId 重复:{}", role.task_id)); + } + if !tool_ids.insert(role.tool_id.clone()) { + return Err(format!("agent toolId 重复:{}", role.tool_id)); + } + } + Ok(()) +} + +fn validate_identifier(value: &str, field: &str, allow_dot: bool) -> Result<(), String> { + validate_nonempty(value, field)?; + if value.len() > 96 + || !value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() + || matches!(character, '-' | '_') + || (allow_dot && character == '.') + }) + { + return Err(format!("{field} 格式无效:{value}")); + } + Ok(()) +} + +fn validate_tool_id(value: &str) -> Result<(), String> { + validate_identifier(value, "agent role toolId", true) +} + +fn validate_file_name(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() + || value.contains(['/', '\\']) + || !value.ends_with(".md") + || !value + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + || !value.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + || Path::new(value) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("{field} 格式无效:{value}")); + } + Ok(()) +} + +fn validate_nonempty(value: &str, field: &str) -> Result<(), String> { + if value.trim().is_empty() { + Err(format!("{field} 不能为空")) + } else { + Ok(()) + } +} + +fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap) -> String { + let mut output = + String::from("// @generated by build.rs from prompts/runtime/manifest.json\n\n"); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_BUNDLE_ID: &str = {};\n", + rust_literal(&manifest.id) + )); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_BUNDLE_VERSION: &str = {};\n", + rust_literal(&manifest.version) + )); + output.push_str("pub(crate) fn runtime_prompt_bundle_section(id: &str) -> Option<&'static str> {\n match id {\n"); + for (id, content) in sections { + output.push_str(&format!( + " {} => Some({}),\n", + rust_literal(id), + rust_literal(content) + )); + } + output.push_str(" _ => None,\n }\n}\n"); + output.push_str(&render_string_slice_const( + "RUNTIME_PROMPT_RUNTIME_COMPOSITION", + &manifest.compositions.runtime, + )); + output.push_str(&render_string_slice_const( + "RUNTIME_PROMPT_SUPERVISOR_COMPOSITION", + &manifest.compositions.supervisor, + )); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_PLATFORM_DEFAULT_SECTION: &str = {};\n", + rust_literal(&manifest.variants.platform.default) + )); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_PLATFORM_LINUX_SECTION: &str = {};\n", + rust_literal(&manifest.variants.platform.linux) + )); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_VISUAL_EDITOR_SECTION: &str = {};\n", + rust_literal(&manifest.variants.visual_contract.editor_configured) + )); + output.push_str(&format!( + "pub(crate) const RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION: &str = {};\n", + rust_literal(&manifest.variants.visual_contract.editor_unavailable) + )); + output.push_str( + "pub(crate) const RUNTIME_PROMPT_ROLE_OVERLAYS: &[(&str, Option<&str>, &[&str])] = &[\n", + ); + for overlay in &manifest.role_overlays { + let root_source = overlay + .root_source + .as_deref() + .map(|value| format!("Some({})", rust_literal(value))) + .unwrap_or_else(|| "None".to_string()); + output.push_str(&format!( + " ({}, {}, &{}),\n", + rust_literal(&overlay.agent_id), + root_source, + render_string_slice(&overlay.sections) + )); + } + output.push_str("];\n\n"); + output.push_str(&render_agent_catalog(&manifest.agent_catalog)); + output +} + +fn render_string_slice_const(name: &str, values: &[String]) -> String { + format!( + "pub(crate) const {name}: &[&str] = &{};\n", + render_string_slice(values) + ) +} + +fn render_string_slice(values: &[String]) -> String { + format!( + "[{}]", + values + .iter() + .map(|value| rust_literal(value)) + .collect::>() + .join(", ") + ) +} + +fn render_agent_catalog(catalog: &AgentCatalog) -> String { + let supervisor_role = &catalog.supervisor.roles[0]; + let mut output = format!( + "pub(crate) const GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID: &str = {};\n", + rust_literal(&supervisor_role.task_id) + ); + output.push_str(&format!( + "pub(crate) const GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH: &str = {};\n", + rust_literal(&format!( + "memory/agents/{}", + catalog.supervisor.brief_path_name + )) + )); + output.push_str(&render_role_array( + "PROJECT_SUPERVISOR_AGENT_ROLES", + &catalog.supervisor.roles, + )); + output.push_str(&format!( + "static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = {};\n", + render_group_value(&catalog.supervisor, "&PROJECT_SUPERVISOR_AGENT_ROLES") + )); + for group in &catalog.groups { + let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id)); + output.push_str(&render_role_array(&roles_name, &group.roles)); + } + output.push_str(&format!( + "const GAME_CREATOR_AGENT_GROUP_DEFINITIONS: [AgentGroupDefinition; {}] = [\n", + catalog.groups.len() + )); + for group in &catalog.groups { + let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id)); + output.push_str(" "); + output.push_str(&render_group_value(group, &format!("&{roles_name}"))); + output.push_str(",\n"); + } + output.push_str("];\n"); + output +} + +fn render_role_array(name: &str, roles: &[AgentRole]) -> String { + let mut output = format!( + "static {name}: [AgentRoleDefinition; {}] = [\n", + roles.len() + ); + for role in roles { + output.push_str(&format!( + " AgentRoleDefinition {{ id: {}, role: {}, task_id: {}, tool_id: {}, brief_path_name: {} }},\n", + rust_literal(&role.id), + rust_literal(&role.role), + rust_literal(&role.task_id), + rust_literal(&role.tool_id), + rust_literal(&role.brief_path_name) + )); + } + output.push_str("];\n"); + output +} + +fn render_group_value(group: &AgentGroup, roles: &str) -> String { + format!( + "AgentGroupDefinition {{ id: {}, label: {}, role: {}, brief_path_name: {}, roles: {} }}", + rust_literal(&group.id), + rust_literal(&group.label), + rust_literal(&group.role), + rust_literal(&group.brief_path_name), + roles + ) +} + +fn rust_identifier(value: &str) -> String { + value + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character.to_ascii_uppercase() + } else { + '_' + } + }) + .collect() +} + +fn rust_literal(value: &str) -> String { + format!("{value:?}") +} diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json index e16a9f558..374d4872e 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/manifest.json @@ -1,21 +1,231 @@ { + "schemaVersion": 1, "id": "genarrative.agent-runtime", - "version": "2026-08-03.4", + "version": "2026-08-04.1", "sections": { "common": "common.md", "isolatedAgentContract": "isolated-agent-contract.md", "platformDefault": "platform/default.md", "platformLinux": "platform/linux.md", - "roles": { - "codePrototypeGameChat": "roles/code-prototype-game-chat.md" + "codePrototypeGameChat": "roles/code-prototype-game-chat.md", + "supervisorIntro": "supervisor/intro.md", + "supervisorVisualWithoutEditor": "supervisor/visual-contract-without-editor.md", + "supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md", + "supervisorPlaybook": "supervisor/playbook.md", + "supervisorClaimGate": "supervisor/claim-gate.md", + "supervisorRepair": "supervisor/repair.md" + }, + "compositions": { + "runtime": [ + "$header", + "common", + "$isolatedAgentTemplates", + "isolatedAgentContract", + "$platform" + ], + "supervisor": [ + "$base", + "supervisorIntro", + "$visualContract", + "supervisorPlaybook", + "supervisorClaimGate", + "supervisorRepair" + ] + }, + "variants": { + "platform": { + "default": "platformDefault", + "linux": "platformLinux" }, - "supervisor": { - "intro": "supervisor/intro.md", - "visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md", - "visualContractWithEditor": "supervisor/visual-contract-with-editor.md", - "playbook": "supervisor/playbook.md", - "claimGate": "supervisor/claim-gate.md", - "repair": "supervisor/repair.md" + "visualContract": { + "editorConfigured": "supervisorVisualWithEditor", + "editorUnavailable": "supervisorVisualWithoutEditor" } + }, + "roleOverlays": [ + { + "agentId": "code-prototype", + "rootSource": "project-supervisor-game-chat", + "sections": ["codePrototypeGameChat"] + } + ], + "agentCatalog": { + "supervisor": { + "id": "supervisor", + "label": "项目总控", + "role": "Project Supervisor", + "briefPathName": "project-supervisor.md", + "roles": [ + { + "id": "project-supervisor", + "role": "Project Supervisor", + "taskId": "project-supervisor", + "toolId": "agent.runtime.project-supervisor", + "briefPathName": "project-supervisor.md" + } + ] + }, + "groups": [ + { + "id": "design", + "label": "策划组", + "role": "Director + Gameplay", + "briefPathName": "design.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "design-director", + "toolId": "agent.role.brief.design.director", + "briefPathName": "director.md" + }, + { + "id": "gameplay", + "role": "Gameplay", + "taskId": "design-foundation", + "toolId": "agent.role.brief.design.gameplay", + "briefPathName": "gameplay.md" + } + ] + }, + { + "id": "balance", + "label": "数值组", + "role": "Director + Difficulty", + "briefPathName": "balance.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "balance-director", + "toolId": "agent.role.brief.balance.director", + "briefPathName": "director.md" + }, + { + "id": "difficulty", + "role": "Difficulty", + "taskId": "balance-seed", + "toolId": "agent.role.brief.balance.difficulty", + "briefPathName": "difficulty.md" + } + ] + }, + { + "id": "art", + "label": "美术组", + "role": "Director + Asset + Polish", + "briefPathName": "art.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "art-director", + "toolId": "agent.role.brief.art.director", + "briefPathName": "director.md" + }, + { + "id": "asset", + "role": "Asset", + "taskId": "art-asset-plan", + "toolId": "agent.role.brief.art.asset", + "briefPathName": "asset.md" + }, + { + "id": "polish", + "role": "Polish", + "taskId": "art-polish", + "toolId": "agent.role.brief.art.polish", + "briefPathName": "polish.md" + } + ] + }, + { + "id": "audio", + "label": "音乐组", + "role": "Director + SFX", + "briefPathName": "audio.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "audio-director", + "toolId": "agent.role.brief.audio.director", + "briefPathName": "director.md" + }, + { + "id": "sfx", + "role": "SFX", + "taskId": "audio-asset-plan", + "toolId": "agent.role.brief.audio.sfx", + "briefPathName": "sfx.md" + } + ] + }, + { + "id": "code", + "label": "程序组", + "role": "Director + Code + Review + Preview + Playtest", + "briefPathName": "code.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "code-director", + "toolId": "agent.role.brief.code.director", + "briefPathName": "director.md" + }, + { + "id": "code", + "role": "Code", + "taskId": "code-prototype", + "toolId": "agent.role.brief.code.code", + "briefPathName": "code.md" + }, + { + "id": "review", + "role": "Review", + "taskId": "quality-review", + "toolId": "agent.role.brief.code.review", + "briefPathName": "review.md" + }, + { + "id": "preview", + "role": "Preview", + "taskId": "preview-readiness", + "toolId": "agent.role.brief.code.preview", + "briefPathName": "preview.md" + }, + { + "id": "playtest", + "role": "Playtest", + "taskId": "preview-playtest", + "toolId": "agent.role.brief.code.playtest", + "briefPathName": "playtest.md" + } + ] + }, + { + "id": "publishing", + "label": "运营组", + "role": "Director + Publish", + "briefPathName": "publishing.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "publish-strategy", + "toolId": "agent.role.brief.publishing.director", + "briefPathName": "director.md" + }, + { + "id": "publish", + "role": "Publish", + "taskId": "publish-package", + "toolId": "agent.role.brief.publishing.publish", + "briefPathName": "publish.md" + } + ] + } + ] } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 9d2ec9aef..ebee3f6ee 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -1,31 +1,5 @@ use super::*; -const RUNTIME_PROMPT_BUNDLE_VERSION: &str = "2026-08-03.4"; -const RUNTIME_PROMPT_BUNDLE_MANIFEST: &str = include_str!("../../prompts/runtime/manifest.json"); -const RUNTIME_PROMPT_COMMON: &str = include_str!("../../prompts/runtime/common.md"); -const RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT: &str = - include_str!("../../prompts/runtime/isolated-agent-contract.md"); -#[cfg(any(test, not(target_os = "linux")))] -const RUNTIME_PROMPT_PLATFORM_DEFAULT: &str = - include_str!("../../prompts/runtime/platform/default.md"); -#[cfg(any(test, target_os = "linux"))] -const RUNTIME_PROMPT_PLATFORM_LINUX: &str = include_str!("../../prompts/runtime/platform/linux.md"); -#[cfg(test)] -const RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT: &str = - include_str!("../../prompts/runtime/roles/code-prototype-game-chat.md"); -#[cfg(not(target_os = "linux"))] -const RUNTIME_PROMPT_PLATFORM: &str = RUNTIME_PROMPT_PLATFORM_DEFAULT; -#[cfg(target_os = "linux")] -const RUNTIME_PROMPT_PLATFORM: &str = RUNTIME_PROMPT_PLATFORM_LINUX; -const SUPERVISOR_INTRO: &str = include_str!("../../prompts/runtime/supervisor/intro.md"); -const SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR: &str = - include_str!("../../prompts/runtime/supervisor/visual-contract-without-editor.md"); -const SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR: &str = - include_str!("../../prompts/runtime/supervisor/visual-contract-with-editor.md"); -const SUPERVISOR_PLAYBOOK: &str = include_str!("../../prompts/runtime/supervisor/playbook.md"); -const SUPERVISOR_CLAIM_GATE: &str = include_str!("../../prompts/runtime/supervisor/claim-gate.md"); -const SUPERVISOR_REPAIR: &str = include_str!("../../prompts/runtime/supervisor/repair.md"); - pub(super) fn render_agent_runtime_prompt_context( root: &Path, agent_id: &str, @@ -572,19 +546,19 @@ fn game_creator_project_supervisor_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let visual_delivery_contract = if editor_api_key_is_configured { - SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR + let visual_section = if editor_api_key_is_configured { + RUNTIME_PROMPT_VISUAL_EDITOR_SECTION } else { - SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR + RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION }; - render_runtime_prompt_sections(&[ - &prompt, - SUPERVISOR_INTRO, - visual_delivery_contract, - SUPERVISOR_PLAYBOOK, - SUPERVISOR_CLAIM_GATE, - SUPERVISOR_REPAIR, - ]) + render_runtime_prompt_composition( + RUNTIME_PROMPT_SUPERVISOR_COMPOSITION, + |marker| match marker { + "$base" => Some(prompt), + "$visualContract" => Some(required_runtime_prompt_section(visual_section)), + _ => None, + }, + ) } fn render_runtime_prompt_sections(sections: &[&str]) -> String { @@ -596,8 +570,62 @@ fn render_runtime_prompt_sections(sections: &[&str]) -> String { .join("\n\n") } +fn required_runtime_prompt_section(section_id: &str) -> &'static str { + runtime_prompt_bundle_section(section_id) + .unwrap_or_else(|| panic!("生成的 Prompt Bundle 缺少 section:{section_id}")) +} + +fn render_runtime_prompt_composition<'a>( + composition: &[&str], + mut dynamic: impl FnMut(&str) -> Option<&'a str>, +) -> String { + let sections = composition + .iter() + .map(|item| { + if item.starts_with('$') { + dynamic(item) + .unwrap_or_else(|| panic!("Prompt composition 缺少动态 marker:{item}")) + } else { + required_runtime_prompt_section(item) + } + }) + .collect::>(); + render_runtime_prompt_sections(§ions) +} + +fn runtime_prompt_platform_section_id(linux: bool) -> &'static str { + if linux { + RUNTIME_PROMPT_PLATFORM_LINUX_SECTION + } else { + RUNTIME_PROMPT_PLATFORM_DEFAULT_SECTION + } +} + +pub(crate) fn game_creator_agent_runtime_role_overlay_prompt( + agent_id: &str, + root_source: Option<&str>, +) -> String { + let sections = RUNTIME_PROMPT_ROLE_OVERLAYS + .iter() + .filter(|(overlay_agent_id, overlay_root_source, _)| { + *overlay_agent_id == agent_id + && overlay_root_source.is_none_or(|required| Some(required) == root_source) + }) + .flat_map(|(_, _, sections)| sections.iter().copied()) + .map(required_runtime_prompt_section) + .collect::>(); + render_runtime_prompt_sections(§ions) +} + pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { - debug_assert!(RUNTIME_PROMPT_BUNDLE_MANIFEST.contains(RUNTIME_PROMPT_BUNDLE_VERSION)); + #[cfg(target_os = "linux")] + const CURRENT_PLATFORM_IS_LINUX: bool = true; + #[cfg(not(target_os = "linux"))] + const CURRENT_PLATFORM_IS_LINUX: bool = false; + game_creator_agent_runtime_tool_plan_system_prompt_for_platform(CURRENT_PLATFORM_IS_LINUX) +} + +fn game_creator_agent_runtime_tool_plan_system_prompt_for_platform(linux: bool) -> String { let tool_catalog = agent_runtime_native_executable_tools().join("、"); let prompt_header = format!( "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须直接调用当前请求广告的原生函数:复杂任务首次拆解、实际进度变化、steer 调整顺序或最终收束时调用 update_agent_plan,并提交 explanation 与完整 steps;无需更新时不要调用 update_agent_plan。steps 只允许 pending、in_progress、completed 且同时最多一个 in_progress;已完成步骤必须保留且不得回退,所有必要步骤 completed 前不得调用 respond_to_user,Runtime 不会按工具动作下标代替你更新进度。只能请求以下 Runtime 当前注册的原生可执行工具:{tool_catalog}。MCP 工具仅以当前请求提供的动态目录为准。" @@ -610,13 +638,13 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> String { let isolated_agent_templates = format!( "agent.spawn_isolated 的合法 templateAgentId 仅限以下静态模板 taskId:{isolated_template_ids}。" ); - render_runtime_prompt_sections(&[ - &prompt_header, - RUNTIME_PROMPT_COMMON, - &isolated_agent_templates, - RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT, - RUNTIME_PROMPT_PLATFORM, - ]) + let platform_section = runtime_prompt_platform_section_id(linux); + render_runtime_prompt_composition(RUNTIME_PROMPT_RUNTIME_COMPOSITION, |marker| match marker { + "$header" => Some(&prompt_header), + "$isolatedAgentTemplates" => Some(&isolated_agent_templates), + "$platform" => Some(required_runtime_prompt_section(platform_section)), + _ => None, + }) } pub(crate) fn game_creator_agent_role_definition( @@ -645,46 +673,35 @@ mod tests { #[test] fn runtime_prompt_bundle_manifest_covers_all_embedded_sections() { - let manifest: serde_json::Value = - serde_json::from_str(RUNTIME_PROMPT_BUNDLE_MANIFEST).expect("prompt bundle manifest"); - - assert_eq!(manifest["id"], "genarrative.agent-runtime"); - assert_eq!(manifest["version"], RUNTIME_PROMPT_BUNDLE_VERSION); + assert_eq!(RUNTIME_PROMPT_BUNDLE_ID, "genarrative.agent-runtime"); + assert_eq!(RUNTIME_PROMPT_BUNDLE_VERSION, "2026-08-04.1"); assert_eq!( - manifest["sections"], - serde_json::json!({ - "common": "common.md", - "isolatedAgentContract": "isolated-agent-contract.md", - "platformDefault": "platform/default.md", - "platformLinux": "platform/linux.md", - "roles": { - "codePrototypeGameChat": "roles/code-prototype-game-chat.md" - }, - "supervisor": { - "intro": "supervisor/intro.md", - "visualContractWithoutEditor": "supervisor/visual-contract-without-editor.md", - "visualContractWithEditor": "supervisor/visual-contract-with-editor.md", - "playbook": "supervisor/playbook.md", - "claimGate": "supervisor/claim-gate.md", - "repair": "supervisor/repair.md" - } - }) + RUNTIME_PROMPT_RUNTIME_COMPOSITION, + &[ + "$header", + "common", + "$isolatedAgentTemplates", + "isolatedAgentContract", + "$platform" + ] ); - assert!([ - RUNTIME_PROMPT_COMMON, - RUNTIME_PROMPT_ISOLATED_AGENT_CONTRACT, - RUNTIME_PROMPT_PLATFORM_DEFAULT, - RUNTIME_PROMPT_PLATFORM_LINUX, - RUNTIME_PROMPT_CODE_PROTOTYPE_GAME_CHAT, - SUPERVISOR_INTRO, - SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR, - SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR, - SUPERVISOR_PLAYBOOK, - SUPERVISOR_CLAIM_GATE, - SUPERVISOR_REPAIR, - ] - .iter() - .all(|section| !section.trim().is_empty())); + for section_id in [ + "common", + "isolatedAgentContract", + "platformDefault", + "platformLinux", + "codePrototypeGameChat", + "supervisorIntro", + "supervisorVisualWithoutEditor", + "supervisorVisualWithEditor", + "supervisorPlaybook", + "supervisorClaimGate", + "supervisorRepair", + ] { + assert!(!required_runtime_prompt_section(section_id) + .trim() + .is_empty()); + } } #[test] @@ -699,18 +716,273 @@ mod tests { } } + #[test] + fn runtime_prompt_selects_exactly_one_manifest_platform_variant() { + let default_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_platform(false); + let linux_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_platform(true); + let default_section = + required_runtime_prompt_section(RUNTIME_PROMPT_PLATFORM_DEFAULT_SECTION).trim(); + let linux_section = + required_runtime_prompt_section(RUNTIME_PROMPT_PLATFORM_LINUX_SECTION).trim(); + + assert!(default_prompt.contains(default_section)); + assert!(!default_prompt.contains(linux_section)); + assert!(linux_prompt.contains(linux_section)); + assert!(!linux_prompt.contains(default_section)); + #[cfg(target_os = "linux")] + assert_eq!( + game_creator_agent_runtime_tool_plan_system_prompt(), + linux_prompt + ); + #[cfg(not(target_os = "linux"))] + assert_eq!( + game_creator_agent_runtime_tool_plan_system_prompt(), + default_prompt + ); + } + + #[test] + fn generated_agent_catalog_preserves_the_authoritative_node_order() { + let actual = GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .map(|group| { + ( + group.id, + group.label, + group.role, + group.brief_path_name, + group + .roles + .iter() + .map(|role| { + ( + role.id, + role.role, + role.task_id, + role.tool_id, + role.brief_path_name, + ) + }) + .collect::>(), + ) + }) + .collect::>(); + let expected = vec![ + ( + "design", + "策划组", + "Director + Gameplay", + "design.md", + vec![ + ( + "director", + "Director", + "design-director", + "agent.role.brief.design.director", + "director.md", + ), + ( + "gameplay", + "Gameplay", + "design-foundation", + "agent.role.brief.design.gameplay", + "gameplay.md", + ), + ], + ), + ( + "balance", + "数值组", + "Director + Difficulty", + "balance.md", + vec![ + ( + "director", + "Director", + "balance-director", + "agent.role.brief.balance.director", + "director.md", + ), + ( + "difficulty", + "Difficulty", + "balance-seed", + "agent.role.brief.balance.difficulty", + "difficulty.md", + ), + ], + ), + ( + "art", + "美术组", + "Director + Asset + Polish", + "art.md", + vec![ + ( + "director", + "Director", + "art-director", + "agent.role.brief.art.director", + "director.md", + ), + ( + "asset", + "Asset", + "art-asset-plan", + "agent.role.brief.art.asset", + "asset.md", + ), + ( + "polish", + "Polish", + "art-polish", + "agent.role.brief.art.polish", + "polish.md", + ), + ], + ), + ( + "audio", + "音乐组", + "Director + SFX", + "audio.md", + vec![ + ( + "director", + "Director", + "audio-director", + "agent.role.brief.audio.director", + "director.md", + ), + ( + "sfx", + "SFX", + "audio-asset-plan", + "agent.role.brief.audio.sfx", + "sfx.md", + ), + ], + ), + ( + "code", + "程序组", + "Director + Code + Review + Preview + Playtest", + "code.md", + vec![ + ( + "director", + "Director", + "code-director", + "agent.role.brief.code.director", + "director.md", + ), + ( + "code", + "Code", + "code-prototype", + "agent.role.brief.code.code", + "code.md", + ), + ( + "review", + "Review", + "quality-review", + "agent.role.brief.code.review", + "review.md", + ), + ( + "preview", + "Preview", + "preview-readiness", + "agent.role.brief.code.preview", + "preview.md", + ), + ( + "playtest", + "Playtest", + "preview-playtest", + "agent.role.brief.code.playtest", + "playtest.md", + ), + ], + ), + ( + "publishing", + "运营组", + "Director + Publish", + "publishing.md", + vec![ + ( + "director", + "Director", + "publish-strategy", + "agent.role.brief.publishing.director", + "director.md", + ), + ( + "publish", + "Publish", + "publish-package", + "agent.role.brief.publishing.publish", + "publish.md", + ), + ], + ), + ]; + + assert_eq!( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "project-supervisor" + ); + assert_eq!(PROJECT_SUPERVISOR_AGENT_DEFINITION.id, "supervisor"); + assert_eq!(PROJECT_SUPERVISOR_AGENT_ROLES.len(), 1); + assert_eq!(actual, expected); + } + + #[test] + fn generated_agent_catalog_matches_the_seed_manifest_identity() { + let catalog = GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .flat_map(|group| { + group + .roles + .iter() + .map(move |role| (role.task_id, group.id, role.role)) + }) + .collect::>(); + let seed_tasks = new_game_creation_app_seed_tasks() + .into_iter() + .map(|task| { + let group = serde_json::to_value(&task.group) + .expect("serialize seed task group") + .as_str() + .expect("seed task group string") + .to_string(); + (task.id, group, task.role) + }) + .collect::>(); + let catalog = catalog + .into_iter() + .map(|(task_id, group, role)| { + (task_id.to_string(), group.to_string(), role.to_string()) + }) + .collect::>(); + + assert_eq!(catalog, seed_tasks); + } + #[test] fn supervisor_prompt_composes_the_versioned_collaboration_graph_in_order() { for editor_api_key_is_configured in [false, true] { let visual_contract = if editor_api_key_is_configured { - SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR + required_runtime_prompt_section(RUNTIME_PROMPT_VISUAL_EDITOR_SECTION) } else { - SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR + required_runtime_prompt_section(RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION) }; let other_visual_contract = if editor_api_key_is_configured { - SUPERVISOR_VISUAL_CONTRACT_WITHOUT_EDITOR + required_runtime_prompt_section(RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION) } else { - SUPERVISOR_VISUAL_CONTRACT_WITH_EDITOR + required_runtime_prompt_section(RUNTIME_PROMPT_VISUAL_EDITOR_SECTION) }; let prompt = game_creator_project_supervisor_tool_plan_prompt( "shared runtime contract", @@ -718,11 +990,11 @@ mod tests { ); let sections = [ "shared runtime contract", - SUPERVISOR_INTRO, + required_runtime_prompt_section("supervisorIntro"), visual_contract, - SUPERVISOR_PLAYBOOK, - SUPERVISOR_CLAIM_GATE, - SUPERVISOR_REPAIR, + required_runtime_prompt_section("supervisorPlaybook"), + required_runtime_prompt_section("supervisorClaimGate"), + required_runtime_prompt_section("supervisorRepair"), ]; let mut cursor = 0; for section in sections { @@ -757,7 +1029,7 @@ mod tests { fn runtime_prompt_bundle_uses_only_native_function_protocol_terms() { for editor_api_key_is_configured in [false, true] { let prompt = game_creator_project_supervisor_tool_plan_prompt( - RUNTIME_PROMPT_COMMON, + required_runtime_prompt_section("common"), editor_api_key_is_configured, ); for legacy_term in [ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 11c0e7cfc..612dd9151 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -3,18 +3,6 @@ use crate::mcp::GAME_CREATOR_MCP_CALL_TOOL; const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止调用 respond_to_user;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。"; -const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = - include_str!("../../../prompts/runtime/roles/code-prototype-game-chat.md"); - -fn game_chat_fast_path_prompt_for_root_source( - agent_id: &str, - root_source: &str, -) -> Option<&'static str> { - (agent_id.trim() == "code-prototype" - && root_source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE) - .then_some(GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT) -} - #[cfg(target_os = "linux")] fn provider_command_exec_contract() -> &'static str { "command.exec 使用 {\"program\":\"受信任 PATH 中的裸可执行名\",\"args\":[\"逐项 argv\"],\"cwd\":\"可选项目内相对目录\",\"timeoutSeconds\":120};Linux 命令固定运行在 bubblewrap workspace-write、network-disabled 沙箱内,允许 bash -lc、管道、重定向和项目脚本,但不接受环境变量、宿主 executable 路径、mount 或网络策略输入" @@ -214,11 +202,13 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( } if autonomous_game_build { let root_source = agent_runtime_root_source_at(root, agent_id, run_id)?; - if let Some(fast_path_prompt) = - game_chat_fast_path_prompt_for_root_source(agent_id, &root_source) - { + let role_overlay = game_creator_agent_runtime_role_overlay_prompt( + agent_id.trim(), + Some(root_source.trim()), + ); + if !role_overlay.is_empty() { system_prompt.push_str("\n\n"); - system_prompt.push_str(fast_path_prompt); + system_prompt.push_str(&role_overlay); } } let mut request = LlmRunRequest::new(vec![ @@ -431,7 +421,7 @@ mod tests { use super::{ agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, build_game_creator_agent_background_tool_plan_request, - game_chat_fast_path_prompt_for_root_source, game_creator_agent_context_preload_notice, + game_creator_agent_context_preload_notice, game_creator_agent_runtime_role_overlay_prompt, init_local_game_project_at, provider_command_exec_contract, provider_command_start_contract, start_game_creator_agent_runtime_task_at, AgentRuntimeTaskLink, GameCreatorMcpCatalog, GameCreatorMcpCatalogTool, @@ -459,6 +449,69 @@ mod tests { .collect() } + fn build_request_system_prompt_for_root_source( + agent_id: &str, + root_source: &str, + suffix: &str, + ) -> String { + let temporary = tempfile::tempdir().expect("temporary project root"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, &format!("overlay-{suffix}"), "role overlay test") + .expect("project init"); + let parent_run_id = format!("overlay-parent-{suffix}"); + let parent = bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_run_id, + root_source, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind parent profile"); + let child_run_id = format!("overlay-child-{suffix}"); + let child_link = AgentRuntimeTaskLink { + parent_agent_id: Some(parent.agent_id), + parent_run_id: Some(parent.run_id), + delegation_id: Some(format!("overlay-delegation-{suffix}")), + }; + bind_game_creator_agent_runtime_run_profile_at( + &root, + agent_id, + &child_run_id, + "agent-ready-task-scheduler", + None, + Some(&child_link), + ) + .expect("bind child profile"); + let state = start_game_creator_agent_runtime_task_at( + &root, + agent_id, + "核对 role overlay", + &child_run_id, + "agent-ready-task-scheduler", + "构建 planning request", + vec!["核对 overlay".to_string()], + ) + .expect("start child task"); + let catalog = GameCreatorMcpCatalog { + fingerprint: String::new(), + servers: Vec::new(), + tools: Vec::new(), + }; + let (_, _, request, _) = build_game_creator_agent_background_tool_plan_request( + &root, + agent_id, + &state.session_id, + &state.run_id, + &state.current_task, + &[], + 0, + &catalog, + ) + .expect("build child request"); + request.messages[0].content.clone() + } + #[test] fn context_preload_notice_matches_agent_context() { assert_eq!( @@ -745,11 +798,10 @@ mod tests { #[test] fn game_chat_fast_path_prompt_protects_existing_game_and_requires_cropped_spritesheet_use() { - let prompt = game_chat_fast_path_prompt_for_root_source( + let prompt = game_creator_agent_runtime_role_overlay_prompt( "code-prototype", - AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, - ) - .expect("game-chat code fast path prompt"); + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), + ); assert!(prompt.contains("素材完整快车道")); assert!(prompt.contains("第一步必须调用 file.read(path=game/index.html)")); @@ -767,26 +819,50 @@ mod tests { assert!(prompt.contains("不得猜测整张图集是等分网格")); assert!(prompt.contains("不得以纯代码几何替代核心实体")); assert!(prompt.contains("四类切片或其可见使用任一缺失时不得交付")); - assert!(game_chat_fast_path_prompt_for_root_source( + assert!(game_creator_agent_runtime_role_overlay_prompt( "quality-review", - AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + Some(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE), ) - .is_none()); - assert!(game_chat_fast_path_prompt_for_root_source( + .is_empty()); + assert!(game_creator_agent_runtime_role_overlay_prompt( + "code-prototype", + Some(AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE), + ) + .is_empty()); + assert!(game_creator_agent_runtime_role_overlay_prompt( + "code-prototype", + Some(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE), + ) + .is_empty()); + assert!(game_creator_agent_runtime_role_overlay_prompt( + "code-prototype", + Some("agent-background-task"), + ) + .is_empty()); + } + + #[test] + fn provider_request_applies_manifest_role_overlay_once_only_to_the_matching_child() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); + let matching = build_request_system_prompt_for_root_source( + "code-prototype", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "matching", + ); + let other_source = build_request_system_prompt_for_root_source( "code-prototype", AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, - ) - .is_none()); - assert!(game_chat_fast_path_prompt_for_root_source( - "code-prototype", - AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - ) - .is_none()); - assert!(game_chat_fast_path_prompt_for_root_source( - "code-prototype", - "agent-background-task", - ) - .is_none()); + "other-source", + ); + let other_agent = build_request_system_prompt_for_root_source( + "quality-review", + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "other-agent", + ); + + assert_eq!(matching.matches("素材完整快车道").count(), 1); + assert_eq!(other_source.matches("素材完整快车道").count(), 0); + assert_eq!(other_agent.matches("素材完整快车道").count(), 0); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs index fb085c0a0..357aec35c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_adapter.rs @@ -4,7 +4,7 @@ use agent_runtime_core::{AgentCatalog, AgentDescriptor, RunProfileCatalog, RunPr fn build_game_creator_runtime_agent_catalog() -> Result { let mut agents = vec![AgentDescriptor::try_new( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - "supervisor", + PROJECT_SUPERVISOR_AGENT_DEFINITION.id, std::iter::empty::<&str>(), ) .and_then(|agent| { @@ -109,7 +109,7 @@ mod tests { catalog .get(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) .map(AgentDescriptor::role), - Some("supervisor") + Some(PROJECT_SUPERVISOR_AGENT_DEFINITION.id) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index eae225c80..d0f317a6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1306,200 +1306,9 @@ struct AgentGroupDefinition { roles: &'static [AgentRoleDefinition], } -pub(crate) const GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID: &str = "project-supervisor"; +include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); + const GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID: &str = "chat"; -const GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH: &str = "memory/agents/project-supervisor.md"; - -static PROJECT_SUPERVISOR_AGENT_ROLES: [AgentRoleDefinition; 1] = [AgentRoleDefinition { - id: "project-supervisor", - role: "Project Supervisor", - task_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - tool_id: "agent.runtime.project-supervisor", - brief_path_name: "project-supervisor.md", -}]; - -static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = AgentGroupDefinition { - id: "supervisor", - label: "项目总控", - role: "Project Supervisor", - brief_path_name: "project-supervisor.md", - roles: &PROJECT_SUPERVISOR_AGENT_ROLES, -}; - -static DESIGN_AGENT_ROLES: [AgentRoleDefinition; 2] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "design-director", - tool_id: "agent.role.brief.design.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "gameplay", - role: "Gameplay", - task_id: "design-foundation", - tool_id: "agent.role.brief.design.gameplay", - brief_path_name: "gameplay.md", - }, -]; - -static BALANCE_AGENT_ROLES: [AgentRoleDefinition; 2] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "balance-director", - tool_id: "agent.role.brief.balance.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "difficulty", - role: "Difficulty", - task_id: "balance-seed", - tool_id: "agent.role.brief.balance.difficulty", - brief_path_name: "difficulty.md", - }, -]; - -static ART_AGENT_ROLES: [AgentRoleDefinition; 3] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "art-director", - tool_id: "agent.role.brief.art.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "asset", - role: "Asset", - task_id: "art-asset-plan", - tool_id: "agent.role.brief.art.asset", - brief_path_name: "asset.md", - }, - AgentRoleDefinition { - id: "polish", - role: "Polish", - task_id: "art-polish", - tool_id: "agent.role.brief.art.polish", - brief_path_name: "polish.md", - }, -]; - -static AUDIO_AGENT_ROLES: [AgentRoleDefinition; 2] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "audio-director", - tool_id: "agent.role.brief.audio.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "sfx", - role: "SFX", - task_id: "audio-asset-plan", - tool_id: "agent.role.brief.audio.sfx", - brief_path_name: "sfx.md", - }, -]; - -static CODE_AGENT_ROLES: [AgentRoleDefinition; 5] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "code-director", - tool_id: "agent.role.brief.code.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "code", - role: "Code", - task_id: "code-prototype", - tool_id: "agent.role.brief.code.code", - brief_path_name: "code.md", - }, - AgentRoleDefinition { - id: "review", - role: "Review", - task_id: "quality-review", - tool_id: "agent.role.brief.code.review", - brief_path_name: "review.md", - }, - AgentRoleDefinition { - id: "preview", - role: "Preview", - task_id: "preview-readiness", - tool_id: "agent.role.brief.code.preview", - brief_path_name: "preview.md", - }, - AgentRoleDefinition { - id: "playtest", - role: "Playtest", - task_id: "preview-playtest", - tool_id: "agent.role.brief.code.playtest", - brief_path_name: "playtest.md", - }, -]; - -static PUBLISHING_AGENT_ROLES: [AgentRoleDefinition; 2] = [ - AgentRoleDefinition { - id: "director", - role: "Director", - task_id: "publish-strategy", - tool_id: "agent.role.brief.publishing.director", - brief_path_name: "director.md", - }, - AgentRoleDefinition { - id: "publish", - role: "Publish", - task_id: "publish-package", - tool_id: "agent.role.brief.publishing.publish", - brief_path_name: "publish.md", - }, -]; - -const GAME_CREATOR_AGENT_GROUP_DEFINITIONS: [AgentGroupDefinition; 6] = [ - AgentGroupDefinition { - id: "design", - label: "策划组", - role: "Director + Gameplay", - brief_path_name: "design.md", - roles: &DESIGN_AGENT_ROLES, - }, - AgentGroupDefinition { - id: "balance", - label: "数值组", - role: "Director + Difficulty", - brief_path_name: "balance.md", - roles: &BALANCE_AGENT_ROLES, - }, - AgentGroupDefinition { - id: "art", - label: "美术组", - role: "Director + Asset + Polish", - brief_path_name: "art.md", - roles: &ART_AGENT_ROLES, - }, - AgentGroupDefinition { - id: "audio", - label: "音乐组", - role: "Director + SFX", - brief_path_name: "audio.md", - roles: &AUDIO_AGENT_ROLES, - }, - AgentGroupDefinition { - id: "code", - label: "程序组", - role: "Director + Code + Review + Preview + Playtest", - brief_path_name: "code.md", - roles: &CODE_AGENT_ROLES, - }, - AgentGroupDefinition { - id: "publishing", - label: "运营组", - role: "Director + Publish", - brief_path_name: "publishing.md", - roles: &PUBLISHING_AGENT_ROLES, - }, -]; struct GameCreatorLlmAgentStatusDefinition { agent_id: String, @@ -1510,7 +1319,7 @@ fn game_creator_llm_agent_status_definitions() -> Vec Self { + let directory = tempfile::tempdir().expect("temporary bundle directory"); + for (section_id, relative_path) in SECTION_FILES { + let path = directory.path().join(relative_path); + fs::create_dir_all(path.parent().expect("section parent")) + .expect("create section directory"); + fs::write(&path, format!("SECTION:{section_id}\n")).expect("write section"); + } + let fixture = Self { + directory, + manifest: valid_manifest(), + }; + fixture.write_manifest(); + fixture + } + + fn root(&self) -> &Path { + self.directory.path() + } + + fn manifest_path(&self) -> PathBuf { + self.root().join("manifest.json") + } + + fn write_manifest(&self) { + fs::write( + self.manifest_path(), + serde_json::to_vec_pretty(&self.manifest).expect("serialize manifest"), + ) + .expect("write manifest"); + } + + fn compile(&self) -> Result { + compile_manifest(&self.manifest_path()) + } +} + +fn valid_manifest() -> Value { + json!({ + "schemaVersion": 1, + "id": "test.agent-runtime", + "version": "2026-08-04.test", + "sections": SECTION_FILES + .iter() + .map(|(id, path)| ((*id).to_string(), json!(path))) + .collect::>(), + "compositions": { + "runtime": [ + "$header", + "common", + "$isolatedAgentTemplates", + "isolatedAgentContract", + "$platform" + ], + "supervisor": [ + "$base", + "supervisorIntro", + "$visualContract", + "supervisorPlaybook", + "supervisorClaimGate", + "supervisorRepair" + ] + }, + "variants": { + "platform": { + "default": "platformDefault", + "linux": "platformLinux" + }, + "visualContract": { + "editorConfigured": "supervisorVisualWithEditor", + "editorUnavailable": "supervisorVisualWithoutEditor" + } + }, + "roleOverlays": [ + { + "agentId": "code-prototype", + "rootSource": "project-supervisor-game-chat", + "sections": ["codePrototypeGameChat"] + } + ], + "agentCatalog": { + "supervisor": { + "id": "supervisor", + "label": "项目总控", + "role": "Project Supervisor", + "briefPathName": "project-supervisor.md", + "roles": [ + { + "id": "project-supervisor", + "role": "Project Supervisor", + "taskId": "project-supervisor", + "toolId": "agent.runtime.project-supervisor", + "briefPathName": "project-supervisor.md" + } + ] + }, + "groups": [ + { + "id": "code", + "label": "程序组", + "role": "Director + Code", + "briefPathName": "code.md", + "roles": [ + { + "id": "director", + "role": "Director", + "taskId": "code-director", + "toolId": "agent.role.brief.code.director", + "briefPathName": "director.md" + }, + { + "id": "code", + "role": "Code", + "taskId": "code-prototype", + "toolId": "agent.role.brief.code.code", + "briefPathName": "code.md" + } + ] + } + ] + } + }) +} + +fn compile_after(mutator: impl FnOnce(&mut Value)) -> Result<(), String> { + let mut fixture = Fixture::new(); + mutator(&mut fixture.manifest); + fixture.write_manifest(); + fixture.compile().map(|_| ()) +} + +fn assert_compile_error(mutator: impl FnOnce(&mut Value), expected: &str) { + let error = compile_after(mutator).expect_err("manifest must fail closed"); + assert!( + error.contains(expected), + "expected error containing {expected:?}, got {error:?}" + ); +} + +#[test] +fn compiles_manifest_in_declared_order_and_emits_all_dependencies() { + let fixture = Fixture::new(); + let compiled = fixture.compile().expect("compile valid manifest"); + assert!(compiled.rust_source.contains( + "pub(crate) const RUNTIME_PROMPT_RUNTIME_COMPOSITION: &[&str] = &[\"$header\", \"common\", \"$isolatedAgentTemplates\", \"isolatedAgentContract\", \"$platform\"];" + )); + assert!(compiled.rust_source.contains( + "pub(crate) const RUNTIME_PROMPT_SUPERVISOR_COMPOSITION: &[&str] = &[\"$base\", \"supervisorIntro\", \"$visualContract\", \"supervisorPlaybook\", \"supervisorClaimGate\", \"supervisorRepair\"];" + )); + assert!(compiled + .rust_source + .contains("static CODE_AGENT_ROLES: [AgentRoleDefinition; 2]")); + assert_eq!( + compiled + .specialist_nodes + .iter() + .map(|node| ( + node.task_id.as_str(), + node.group_id.as_str(), + node.role.as_str() + )) + .collect::>(), + BTreeSet::from([ + ("code-director", "code", "Director"), + ("code-prototype", "code", "Code"), + ]) + ); + + let dependencies = compiled + .dependencies + .iter() + .map(|path| { + path.strip_prefix(fixture.root()) + .expect("dependency below bundle root") + .to_string_lossy() + .replace('\\', "/") + }) + .collect::>(); + let expected = std::iter::once("manifest.json".to_string()) + .chain(SECTION_FILES.iter().map(|(_, path)| (*path).to_string())) + .collect::>(); + assert_eq!(dependencies, expected); +} + +#[test] +fn rejects_malformed_unknown_and_unsupported_manifests() { + let fixture = Fixture::new(); + fs::write(fixture.manifest_path(), b"{").expect("write malformed manifest"); + assert!(fixture + .compile() + .expect_err("malformed manifest must fail") + .contains("解析 Prompt Bundle manifest 失败")); + + assert_compile_error( + |manifest| { + manifest["unexpectedRootField"] = json!(true); + }, + "unknown field", + ); + assert_compile_error( + |manifest| manifest["schemaVersion"] = json!(2), + "schemaVersion 不受支持", + ); +} + +#[test] +fn rejects_duplicate_section_keys_and_paths() { + let fixture = Fixture::new(); + let raw = serde_json::to_string(&fixture.manifest).expect("serialize manifest"); + let raw = raw.replacen( + "\"sections\":{", + "\"sections\":{\"common\":\"common.md\",", + 1, + ); + fs::write(fixture.manifest_path(), raw).expect("write duplicate section manifest"); + assert!(fixture + .compile() + .expect_err("duplicate section key must fail") + .contains("duplicate section id: common")); + + assert_compile_error( + |manifest| { + manifest["sections"]["isolatedAgentContract"] = json!("common.md"); + }, + "section 路径重复", + ); + assert_compile_error( + |manifest| { + manifest["sections"]["isolatedAgentContract"] = json!("COMMON.md"); + }, + "section 路径重复", + ); +} + +#[test] +fn rejects_nonportable_or_escaping_section_paths() { + for path in [ + "../common.md", + "/tmp/common.md", + "dir\\common.md", + "platform//default.md", + "提示.md", + "common.txt", + ] { + assert_compile_error( + |manifest| manifest["sections"]["common"] = json!(path), + "Prompt section 路径", + ); + } +} + +#[cfg(unix)] +#[test] +fn rejects_symlinked_section_files_and_intermediate_directories() { + use std::os::unix::fs::symlink; + + let fixture = Fixture::new(); + let outside = tempfile::tempdir().expect("outside directory"); + fs::write(outside.path().join("common.md"), "outside").expect("write outside section"); + fs::remove_file(fixture.root().join("common.md")).expect("remove regular section"); + symlink( + outside.path().join("common.md"), + fixture.root().join("common.md"), + ) + .expect("create section symlink"); + assert!(fixture + .compile() + .expect_err("section symlink must fail") + .contains("不得包含 symlink")); + + let mut fixture = Fixture::new(); + fs::remove_file(fixture.root().join("common.md")).expect("remove old section"); + symlink(outside.path(), fixture.root().join("linked")).expect("create directory symlink"); + fixture.manifest["sections"]["common"] = json!("linked/common.md"); + fixture.write_manifest(); + assert!(fixture + .compile() + .expect_err("intermediate symlink must fail") + .contains("不得包含 symlink")); +} + +#[test] +fn rejects_missing_empty_and_non_utf8_sections() { + let fixture = Fixture::new(); + fs::remove_file(fixture.root().join("common.md")).expect("remove section"); + assert!(fixture + .compile() + .expect_err("missing section must fail") + .contains("读取 Prompt section 路径失败")); + + let fixture = Fixture::new(); + fs::write(fixture.root().join("common.md"), " \n").expect("write empty section"); + assert!(fixture + .compile() + .expect_err("empty section must fail") + .contains("Prompt section 不能为空")); + + let fixture = Fixture::new(); + fs::write(fixture.root().join("common.md"), [0xff, 0xfe]).expect("write invalid UTF-8"); + assert!(fixture + .compile() + .expect_err("non UTF-8 section must fail") + .contains("读取 Prompt section 失败")); +} + +#[test] +fn rejects_unknown_duplicate_and_missing_composition_items() { + assert_compile_error( + |manifest| manifest["compositions"]["runtime"][0] = json!("$unknown"), + "未知 marker", + ); + assert_compile_error( + |manifest| manifest["compositions"]["runtime"][1] = json!("missingSection"), + "未知 section", + ); + assert_compile_error( + |manifest| { + manifest["compositions"]["runtime"] = json!([ + "$header", + "common", + "common", + "$isolatedAgentTemplates", + "isolatedAgentContract", + "$platform" + ]); + }, + "包含重复项", + ); + assert_compile_error( + |manifest| { + manifest["compositions"]["runtime"] = json!([ + "common", + "$isolatedAgentTemplates", + "isolatedAgentContract", + "$platform" + ]); + }, + "缺少 marker:$header", + ); +} + +#[test] +fn rejects_invalid_variant_references() { + assert_compile_error( + |manifest| manifest["variants"]["platform"]["linux"] = json!("missingSection"), + "未知 section", + ); + assert_compile_error( + |manifest| { + manifest["variants"]["platform"]["linux"] = json!("platformDefault"); + }, + "default/linux 必须引用不同 section", + ); + assert_compile_error( + |manifest| { + manifest["variants"]["visualContract"]["editorUnavailable"] = + json!("supervisorVisualWithEditor"); + }, + "visual contract 两个变体必须引用不同 section", + ); +} + +#[test] +fn rejects_unused_registered_and_unregistered_markdown_sections() { + let mut fixture = Fixture::new(); + fs::write(fixture.root().join("unused.md"), "unused").expect("write unused section"); + fixture.manifest["sections"]["unused"] = json!("unused.md"); + fixture.write_manifest(); + assert!(fixture + .compile() + .expect_err("unused registered section must fail") + .contains("未被任何 composition 使用")); + + let fixture = Fixture::new(); + fs::write(fixture.root().join("orphan.md"), "orphan").expect("write orphan section"); + assert!(fixture + .compile() + .expect_err("orphan Markdown must fail") + .contains("未登记的 Markdown section")); +} + +#[test] +fn rejects_invalid_role_overlays() { + assert_compile_error( + |manifest| manifest["roleOverlays"][0]["agentId"] = json!("unknown-agent"), + "未知 agentId", + ); + assert_compile_error( + |manifest| manifest["roleOverlays"][0]["sections"] = json!([]), + "sections 不能为空", + ); + assert_compile_error( + |manifest| manifest["roleOverlays"][0]["sections"] = json!(["missingSection"]), + "未知 section", + ); + assert_compile_error( + |manifest| { + manifest["roleOverlays"][0]["sections"] = + json!(["codePrototypeGameChat", "codePrototypeGameChat"]); + }, + "重复 section", + ); + assert_compile_error( + |manifest| { + let overlay = manifest["roleOverlays"][0].clone(); + manifest["roleOverlays"] = json!([overlay.clone(), overlay]); + }, + "role overlay 重复", + ); + assert_compile_error( + |manifest| { + manifest["roleOverlays"] = json!([ + { + "agentId": "code-prototype", + "rootSource": null, + "sections": ["codePrototypeGameChat"] + }, + { + "agentId": "code-prototype", + "rootSource": "project-supervisor-game-chat", + "sections": ["codePrototypeGameChat"] + } + ]); + }, + "selector 重叠", + ); +} + +#[test] +fn rejects_invalid_agent_catalogs() { + assert_compile_error( + |manifest| manifest["agentCatalog"]["supervisor"]["roles"] = json!([]), + "必须且只能包含一个 role", + ); + assert_compile_error( + |manifest| manifest["agentCatalog"]["groups"] = json!([]), + "agentCatalog.groups 不能为空", + ); + assert_compile_error( + |manifest| { + let duplicate = manifest["agentCatalog"]["groups"][0].clone(); + manifest["agentCatalog"]["groups"] = json!([duplicate.clone(), duplicate]); + }, + "briefPathName 重复", + ); + assert_compile_error( + |manifest| { + manifest["agentCatalog"]["groups"][0]["roles"][1]["taskId"] = json!("code-director"); + }, + "agent taskId 重复", + ); + assert_compile_error( + |manifest| { + manifest["agentCatalog"]["groups"][0]["roles"][1]["toolId"] = + json!("agent.role.brief.code.director"); + }, + "agent toolId 重复", + ); + assert_compile_error( + |manifest| { + manifest["agentCatalog"]["groups"][0]["roles"][1]["briefPathName"] = + json!("../code.md"); + }, + "briefPathName 格式无效", + ); + assert_compile_error( + |manifest| manifest["agentCatalog"]["groups"][0]["id"] = json!("1code"), + "无法生成合法 Rust 标识符", + ); + assert_compile_error( + |manifest| { + manifest["agentCatalog"]["groups"][0]["roles"][0]["role"] = json!("Prototype"); + }, + "alias 与其它 taskId 冲突", + ); + assert_compile_error( + |manifest| { + manifest["agentCatalog"]["groups"][0]["roles"][0]["role"] = json!("Same!"); + manifest["agentCatalog"]["groups"][0]["roles"][1]["role"] = json!("Same?"); + }, + "alias 重复映射", + ); +} + +#[test] +fn rejects_generated_rust_identifier_collisions() { + assert_compile_error( + |manifest| { + let mut second = manifest["agentCatalog"]["groups"][0].clone(); + manifest["agentCatalog"]["groups"][0]["id"] = json!("code-group"); + manifest["agentCatalog"]["groups"][0]["briefPathName"] = json!("code-group.md"); + second["id"] = json!("code_group"); + second["briefPathName"] = json!("code-group-two.md"); + second["roles"][0]["id"] = json!("director-two"); + second["roles"][0]["taskId"] = json!("code-director-two"); + second["roles"][0]["toolId"] = json!("agent.role.brief.code.director-two"); + second["roles"][1]["id"] = json!("code-two"); + second["roles"][1]["taskId"] = json!("code-prototype-two"); + second["roles"][1]["toolId"] = json!("agent.role.brief.code.code-two"); + manifest["agentCatalog"]["groups"] = + json!([manifest["agentCatalog"]["groups"][0].clone(), second]); + }, + "生成 Rust 标识符重复", + ); +} + +#[test] +fn production_sources_cannot_restore_parallel_prompt_or_agent_catalog_truths() { + fn walk(directory: &Path, files: &mut Vec) { + for entry in fs::read_dir(directory).expect("read source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().and_then(|value| value.to_str()) == Some("rs") { + files.push(path); + } + } + } + + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut sources = Vec::new(); + walk(&crate_root.join("src"), &mut sources); + for path in sources { + let source = fs::read_to_string(&path).expect("read Rust source"); + assert!( + !(source.contains("include_str!") && source.contains("prompts/runtime")), + "{} must consume generated Prompt Bundle APIs", + path.display() + ); + } + let main_source = fs::read_to_string(crate_root.join("src/main.rs")).expect("read main.rs"); + for forbidden in [ + "static DESIGN_AGENT_ROLES", + "static BALANCE_AGENT_ROLES", + "static ART_AGENT_ROLES", + "static AUDIO_AGENT_ROLES", + "static CODE_AGENT_ROLES", + "static PUBLISHING_AGENT_ROLES", + ] { + assert!( + !main_source.contains(forbidden), + "main.rs must not restore generated catalog symbol {forbidden}" + ); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8df741b7f..55caa740a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5938,9 +5938,9 @@ ## 2026-08-03 Agent Runtime Prompt 使用版本化 Bundle -- 决策:把 `prompt.rs` 中依赖长自然语言精确匹配的链式 `.replace()` 拆成仓库内版本化 Prompt Bundle,并用 `include_str!` 编译进 Tauri 二进制。 -- 边界:Supervisor 的角色选择、并行委派、all-join、视觉返工、claim gate 和 repair 自然语言合同进入 Bundle;Bundle 不是可执行 graph,也不是生产 Skill,正式 DAG、权限、安全门和完成合同继续由 Rust 与校验后的项目协作策略掌控。 -- 一致性:原生工具目录从 `agent_runtime_native_executable_tools()` 生成,`mcp.call` 不混入静态原生目录;MCP 工具只从当前请求的动态 catalog 暴露。manifest 版本、section 覆盖、组合顺序和既有 Prompt 合同由测试锁定。 +- 决策:把 `prompt.rs` 中依赖长自然语言精确匹配的链式 `.replace()` 拆成仓库内版本化 Prompt Bundle;`build.rs` 读取、校验并生成静态 Rust 定义编译进 Tauri 二进制,生产源码不再直接引用 `prompts/runtime` 的单个 Markdown。 +- 边界:Supervisor 的角色选择、并行委派、all-join、视觉返工、claim gate 和 repair 自然语言合同,以及 Supervisor / 六组专业 Agent 的编译期静态节点目录进入 Bundle。Bundle 不是完整可执行 graph,也不是生产 Skill;正式 DAG 依赖边、权限、安全门和完成合同继续由 Rust、`shared-contracts` 与校验后的项目协作策略掌控。 +- 一致性:manifest 是 section、组合顺序、平台 / Editor 变体、role overlay 和静态节点目录的单一来源;构建期拒绝未知字段、非法 / 重复 / symlink 路径、孤立 Markdown、未知 / 重叠 selector、节点 / alias / 生成标识符冲突,并强制专业节点 taskId / group / role 与正式 seed DAG 一致。原生工具目录仍从 `agent_runtime_native_executable_tools()` 生成,`mcp.call` 不混入静态原生目录;MCP 工具只从当前请求的动态 catalog 暴露。 ## 2026-08-03 AI 游戏生成泥点不足使用确定性中断说明 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 0be4deb28..7a613f2f4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -78,7 +78,7 @@ V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。 -2026-08-03 起,Runtime 的公共工具规划指令与 Supervisor 协作编排 playbook 拆分为版本化 Prompt Bundle,位于 `apps/ai-game-creator-shell/src-tauri/prompts/runtime/`,由 `manifest.json` 声明版本与 section,Rust 通过 `include_str!` 编译进发布二进制。Bundle 承载公共指令、隔离 Agent 合同、平台差异、角色选择、并行委派、all-join、视觉返工、claim gate 和 repair 合同;`agent_runtime_native_executable_tools()` 是原生可执行工具的权威源列表,同时供 Prompt 工具目录与 native capability registry 使用,MCP 工具只从当前请求的动态 catalog 暴露。最终 Provider 请求也必须通过显式 section 与条件组合构建,禁止依赖自然语言精确 `.replace()` 注入工具合同、平台规则或角色规则。正式任务 DAG、权限、沙箱、委派容量、持久 all-join 状态机、完成门和身份校验仍由 Rust 与经校验的 `.agent/collaboration-policy.json` 强制执行,不允许通过 Skill 或任意运行时 Prompt 覆盖绕过。 +2026-08-04 起,Runtime 的公共工具规划指令、Supervisor 协作编排 playbook、条件 overlay 和编译期静态 Agent 节点目录统一由版本化 Prompt Bundle 驱动,位于 `apps/ai-game-creator-shell/src-tauri/prompts/runtime/`。`manifest.json` 是 section 路径、组合顺序、平台 / Editor 变体、role overlay,以及 Supervisor 与六组专业 Agent 静态目录的单一来源;`build.rs` 以失败关闭方式校验 schema、引用、路径 / symlink、孤立 Markdown、selector、节点身份、旧 alias 和生成标识符,再生成 `'static + Copy` Rust 定义并编译进发布二进制。生成的专业节点 taskId / group / role 还必须在构建期与 `shared-contracts::new_game_creation_app_seed_tasks()` 强一致,防止静态目录和正式 seed DAG 漂移。Bundle 承载公共指令、隔离 Agent 合同、平台差异、角色选择、并行委派、all-join、视觉返工、claim gate 和 repair 自然语言合同;`agent_runtime_native_executable_tools()` 仍是原生可执行工具的权威源列表,同时供 Prompt 工具目录与 native capability registry 使用,MCP 工具只从当前请求的动态 catalog 暴露。最终 Provider 请求必须通过生成的 section、composition 与 overlay API 构建,禁止恢复直接 `include_str!("prompts/runtime/...")` 或依赖自然语言精确 `.replace()` 注入工具合同、平台规则或角色规则。Bundle 不是完整可执行 graph:正式 DAG 依赖边、权限、沙箱、委派容量、持久 all-join 状态机、完成门和身份校验仍由 Rust、`shared-contracts` 与经校验的 `.agent/collaboration-policy.json` 强制执行,不允许通过 Skill、外部配置或任意运行时 Prompt 覆盖绕过。 2026-07-12 起,通用开发能力的 Runtime V1.1 增量以 [`【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`](./【技术方案】AI游戏创作Agent%20Runtime%20V1.1-2026-07-12.md) 为编码级事实源。它补充仓库启动上下文、同一发布二进制独立 Runner、受限本地预览浏览器验证、动态隔离子 Agent 和真实 Provider 全链路验收;本文件中“进程内 tokio task”“首轮不预加载项目内容”和“不创建动态执行实例”的旧口径由 V1.1 明确替代,未涉及能力继续沿用本文件。