From 12da570d281b916eabb7a3e0e4a90d7e5ce1c29d Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 3 Sep 2026 10:04:48 +0000 Subject: [PATCH] =?UTF-8?q?=E7=AD=96=E5=88=92=20Agent=20Runtime=20V2=20?= =?UTF-8?q?=E5=AE=8C=E6=88=90=20P2=20=E4=BA=A7=E7=89=A9=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增单 Agent GDD 策略、版本产物和审批命令 补齐问询计数、上下文进度和输出校验边界 注册 Planning V2 Tauri 命令并纳入私有产物路径 更新 Runtime V2 技术方案与项目决策记录 --- .../src-tauri/src/agent/runtime_protocol.rs | 4 + .../runtime_protocol/planning_policy_v2.rs | 1186 +++++++++++++++++ .../runtime_protocol/planning_session_v2.rs | 1098 +++++++++++++++ .../runtime_protocol/planning_storage.rs | 2 +- .../src-tauri/src/main.rs | 4 + .../src-tauri/src/project/filesystem.rs | 12 +- .../shared-memory/decision-log.md | 14 +- ...策划会话RuntimeV2接入与旧链路退役-2026-09-03.md | 12 +- 8 files changed, 2324 insertions(+), 8 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 8a560b34b..6bc59608e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -9,7 +9,9 @@ mod models; mod planning_approval; mod planning_coordinator; mod planning_hydrate; +mod planning_policy_v2; mod planning_provider_usage; +mod planning_session_v2; mod planning_storage; mod planning_submit; mod provider_control; @@ -28,7 +30,9 @@ pub(in crate::agent) use models::*; pub(crate) use planning_approval::*; pub(crate) use planning_coordinator::*; pub(crate) use planning_hydrate::*; +pub(crate) use planning_policy_v2::*; pub(crate) use planning_provider_usage::*; +pub(crate) use planning_session_v2::*; pub(crate) use planning_storage::*; pub(crate) use planning_submit::*; pub(in crate::agent) use provider_control::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs new file mode 100644 index 000000000..5ab2adc19 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -0,0 +1,1186 @@ +use super::*; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +pub(crate) const PLAN_GDD_V2_SCHEMA_VERSION: &str = "plan-gdd.v2"; +pub(crate) const PLAN_GDD_INDEX_V2_SCHEMA_VERSION: &str = "plan-gdd-index.v2"; +pub(crate) const PLAN_APPROVAL_V2_SCHEMA_VERSION: &str = "plan-approval.v2"; +const PLAN_GDD_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v2"; +const PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.approval.v2"; +const PLAN_GDD_V2_MAX_BYTES: usize = 64 * 1024; +const PLAN_GDD_V2_MAX_VERSIONS: u32 = 128; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningQuestionV2 { + pub id: String, + pub header: String, + pub question: String, + pub options: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningQuestionOptionV2 { + pub label: String, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddDecisionInputV2 { + pub id: String, + pub topic: String, + pub state: String, + #[serde(default)] + pub answer_source: Option, + pub round: u32, + pub answer_summary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddInputV2 { + pub schema_version: String, + pub game: PlanSubmitGame, + pub decisions: Vec, + pub prototype_validation_items: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningGddV2 { + pub schema_version: String, + pub project_id: String, + pub gdd_id: String, + pub version: u32, + pub created_at_utc: String, + pub game: PlanGddGame, + pub decisions: Vec, + pub prototype_validation_items: Vec, + pub fingerprint: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PlanningGddFingerprintValueV2<'a> { + schema_version: &'a str, + project_id: &'a str, + gdd_id: &'a str, + version: u32, + created_at_utc: &'a str, + game: &'a PlanGddGame, + decisions: &'a Vec, + prototype_validation_items: &'a Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanningGddIndexV2 { + schema_version: String, + project_id: String, + latest_version: Option, + entries: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PlanningGddIndexEntryV2 { + gdd_id: String, + version: u32, + fingerprint: String, + status: String, + created_at_utc: String, + decision_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanningApprovalV2 { + pub schema_version: String, + pub project_id: String, + pub session_id: String, + pub artifact_id: String, + pub version: u32, + pub fingerprint: String, + pub decision_id: String, + pub action: String, + pub comment: Option, + pub decided_at_utc: String, + pub receipt_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DecidePlanningArtifactV2Input { + pub session_id: String, + pub artifact_id: String, + pub version: u32, + pub fingerprint: String, + pub decision_id: String, + pub action: String, + pub comment: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningApprovalCommandResultV2 { + pub approval: PlanningApprovalV2, + pub session: PlanningSessionV2, + pub current_artifact: Option, + pub replayed: bool, +} + +#[derive(Clone, Debug)] +pub(crate) enum PlanningPolicyOutputV2 { + Question(PlanningQuestionV2), + Gdd(PlanningGddInputV2), +} + +#[derive(Clone, Debug)] +pub(crate) struct PlanningPolicyPersistedV2 { + pub session: PlanningSessionV2, + pub result: PlanningTurnResultV2, + pub current_artifact: Option, +} + +fn v2_path(root: &Path, relative: &str) -> PathBuf { + root.join(".agent/planning-v2").join(relative) +} + +fn strip_json_fence(text: &str) -> &str { + let text = text.trim(); + if let Some(inner) = text.strip_prefix("```") { + let inner = inner.strip_prefix("json").unwrap_or(inner); + return inner.strip_suffix("```").unwrap_or(inner).trim(); + } + text +} + +fn json_object<'a>(value: &'a Value, label: &str) -> Result<&'a Map, String> { + value + .as_object() + .ok_or_else(|| format!("{label} 必须是 JSON object")) +} + +pub(crate) fn parse_planning_policy_output_v2( + result: &PlanningTurnResultV2, +) -> Result { + let text = result_text(result); + let value = serde_json::from_str::(strip_json_fence(&text)) + .map_err(|error| format!("PLANNING_INVALID_OUTPUT: Provider 输出不是合法 JSON:{error}"))?; + let object = json_object(&value, "策划输出")?; + let kind = object + .get("kind") + .and_then(Value::as_str) + .unwrap_or_else(|| { + if object.contains_key("question") || object.contains_key("questions") { + "question" + } else { + "gdd" + } + }); + match kind { + "question" => { + let question_value = object + .get("question") + .or_else(|| { + object + .get("questions") + .and_then(|value| value.as_array()) + .and_then(|items| items.first()) + }) + .unwrap_or(&value) + .clone(); + let question = + serde_json::from_value::(question_value).map_err(|error| { + format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}") + })?; + validate_question_v2(&question)?; + Ok(PlanningPolicyOutputV2::Question(question)) + } + "gdd" | "artifact" => { + let gdd_value = object + .get("gdd") + .or_else(|| object.get("payload")) + .unwrap_or(&value) + .clone(); + let gdd = serde_json::from_value::(gdd_value) + .map_err(|error| format!("PLANNING_INVALID_GDD: GDD 结构无效:{error}"))?; + Ok(PlanningPolicyOutputV2::Gdd(gdd)) + } + _ => Err(format!( + "PLANNING_INVALID_OUTPUT: 未知策划输出 kind:{kind}" + )), + } +} + +fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> { + let id = &question.id; + if id.len() > 32 + || !id.chars().enumerate().all(|(index, value)| { + (index == 0 && value.is_ascii_lowercase()) + || (index > 0 + && (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '_')) + }) + { + return Err("PLANNING_INVALID_QUESTION: question.id 必须是 snake_case".to_string()); + } + validate_text(&question.id, "question.id", 1, 32).map_err(|error| error.to_string())?; + validate_text(&question.header, "question.header", 1, 120) + .map_err(|error| error.to_string())?; + validate_text(&question.question, "question.question", 1, 400) + .map_err(|error| error.to_string())?; + if !(2..=4).contains(&question.options.len()) { + return Err("PLANNING_INVALID_QUESTION: options 必须有 2~4 项".to_string()); + } + let mut labels = std::collections::BTreeSet::new(); + for option in &question.options { + validate_text(&option.label, "question.options.label", 1, 80) + .map_err(|error| error.to_string())?; + validate_text(&option.description, "question.options.description", 1, 400) + .map_err(|error| error.to_string())?; + if !labels.insert(option.label.as_str()) { + return Err("PLANNING_INVALID_QUESTION: options.label 不能重复".to_string()); + } + } + Ok(()) +} + +fn normalize_answer_source(state: &str, source: Option) -> Result { + let fallback = match state { + "confirmed" => "user_freeform".to_string(), + _ => "default".to_string(), + }; + let source = source + .map(|value| value.trim().to_string()) + .filter(|value| { + matches!( + value.as_str(), + "user_option" | "user_freeform" | "user_revision" | "default" + ) + }) + .unwrap_or(fallback); + Ok(source) +} + +fn planning_gdd_game_from_input(game: &PlanSubmitGame) -> PlanGddGame { + PlanGddGame { + title: game.title.clone(), + genre: game.genre.clone(), + art_style: game.art_style.clone(), + one_liner: game.one_liner.clone(), + pillars: game + .pillars + .iter() + .map(|value| PlanPillar { + name: value.name.clone(), + player_feel: value.player_feel.clone(), + mechanism: value.mechanism.clone(), + decision_state: value.decision_state.clone(), + basis: None, + }) + .collect(), + core_loop: game.core_loop.clone(), + target_users: game.target_users.clone(), + platform_facts: fixed_plan_platform_facts(), + mvp_systems: game + .mvp_systems + .iter() + .map(|value| PlanMvpSystem { + system: value.system.clone(), + minimal_function: value.minimal_function.clone(), + why_required: value.why_required.clone(), + verify_method: value.verify_method.clone(), + decision_state: value.decision_state.clone(), + basis: None, + }) + .collect(), + out_of_scope: game.out_of_scope.clone(), + creator_tips: game.creator_tips.clone(), + } +} + +fn validate_v2_decisions( + decisions: &[PlanningGddDecisionInputV2], + prototype_items: &[PlanPrototypeValidationItem], +) -> Result<(), String> { + if !(1..=64).contains(&decisions.len()) { + return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); + } + let mut ids = std::collections::BTreeSet::new(); + let mut prototype_ids = std::collections::BTreeSet::new(); + for (index, decision) in decisions.iter().enumerate() { + if decision.id != "initial-request" + && (decision.id.len() > 64 + || !decision.id.chars().enumerate().all(|(position, value)| { + (position == 0 && value.is_ascii_lowercase()) + || (position > 0 + && (value.is_ascii_lowercase() + || value.is_ascii_digit() + || value == '-')) + })) + { + return Err(format!( + "PLANNING_INVALID_GDD: decisions[{index}].id 必须是 kebab-case" + )); + } + validate_text(&decision.id, &format!("decisions[{index}].id"), 1, 64) + .map_err(|error| error.to_string())?; + validate_text( + &decision.topic, + &format!("decisions[{index}].topic"), + 1, + 120, + ) + .map_err(|error| error.to_string())?; + if !matches!( + decision.state.as_str(), + "confirmed" | "default_pending" | "prototype_pending" + ) { + return Err(format!( + "PLANNING_INVALID_GDD: decisions[{index}].state 无效" + )); + } + normalize_answer_source(&decision.state, decision.answer_source.clone())?; + validate_text( + &decision.answer_summary, + &format!("decisions[{index}].answerSummary"), + 1, + if decision.id == "initial-request" { + PLAN_INITIAL_REQUEST_MAX_CHARS + } else { + PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + }, + ) + .map_err(|error| error.to_string())?; + if !ids.insert(decision.id.as_str()) { + return Err(format!( + "PLANNING_INVALID_GDD: decisions.id 不能重复:{}", + decision.id + )); + } + if decision.state == "prototype_pending" { + prototype_ids.insert(decision.id.as_str()); + } + } + if decisions.first().map(|value| value.id.as_str()) != Some("initial-request") { + return Err("PLANNING_INVALID_GDD: decisions 第一项必须是 initial-request".to_string()); + } + let first = &decisions[0]; + if first.state != "confirmed" || first.round != 0 { + return Err("PLANNING_INVALID_GDD: initial-request 必须 confirmed 且 round=0".to_string()); + } + if prototype_items.len() > 3 || prototype_ids.len() != prototype_items.len() { + return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 3 项".to_string()); + } + let mut item_ids = std::collections::BTreeSet::new(); + for item in prototype_items { + if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) { + return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string()); + } + if !item.id.chars().enumerate().all(|(position, value)| { + (position == 0 && value.is_ascii_lowercase()) + || (position > 0 + && (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '-')) + }) { + return Err( + "PLANNING_INVALID_GDD: prototypeValidationItems.id 必须是 kebab-case".to_string(), + ); + } + validate_text(&item.id, "prototypeValidationItems.id", 1, 64) + .map_err(|error| error.to_string())?; + for (label, value) in [ + ("question", &item.question), + ("microPrototype", &item.micro_prototype), + ("observation", &item.observation), + ("passCriterion", &item.pass_criterion), + ] { + validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 400) + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +fn build_gdd_v2( + project_id: &str, + version: u32, + input: PlanningGddInputV2, +) -> Result { + validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(input.clone()))?; + let game = planning_gdd_game_from_input(&input.game); + validate_plan_game(&game).map_err(|error| format!("PLANNING_INVALID_GDD: {}", error))?; + let decisions = input + .decisions + .into_iter() + .map(|value| { + Ok(PlanDecision { + id: value.id, + topic: value.topic, + state: value.state.clone(), + answer_source: normalize_answer_source(&value.state, value.answer_source)?, + round: value.round, + answer_summary: value.answer_summary, + basis: None, + }) + }) + .collect::, String>>()?; + let mut gdd = PlanningGddV2 { + schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(), + project_id: project_id.to_string(), + gdd_id: format!("gdd-{}", Uuid::new_v4()), + version, + created_at_utc: current_plan_timestamp_utc(), + game, + decisions, + prototype_validation_items: input.prototype_validation_items, + fingerprint: String::new(), + }; + gdd.fingerprint = fingerprint_gdd_v2(&gdd)?; + Ok(gdd) +} + +pub(crate) fn validate_planning_policy_output_v2( + output: &PlanningPolicyOutputV2, +) -> Result<(), String> { + match output { + PlanningPolicyOutputV2::Question(question) => validate_question_v2(question), + PlanningPolicyOutputV2::Gdd(input) => { + if input.schema_version != PLAN_GDD_V2_SCHEMA_VERSION { + return Err(format!( + "PLANNING_INVALID_GDD: schemaVersion 必须是 {PLAN_GDD_V2_SCHEMA_VERSION}" + )); + } + validate_v2_decisions(&input.decisions, &input.prototype_validation_items)?; + let game = planning_gdd_game_from_input(&input.game); + validate_plan_game(&game).map_err(|error| format!("PLANNING_INVALID_GDD: {error}")) + } + } +} + +fn fingerprint_gdd_v2(value: &PlanningGddV2) -> Result { + typed_serde_fingerprint( + PLAN_GDD_V2_FINGERPRINT_DOMAIN, + &PlanningGddFingerprintValueV2 { + schema_version: &value.schema_version, + project_id: &value.project_id, + gdd_id: &value.gdd_id, + version: value.version, + created_at_utc: &value.created_at_utc, + game: &value.game, + decisions: &value.decisions, + prototype_validation_items: &value.prototype_validation_items, + }, + ) + .map_err(|error| error.to_string()) +} + +fn validate_gdd_v2(value: &PlanningGddV2, project_id: &str) -> Result<(), String> { + if value.schema_version != PLAN_GDD_V2_SCHEMA_VERSION + || value.project_id != project_id + || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) + || !is_typed_fingerprint(&value.fingerprint) + { + return Err("PLANNING_INVALID_GDD: V2 GDD 身份或 schema 无效".to_string()); + } + validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId").map_err(|error| error.to_string())?; + validate_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?; + validate_plan_game(&value.game).map_err(|error| error.to_string())?; + let expected = fingerprint_gdd_v2(value)?; + if expected != value.fingerprint { + return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string()); + } + Ok(()) +} + +fn canonical_gdd_v2_bytes(value: &PlanningGddV2) -> Result, String> { + let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; + if bytes.len() > PLAN_GDD_V2_MAX_BYTES { + return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); + } + Ok(bytes) +} + +fn create_v2_immutable_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> { + let path = v2_path(root, relative); + let parent = path + .parent() + .ok_or_else(|| "V2 文件缺少父目录".to_string())?; + ensure_game_creator_private_directory_tree(parent, "Planning V2 目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Planning V2 目录")?; + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + { + Ok(mut file) => { + harden_new_game_creator_private_path(&path, false, "Planning V2 文件")?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| { + format!("写入 Planning V2 文件失败:{}: {error}", path.display()) + })?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 文件")?; + let existing = fs::read(&path).map_err(|read_error| { + format!( + "读取已存在 Planning V2 文件失败:{}: {read_error}", + path.display() + ) + })?; + if existing == bytes { + Ok(()) + } else { + Err(format!( + "Planning V2 不可变文件已存在且内容不同:{}", + path.display() + )) + } + } + Err(error) => Err(format!( + "创建 Planning V2 文件失败:{}: {error}", + path.display() + )), + } +} + +fn read_gdd_v2(root: &Path, version: u32) -> Result { + let path = v2_path(root, &format!("gdd.v{version}.json")); + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD")?; + let metadata = + fs::metadata(&path).map_err(|error| format!("读取 V2 GDD 元数据失败:{error}"))?; + if metadata.len() > PLAN_GDD_V2_MAX_BYTES as u64 { + return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); + } + let bytes = fs::read(&path) + .map_err(|error| format!("读取 V2 GDD 失败:{}: {error}", path.display()))?; + let gdd = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 GDD 失败:{error}"))?; + let project_id = read_manifest_for_project(root)?.project_id; + validate_gdd_v2(&gdd, &project_id)?; + if gdd.version != version { + return Err("PLANNING_INVALID_GDD: GDD 文件名与 version 不一致".to_string()); + } + Ok(gdd) +} + +fn read_index_v2(root: &Path) -> Result { + let path = v2_path(root, "index.json"); + let project_id = read_manifest_for_project(root)?.project_id; + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD index")?; + match fs::read(&path) { + Ok(bytes) => { + let index = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 GDD index 失败:{error}"))?; + if index.schema_version != PLAN_GDD_INDEX_V2_SCHEMA_VERSION + || index.project_id != project_id + { + return Err("PLANNING_INVALID_INDEX: V2 GDD index 身份无效".to_string()); + } + Ok(index) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PlanningGddIndexV2 { + schema_version: PLAN_GDD_INDEX_V2_SCHEMA_VERSION.to_string(), + project_id, + latest_version: None, + entries: Vec::new(), + }), + Err(error) => Err(format!( + "读取 V2 GDD index 失败:{}: {error}", + path.display() + )), + } +} + +fn write_index_v2(root: &Path, index: &PlanningGddIndexV2) -> Result<(), String> { + let bytes = + serde_json::to_vec(index).map_err(|error| format!("序列化 V2 GDD index 失败:{error}"))?; + if bytes.len() > 256 * 1024 { + return Err("PLANNING_SIZE_LIMIT: V2 GDD index 超过 256 KiB".to_string()); + } + write_game_creator_private_file( + &v2_path(root, "index.json"), + &bytes, + "Planning V2 GDD index", + ) +} + +fn update_index_v2( + root: &Path, + gdd: &PlanningGddV2, + status: &str, + decision_id: Option, +) -> Result<(), String> { + let mut index = read_index_v2(root)?; + if let Some(entry) = index + .entries + .iter_mut() + .find(|entry| entry.version == gdd.version) + { + entry.status = status.to_string(); + entry.decision_id = decision_id; + } else { + index.entries.push(PlanningGddIndexEntryV2 { + gdd_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + status: status.to_string(), + created_at_utc: gdd.created_at_utc.clone(), + decision_id, + }); + } + index.entries.sort_by_key(|entry| entry.version); + index.latest_version = index.entries.last().map(|entry| entry.version); + write_index_v2(root, &index) +} + +fn index_status_v2(root: &Path, version: u32) -> Result { + Ok(read_index_v2(root)? + .entries + .into_iter() + .find(|entry| entry.version == version) + .map(|entry| entry.status) + .unwrap_or_else(|| "ready_for_approval".to_string())) +} + +fn markdown_escape_v2(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('`', "\\`") + .replace('*', "\\*") + .replace('_', "\\_") + .replace('|', "\\|") +} + +fn render_gdd_v2_markdown(gdd: &PlanningGddV2, status: &str) -> Result { + validate_gdd_v2(gdd, &gdd.project_id)?; + let mut output = format!( + "# {}\n\n> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n\n", + markdown_escape_v2(&gdd.game.title), + gdd.version, + markdown_escape_v2(status), + gdd.gdd_id, + gdd.fingerprint + ); + output.push_str("## 决定状态\n\n"); + for decision in &gdd.decisions { + output.push_str(&format!( + "- **{}**({},{},第 {} 轮):{}\n", + markdown_escape_v2(&decision.topic), + decision.state, + decision.answer_source, + decision.round, + markdown_escape_v2(&decision.answer_summary) + )); + } + output.push_str(&format!( + "\n## 一句话描述\n\n{}\n\n", + markdown_escape_v2(&gdd.game.one_liner) + )); + output.push_str("## 核心循环\n\n"); + for (index, step) in gdd.game.core_loop.iter().enumerate() { + output.push_str(&format!("{}. {}\n", index + 1, markdown_escape_v2(step))); + } + output.push_str("\n## MVP 系统\n\n"); + for system in &gdd.game.mvp_systems { + output.push_str(&format!( + "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n\n", + markdown_escape_v2(&system.system), + markdown_escape_v2(&system.minimal_function), + markdown_escape_v2(&system.why_required), + markdown_escape_v2(&system.verify_method) + )); + } + output.push_str("## 制作边界\n\n"); + for item in &gdd.game.out_of_scope { + output.push_str(&format!("- {}\n", markdown_escape_v2(item))); + } + if !gdd.prototype_validation_items.is_empty() { + output.push_str("\n## 原型验证项\n\n"); + for item in &gdd.prototype_validation_items { + output.push_str(&format!( + "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", + markdown_escape_v2(&item.id), + markdown_escape_v2(&item.question), + markdown_escape_v2(&item.micro_prototype), + markdown_escape_v2(&item.observation), + markdown_escape_v2(&item.pass_criterion) + )); + } + } + if output.len() > PLAN_FAST_GDD_MAX_BYTES { + return Err("PLANNING_SIZE_LIMIT: Fast GDD Markdown 超过大小上限".to_string()); + } + Ok(output) +} + +fn artifact_value_v2(gdd: &PlanningGddV2, status: &str) -> Value { + serde_json::json!({ + "artifactId": gdd.gdd_id, + "kind": "gdd", + "version": gdd.version, + "status": status, + "fingerprint": gdd.fingerprint, + "payload": gdd, + }) +} + +pub(crate) fn current_planning_artifact_v2(root: &Path) -> Result, String> { + let Some(session) = read_planning_session_v2(root)? else { + return Ok(None); + }; + let Some(version) = session.current_artifact_version else { + return Ok(None); + }; + let version = u32::try_from(version) + .map_err(|_| "PLANNING_INVALID_GDD: artifact version 超出范围".to_string())?; + let gdd = read_gdd_v2(root, version)?; + Ok(Some(artifact_value_v2( + &gdd, + &index_status_v2(root, version)?, + ))) +} + +pub(crate) fn persist_planning_policy_output_v2( + root: &Path, + client_turn_id: &str, + session_id: &str, + turn_index: u64, + elapsed_seconds: f64, + output: PlanningPolicyOutputV2, +) -> Result { + let _lock = acquire_project_write_lock(root, "planning.v2.policy.persist")?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if session.session_id != session_id || session.turn_index != turn_index { + return Err("Planning V2 Session 回合身份发生变化".to_string()); + } + let project_id = read_manifest_for_project(root)?.project_id; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + match output { + PlanningPolicyOutputV2::Question(question) => { + let payload = serde_json::to_value(&question).map_err(|error| error.to_string())?; + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "question".to_string(), + payload: payload.clone(), + }, + )?; + session.question_count = session.question_count.saturating_add(1); + session.current_question = Some(payload.clone()); + session.status = "awaiting_user".to_string(); + session.processing_seconds += elapsed_seconds.max(0.0); + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = None; + write_planning_session_v2(root, &session)?; + Ok(PlanningPolicyPersistedV2 { + session, + result: PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "question".to_string(), + payload, + }, + current_artifact: None, + }) + } + PlanningPolicyOutputV2::Gdd(input) => { + let next_version = session + .current_artifact_version + .map(|value| value.saturating_add(1)) + .unwrap_or(1); + let next_version = u32::try_from(next_version) + .map_err(|_| "PLANNING_VERSION_LIMIT: GDD 版本超出范围".to_string())?; + let gdd = build_gdd_v2(&project_id, next_version, input)?; + validate_gdd_v2(&gdd, &project_id)?; + let bytes = canonical_gdd_v2_bytes(&gdd)?; + create_v2_immutable_file(root, &format!("gdd.v{}.json", gdd.version), &bytes)?; + update_index_v2(root, &gdd, "ready_for_approval", None)?; + let markdown = render_gdd_v2_markdown(&gdd, "ready_for_approval")?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + let artifact = artifact_value_v2(&gdd, "ready_for_approval"); + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "artifact".to_string(), + payload: artifact.clone(), + }, + )?; + session.current_artifact_version = Some(u64::from(gdd.version)); + session.current_question = None; + session.status = "awaiting_approval".to_string(); + session.processing_seconds += elapsed_seconds.max(0.0); + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = None; + write_planning_session_v2(root, &session)?; + Ok(PlanningPolicyPersistedV2 { + session, + result: PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "artifact".to_string(), + payload: artifact.clone(), + }, + current_artifact: Some(artifact), + }) + } + } +} + +fn approval_fingerprint(value: &PlanningApprovalV2) -> Result { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Fingerprint<'a> { + schema_version: &'a str, + project_id: &'a str, + session_id: &'a str, + artifact_id: &'a str, + version: u32, + fingerprint: &'a str, + decision_id: &'a str, + action: &'a str, + comment: &'a Option, + decided_at_utc: &'a str, + } + typed_serde_fingerprint( + PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN, + &Fingerprint { + schema_version: &value.schema_version, + project_id: &value.project_id, + session_id: &value.session_id, + artifact_id: &value.artifact_id, + version: value.version, + fingerprint: &value.fingerprint, + decision_id: &value.decision_id, + action: &value.action, + comment: &value.comment, + decided_at_utc: &value.decided_at_utc, + }, + ) + .map_err(|error| error.to_string()) +} + +fn validate_approval_v2(value: &PlanningApprovalV2, project_id: &str) -> Result<(), String> { + if value.schema_version != PLAN_APPROVAL_V2_SCHEMA_VERSION + || value.project_id != project_id + || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) + || !matches!(value.action.as_str(), "approve" | "revise" | "reject") + || !is_typed_fingerprint(&value.fingerprint) + || !is_typed_fingerprint(&value.receipt_fingerprint) + { + return Err("PLANNING_INVALID_APPROVAL: V2 审批记录身份或 schema 无效".to_string()); + } + validate_uuid_prefixed(&value.artifact_id, "gdd-", "artifactId") + .map_err(|error| error.to_string())?; + validate_text(&value.decision_id, "decisionId", 1, 128).map_err(|error| error.to_string())?; + validate_timestamp(&value.decided_at_utc, "decidedAtUtc").map_err(|error| error.to_string())?; + if matches!(value.action.as_str(), "revise" | "reject") && value.comment.is_none() { + return Err("PLANNING_INVALID_APPROVAL: 修改或退回审批记录必须填写 comment".to_string()); + } + if value + .comment + .as_deref() + .is_some_and(|comment| comment.chars().count() > 2_000) + { + return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); + } + let expected = approval_fingerprint(value)?; + if expected != value.receipt_fingerprint { + return Err("PLANNING_INVALID_APPROVAL: receiptFingerprint 不匹配".to_string()); + } + Ok(()) +} + +fn read_approval_v2(root: &Path, version: u32) -> Result, String> { + let path = v2_path(root, &format!("approvals/v{version}.json")); + prepare_game_creator_private_path_for_read(&path, false, "Planning V2 审批记录")?; + match fs::read(&path) { + Ok(bytes) => { + let approval = serde_json::from_slice::(&bytes) + .map_err(|error| format!("解析 V2 审批记录失败:{error}"))?; + let project_id = read_manifest_for_project(root)?.project_id; + validate_approval_v2(&approval, &project_id)?; + Ok(Some(approval)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("读取 V2 审批记录失败:{}: {error}", path.display())), + } +} + +fn write_approval_v2(root: &Path, approval: &PlanningApprovalV2) -> Result<(), String> { + let bytes = + serde_json::to_vec(approval).map_err(|error| format!("序列化 V2 审批记录失败:{error}"))?; + create_v2_immutable_file( + root, + &format!("approvals/v{}.json", approval.version), + &bytes, + ) +} + +pub(crate) fn decide_planning_artifact_v2_at( + root: &Path, + input: DecidePlanningArtifactV2Input, +) -> Result { + let _lock = acquire_project_write_lock(root, "planning.v2.approval")?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + let project_id = read_manifest_for_project(root)?.project_id; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if session.session_id != input.session_id { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { + return Err("PLANNING_INVALID_APPROVAL: action 必须是 approve/revise/reject".to_string()); + } + let comment = input + .comment + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if input.decision_id.trim().is_empty() || input.decision_id.chars().count() > 128 { + return Err("PLANNING_INVALID_APPROVAL: decisionId 不能为空且长度不能超过 128".to_string()); + } + if comment + .as_deref() + .is_some_and(|value| value.chars().count() > 2_000) + { + return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); + } + if matches!(input.action.as_str(), "revise" | "reject") && comment.is_none() { + return Err("PLANNING_INVALID_APPROVAL: 修改或退回必须填写 comment".to_string()); + } + let gdd = read_gdd_v2(root, input.version)?; + if gdd.gdd_id != input.artifact_id + || gdd.fingerprint != input.fingerprint + || session.current_artifact_version != Some(u64::from(input.version)) + { + return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string()); + } + if let Some(existing) = read_approval_v2(root, input.version)? { + if existing.decision_id == input.decision_id + && existing.action == input.action + && existing.comment == comment + { + let status = match existing.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }; + update_index_v2(root, &gdd, status, Some(existing.decision_id.clone()))?; + let markdown = render_gdd_v2_markdown(&gdd, status)?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + if session.status != status { + session.status = status.to_string(); + if status == "revision_requested" { + session.revision_count = session.revision_count.saturating_add(1); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(root, &session)?; + } + return Ok(PlanningApprovalCommandResultV2 { + approval: existing, + session, + current_artifact: Some(artifact_value_v2( + &gdd, + &index_status_v2(root, input.version)?, + )), + replayed: true, + }); + } + return Err("PLANNING_APPROVAL_CONFLICT: 当前 GDD 已存在不同审批决定".to_string()); + } + let mut approval = PlanningApprovalV2 { + schema_version: PLAN_APPROVAL_V2_SCHEMA_VERSION.to_string(), + project_id: project_id.clone(), + session_id: session.session_id.clone(), + artifact_id: gdd.gdd_id.clone(), + version: gdd.version, + fingerprint: gdd.fingerprint.clone(), + decision_id: input.decision_id.clone(), + action: input.action.clone(), + comment: comment.clone(), + decided_at_utc: current_plan_timestamp_utc(), + receipt_fingerprint: String::new(), + }; + approval.receipt_fingerprint = approval_fingerprint(&approval)?; + write_approval_v2(root, &approval)?; + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: input.decision_id.clone(), + turn_index: session.turn_index, + at_utc: approval.decided_at_utc.clone(), + role: "user".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": format!("审批:{} {}", input.action, comment.as_deref().unwrap_or_default()), "approvalAction": input.action, "artifactId": gdd.gdd_id, "version": gdd.version}), + }, + )?; + update_index_v2( + root, + &gdd, + match input.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }, + Some(input.decision_id.clone()), + )?; + let status = match input.action.as_str() { + "approve" => "approved", + "reject" => "rejected", + _ => "revision_requested", + }; + let markdown = render_gdd_v2_markdown(&gdd, status)?; + write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) + .map_err(|error| error.to_string())?; + session.status = status.to_string(); + session.current_question = None; + if input.action == "revise" { + session.revision_count = session.revision_count.saturating_add(1); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(root, &session)?; + Ok(PlanningApprovalCommandResultV2 { + approval, + session, + current_artifact: Some(artifact_value_v2(&gdd, status)), + replayed: false, + }) +} + +#[tauri::command] +pub(crate) fn decide_planning_artifact_v2( + project_path: String, + session_id: String, + artifact_id: String, + version: u32, + fingerprint: String, + decision_id: String, + action: String, + comment: Option, +) -> Result { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + enforce_project_permission_policy(&root, "conversation.write")?; + decide_planning_artifact_v2_at( + &root, + DecidePlanningArtifactV2Input { + session_id, + artifact_id, + version, + fingerprint, + decision_id, + action, + comment, + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_answer_source_is_normalized_without_blocking() { + assert_eq!( + normalize_answer_source("confirmed", None).unwrap(), + "user_freeform" + ); + assert_eq!( + normalize_answer_source("default_pending", None).unwrap(), + "default" + ); + } + + #[test] + fn question_validation_allows_two_to_four_options() { + let question = PlanningQuestionV2 { + id: "core_loop".to_string(), + header: "当前要决定:核心循环".to_string(), + question: "玩家主要做什么?".to_string(), + options: vec![ + PlanningQuestionOptionV2 { + label: "方案 A".to_string(), + description: "先设计再验证".to_string(), + }, + PlanningQuestionOptionV2 { + label: "方案 B".to_string(), + description: "先战斗再调整".to_string(), + }, + ], + }; + assert!(validate_question_v2(&question).is_ok()); + } + + #[test] + fn parses_v2_gdd_without_old_supervisor_identity_fields() { + let payload = serde_json::json!({ + "kind": "gdd", + "gdd": { + "schemaVersion": "plan-gdd.v2", + "game": { + "title": "萤火守夜者", + "genre": {"primary": "轻策略", "fusion": null}, + "artStyle": { + "visualType": "手绘平面", + "keywords": ["暖色", "剪影", "纸感"], + "moodAndColor": "夜色中的暖黄灯火", + "mvpArtBoundary": "只做可复用占位素材" + }, + "oneLiner": "玩家在短局守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点", + "pillars": [ + {"name": "取舍", "playerFeel": "每次选择都有代价", "mechanism": "有限灯火在路线与营地之间分配", "decisionState": "confirmed"}, + {"name": "重玩", "playerFeel": "想再试一次更优路线", "mechanism": "不同路线组合产生不同风险", "decisionState": "confirmed"} + ], + "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], + "targetUsers": {"coreUsers": "喜欢短局策略的玩家", "preferences": "偏好清晰反馈", "sessionLength": "10至20分钟", "referenceGames": []}, + "mvpSystems": [ + {"system": "地图", "minimalFunction": "展示当前营地与路线", "whyRequired": "承载空间选择", "verifyMethod": "玩家能走完一局", "decisionState": "confirmed"}, + {"system": "灯火", "minimalFunction": "在安全和探索间分配", "whyRequired": "承载核心取舍", "verifyMethod": "两种策略结果可区分", "decisionState": "confirmed"}, + {"system": "事件", "minimalFunction": "路线途中触发选择", "whyRequired": "提供短局变化", "verifyMethod": "重玩时结果不同", "decisionState": "confirmed"} + ], + "outOfScope": ["多人联机"], + "creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"} + }, + "decisions": [{"id": "initial-request", "topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}], + "prototypeValidationItems": [] + } + }); + let mut result = PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({"text": serde_json::to_string(&payload).unwrap()}), + }; + let parsed = parse_planning_policy_output_v2(&result).expect("parse v2 gdd"); + assert!(matches!(parsed, PlanningPolicyOutputV2::Gdd(_))); + result.payload["text"] = + serde_json::json!("{\"kind\":\"gdd\",\"gdd\":{\"schemaVersion\":\"plan-gdd.v2\"}}"); + assert!(parse_planning_policy_output_v2(&result).is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs new file mode 100644 index 000000000..3386b80b7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -0,0 +1,1098 @@ +use super::*; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; +use uuid::Uuid; + +pub(crate) const PLANNING_SESSION_V2_SCHEMA_VERSION: &str = "planning-session.v2"; +pub(crate) const PLANNING_MESSAGE_V2_SCHEMA_VERSION: &str = "planning-message.v2"; +pub(crate) const PLANNING_TURN_RESULT_V2_SCHEMA_VERSION: &str = "planning-turn-result.v2"; +pub(crate) const PLANNING_SESSION_V2_ENGINE: &str = "planning-session-v2"; +pub(crate) const PLANNING_SESSION_V2_SESSION_PATH: &str = ".agent/planning-v2/session.json"; +pub(crate) const PLANNING_SESSION_V2_CONVERSATION_PATH: &str = + ".agent/planning-v2/conversation.jsonl"; +const PLANNING_SESSION_V2_MAX_CONTEXT_CHARS: usize = 1_000_000; +const PLANNING_SESSION_V2_MAX_TEXT_CHARS: usize = 64 * 1024; +const PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES: u64 = 4 * 1024 * 1024; + +static ACTIVE_PLANNING_V2_PROJECTS: OnceLock>> = OnceLock::new(); + +fn active_planning_v2_projects() -> &'static Mutex> { + ACTIVE_PLANNING_V2_PROJECTS.get_or_init(|| Mutex::new(BTreeSet::new())) +} + +struct PlanningV2ActiveGuard { + project_id: String, +} + +impl Drop for PlanningV2ActiveGuard { + fn drop(&mut self) { + if let Ok(mut active) = active_planning_v2_projects().lock() { + active.remove(&self.project_id); + } + } +} + +fn try_acquire_planning_v2_active(project_id: &str) -> Result { + let mut active = active_planning_v2_projects() + .lock() + .map_err(|_| "Planning V2 活跃回合锁损坏".to_string())?; + if !active.insert(project_id.to_string()) { + return Err("Planning V2 当前已有回合执行中".to_string()); + } + Ok(PlanningV2ActiveGuard { + project_id: project_id.to_string(), + }) +} + +fn planning_v2_is_active(project_id: &str) -> bool { + active_planning_v2_projects() + .lock() + .map(|active| active.contains(project_id)) + .unwrap_or(false) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningCapabilitySnapshotV2 { + pub tools: Vec, + pub skills: Vec, +} + +impl Default for PlanningCapabilitySnapshotV2 { + fn default() -> Self { + Self { + tools: Vec::new(), + skills: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningErrorV2 { + pub code: String, + pub summary: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionV2 { + pub schema_version: String, + pub engine: String, + pub session_id: String, + pub project_id: String, + pub mode: String, + pub status: String, + pub turn_index: u64, + pub question_count: u64, + pub question_limit: Option, + pub revision_count: u64, + pub current_artifact_version: Option, + pub current_question: Option, + pub capabilities: PlanningCapabilitySnapshotV2, + pub processing_seconds: f64, + pub created_at_utc: String, + pub updated_at_utc: String, + pub last_error: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningMessageV2 { + pub schema_version: String, + pub message_id: String, + pub client_turn_id: String, + pub turn_index: u64, + pub at_utc: String, + pub role: String, + pub kind: String, + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningTurnResultV2 { + pub schema_version: String, + pub kind: String, + pub payload: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionCommandResultV2 { + pub session: PlanningSessionV2, + pub result: Option, + pub current_artifact: Option, + pub replayed: bool, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlanningSessionStreamEventV2 { + pub session_id: String, + pub client_turn_id: String, + pub status: String, + pub delta_text: String, + pub accumulated_text: String, + pub finish_reason: Option, + pub result: Option, + pub error: Option, +} + +#[derive(Clone, Debug)] +struct PlanningTurnStartV2 { + session: PlanningSessionV2, + context_messages: Vec, + replay: Option, +} + +fn planning_session_path(root: &Path) -> PathBuf { + root.join(PLANNING_SESSION_V2_SESSION_PATH) +} + +fn planning_conversation_path(root: &Path) -> PathBuf { + root.join(PLANNING_SESSION_V2_CONVERSATION_PATH) +} + +fn validate_client_turn_id(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.chars().count() > 128 || value.contains(['\r', '\n', '\0']) { + return Err("clientTurnId 不能为空且长度不能超过 128 个字符".to_string()); + } + Ok(value.to_string()) +} + +fn validate_prompt(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("策划输入不能为空".to_string()); + } + if value.chars().count() > PLANNING_SESSION_V2_MAX_TEXT_CHARS { + return Err("策划输入过长".to_string()); + } + Ok(value.to_string()) +} + +fn normalize_planning_input(value: Value) -> Result { + match value { + Value::String(text) => validate_prompt(&text), + Value::Object(object) => { + if let Some(text) = object.get("text").and_then(Value::as_str) { + return validate_prompt(text); + } + if let Some(input) = object.get("input").and_then(Value::as_str) { + return validate_prompt(input); + } + if let Some(label) = object.get("optionLabel").and_then(Value::as_str) { + return validate_prompt(&format!("按选项:{label}")); + } + if let Some(index) = object.get("optionIndex").and_then(Value::as_u64) { + return validate_prompt(&format!("按第 {} 个选项做", index.saturating_add(1))); + } + Err("Planning V2 input 缺少 text、input 或 optionLabel".to_string()) + } + _ => Err("Planning V2 input 必须是文本或对象".to_string()), + } +} + +fn safe_error(code: &str, detail: impl AsRef) -> PlanningErrorV2 { + PlanningErrorV2 { + code: code.to_string(), + summary: sanitize_diagnostic_message(detail.as_ref(), None), + } +} + +pub(crate) fn read_planning_session_v2(root: &Path) -> Result, String> { + let path = planning_session_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 Session")? { + return Ok(None); + } + let metadata = fs::metadata(&path) + .map_err(|error| format!("读取 Planning V2 Session 元数据失败:{error}"))?; + if metadata.len() > 64 * 1024 { + return Err("PLANNING_SESSION_INVALID: Planning V2 Session 超过大小上限".to_string()); + } + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取 Planning V2 Session 失败:{}: {error}", path.display()))?; + let session = serde_json::from_str::(&content) + .map_err(|error| format!("解析 Planning V2 Session 失败:{}: {error}", path.display()))?; + validate_planning_session_v2(&session)?; + Ok(Some(session)) +} + +fn validate_planning_session_v2(session: &PlanningSessionV2) -> Result<(), String> { + if session.schema_version != PLANNING_SESSION_V2_SCHEMA_VERSION + || session.engine != PLANNING_SESSION_V2_ENGINE + || session.session_id.trim().is_empty() + || session.project_id.trim().is_empty() + || !matches!( + session.status.as_str(), + "idle" + | "planning" + | "awaiting_user" + | "awaiting_approval" + | "revision_requested" + | "approved" + | "rejected" + | "provider_failed" + | "stopped" + ) + || session + .capabilities + .tools + .iter() + .any(|value| value.trim().is_empty()) + || session + .capabilities + .skills + .iter() + .any(|value| value.trim().is_empty()) + { + return Err("PLANNING_SESSION_INVALID: Planning V2 Session 字段无效".to_string()); + } + Ok(()) +} + +pub(crate) fn write_planning_session_v2( + root: &Path, + session: &PlanningSessionV2, +) -> Result<(), String> { + let content = serde_json::to_vec_pretty(session) + .map_err(|error| format!("序列化 Planning V2 Session 失败:{error}"))?; + write_game_creator_private_file( + &planning_session_path(root), + format!("{}\n", String::from_utf8_lossy(&content)).as_bytes(), + "Planning V2 Session", + ) +} + +pub(crate) fn append_planning_message_v2( + root: &Path, + message: &PlanningMessageV2, +) -> Result<(), String> { + let line = serde_json::to_string(message) + .map_err(|error| format!("序列化 Planning V2 消息失败:{error}"))?; + let path = planning_conversation_path(root); + if let Some(parent) = path.parent() { + ensure_game_creator_private_directory_tree(parent, "Planning V2 对话目录")?; + prepare_game_creator_private_path_for_read(parent, true, "Planning V2 对话目录")?; + } + let mut content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => { + return Err(format!( + "读取 Planning V2 对话记录失败:{}: {error}", + path.display() + )) + } + }; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(&line); + content.push('\n'); + if content.len() > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS.saturating_mul(4) { + return Err("Planning V2 对话记录超过有界大小".to_string()); + } + write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 对话记录") +} + +pub(crate) fn read_planning_messages_v2(root: &Path) -> Result, String> { + let path = planning_conversation_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 对话记录")? { + return Ok(Vec::new()); + } + let metadata = fs::metadata(&path) + .map_err(|error| format!("读取 Planning V2 对话记录元数据失败:{error}"))?; + if metadata.len() > PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES { + return Err("PLANNING_CONTEXT_OVERFLOW: Planning V2 对话记录超过有界大小".to_string()); + } + let file = fs::File::open(&path) + .map_err(|error| format!("读取 Planning V2 对话记录失败:{}: {error}", path.display()))?; + let mut messages = Vec::new(); + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| format!("读取 Planning V2 消息失败:{error}"))?; + if line.trim().is_empty() { + continue; + } + let message = serde_json::from_str::(&line) + .map_err(|error| format!("解析 Planning V2 消息失败:{error}"))?; + messages.push(message); + } + Ok(messages) +} + +fn message_text(message: &PlanningMessageV2) -> Option { + message + .payload + .get("text") + .and_then(Value::as_str) + .map(ToString::to_string) +} + +fn build_context_v2( + root: &Path, + llm: &GameCreatorLlmConfig, + skip_client_turn_id: Option<&str>, +) -> Result, String> { + let messages = read_planning_messages_v2(root)?; + let mut estimated_chars = 0_usize; + let mut output = Vec::with_capacity(messages.len()); + for message in messages { + if skip_client_turn_id + .is_some_and(|turn_id| message.client_turn_id == turn_id && message.role == "user") + { + continue; + } + let text = message_text(&message).or_else(|| { + matches!(message.kind.as_str(), "question" | "artifact") + .then(|| serde_json::to_string(&message.payload).ok()) + .flatten() + }); + let Some(text) = text else { continue }; + estimated_chars = estimated_chars.saturating_add(text.chars().count()); + if estimated_chars > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS { + return Err( + "PLANNING_CONTEXT_OVERFLOW: Planning V2 会话上下文超过有界上限".to_string(), + ); + } + let role = match message.role.as_str() { + "user" => platform_llm::LlmMessage::user(text), + "assistant" => platform_llm::LlmMessage::assistant(text), + _ => continue, + }; + output.push(role); + } + let configured_limit = llm.context_window_tokens.saturating_mul(4) as usize; + if configured_limit > 0 && estimated_chars > configured_limit { + return Err( + "PLANNING_CONTEXT_OVERFLOW: Planning V2 请求上下文超过 Provider 窗口".to_string(), + ); + } + Ok(output) +} + +fn new_session_v2(project_id: String, mode: String) -> PlanningSessionV2 { + let now = current_plan_timestamp_utc(); + PlanningSessionV2 { + schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), + engine: PLANNING_SESSION_V2_ENGINE.to_string(), + session_id: format!("ps-{}", Uuid::new_v4().simple()), + project_id, + mode, + status: "idle".to_string(), + turn_index: 0, + question_count: 0, + question_limit: Some(8), + revision_count: 0, + current_artifact_version: None, + current_question: None, + capabilities: PlanningCapabilitySnapshotV2::default(), + processing_seconds: 0.0, + created_at_utc: now.clone(), + updated_at_utc: now, + last_error: None, + } +} + +fn read_project_id_v2(root: &Path) -> Result { + read_manifest_for_project(root) + .map(|manifest| manifest.project_id) + .map_err(|error| format!("读取项目身份失败:{error}")) +} + +fn existing_turn_result_v2( + messages: &[PlanningMessageV2], + client_turn_id: &str, +) -> Option { + messages + .iter() + .rev() + .find(|message| { + message.client_turn_id == client_turn_id + && message.role == "assistant" + && message.kind != "error" + }) + .and_then(|message| { + Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: message.payload.clone(), + }) + }) +} + +fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { + messages.iter().any(|message| { + message.turn_index == turn_index + && message.role == "assistant" + && message.kind != "error" + && message_text(message).is_some_and(|text| !text.trim().is_empty()) + }) +} + +fn prepare_turn_v2( + root: &Path, + client_turn_id: &str, + prompt: &str, + mode: Option<&str>, + is_start: bool, +) -> Result { + validate_project_root(root)?; + let client_turn_id = validate_client_turn_id(client_turn_id)?; + let prompt = validate_prompt(prompt)?; + let _lock = acquire_project_write_lock(root, "planning.v2.turn.start")?; + let mut session = match read_planning_session_v2(root)? { + Some(session) => session, + None => { + let mode = mode.unwrap_or("gdd").trim(); + if mode != "gdd" { + return Err("Planning V2 当前只支持 mode=gdd".to_string()); + } + new_session_v2(read_project_id_v2(root)?, mode.to_string()) + } + }; + let project_id = read_project_id_v2(root)?; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if let Some(requested_mode) = mode.map(str::trim).filter(|value| !value.is_empty()) { + if requested_mode != session.mode { + return Err("Planning V2 不允许在同一 Session 切换 mode".to_string()); + } + } + let messages = read_planning_messages_v2(root)?; + if let Some(replay) = existing_turn_result_v2(&messages, &client_turn_id) { + return Ok(PlanningTurnStartV2 { + session, + context_messages: Vec::new(), + replay: Some(replay), + }); + } + if session.status == "planning" { + return Err("Planning V2 当前已有回合执行中".to_string()); + } + if !is_start + && !matches!( + session.status.as_str(), + "awaiting_user" | "revision_requested" | "provider_failed" + ) + { + return Err(format!( + "Planning V2 Session 当前状态不可提交用户输入:{}", + session.status + )); + } + if matches!(session.status.as_str(), "approved" | "rejected" | "stopped") { + return Err(format!( + "Planning V2 Session 当前状态不可继续:{}", + session.status + )); + } + if is_start && session.turn_index > 0 && messages.iter().any(|message| message.role == "user") { + return Err("Planning V2 已有会话内容,请使用 continue_planning_session_v2".to_string()); + } + let existing_user = messages + .iter() + .rev() + .find(|message| message.client_turn_id == client_turn_id && message.role == "user"); + let llm = resolve_game_creator_llm_config_for_agent( + &load_game_creator_app_config()?, + "planning-agent-v2", + ); + let context_messages = + build_context_v2(root, &llm, existing_user.map(|_| client_turn_id.as_str()))?; + if let Some(existing_user) = existing_user { + let existing_text = message_text(existing_user).unwrap_or_default(); + if existing_text != prompt { + return Err("同一 clientTurnId 不能提交不同用户输入".to_string()); + } + if session.status != "provider_failed" { + return Err("Planning V2 当前回合不能重复提交".to_string()); + } + session.turn_index = existing_user.turn_index; + } else { + session.turn_index = session.turn_index.saturating_add(1); + } + session.status = "planning".to_string(); + session.current_question = None; + session.last_error = None; + session.updated_at_utc = current_plan_timestamp_utc(); + if existing_user.is_none() { + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.clone(), + turn_index: session.turn_index, + at_utc: current_plan_timestamp_utc(), + role: "user".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": prompt}), + }, + )?; + } + write_planning_session_v2(root, &session)?; + Ok(PlanningTurnStartV2 { + session, + context_messages, + replay: None, + }) +} + +fn build_provider_request_v2( + session: &PlanningSessionV2, + context_messages: Vec, + prompt: &str, + llm: &GameCreatorLlmConfig, +) -> Result { + let question_policy = match session.question_limit { + Some(limit) if session.question_count >= limit => format!( + "当前已向用户展示 {} 个有效问题;已达到问询上限,禁止再返回 question,必须直接输出完整 GDD。", + session.question_count + ), + Some(_limit) => format!( + "当前已向用户展示 {} 个有效问题;只有确实影响首个可玩闭环且无法合理默认的空白才返回 question。", + session.question_count + ), + None => format!( + "当前已向用户展示 {} 个有效问题,问题数不设上限;只有确实影响首个可玩闭环且无法合理默认的空白才返回 question。", + session.question_count + ), + }; + let system = platform_llm::LlmMessage::system(format!( + "你是 Planning Session V2 的立项策划 Agent。当前会话 {},回合 {}。只返回一个合法 JSON object,不要 Markdown 代码围栏、解释文字、Supervisor、子 Agent、委派或验收协议。\n\n{}\n\n如果需要向用户确认,只返回 {{\"kind\":\"question\",\"question\":{{\"id\":\"snake_case_id\",\"header\":\"当前要决定:...\",\"question\":\"...\",\"options\":[{{\"label\":\"方案 A\",\"description\":\"...\"}},{{\"label\":\"方案 B\",\"description\":\"...\"}}]}}}}。如果信息足够或问题数已达到上限,只返回 {{\"kind\":\"gdd\",\"gdd\":{{\"schemaVersion\":\"plan-gdd.v2\",\"game\":{{...}},\"decisions\":[...],\"prototypeValidationItems\":[]}}}}。GDD 必须完整填写游戏、支柱、核心循环、目标用户、MVP 系统、制作边界和创作者提示;不要省略字段。", + session.session_id, + session.turn_index, + question_policy + )); + let mut messages = vec![system]; + messages.extend(context_messages); + messages.push(platform_llm::LlmMessage::user(prompt.to_string())); + let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; + let request = platform_llm::LlmRunRequest::new(messages) + .with_api_kind(api_kind) + .with_model(llm.model.clone()) + .with_max_output_tokens(4_096) + .with_request_timeout_ms(llm.request_timeout_ms); + apply_game_creator_llm_reasoning_effort(request, llm) +} + +async fn invoke_provider_v2( + session: &PlanningSessionV2, + prompt: &str, + context_messages: Vec, + mut on_delta: F, +) -> Result +where + F: FnMut(&str, &str, Option<&str>) + Send, +{ + let config = load_game_creator_app_config()?; + let llm = resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2"); + let client = build_game_creator_llm_client_from_llm_config(&llm, "planning.v2")?; + let request = build_provider_request_v2(session, context_messages, prompt, &llm)?; + if llm.stream { + let response = client + .stream_run(request, |delta| { + on_delta( + delta.accumulated_text.as_str(), + delta.delta_text.as_str(), + delta.finish_reason.as_deref(), + ); + }) + .await + .map_err(|error| format!("Planning V2 Provider 流式调用失败:{error}"))?; + Ok(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": response.text.clone(), + "finishReason": response.finish_reason.clone(), + "responseId": response.response_id.clone(), + "model": response.model.clone(), + }), + }) + } else { + let response = client + .run(request) + .await + .map_err(|error| format!("Planning V2 Provider 调用失败:{error}"))?; + let text = response.text.clone(); + on_delta( + text.as_str(), + text.as_str(), + response.finish_reason.as_deref(), + ); + Ok(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": text, + "finishReason": response.finish_reason, + "responseId": response.response_id, + "model": response.model, + }), + }) + } +} + +pub(crate) fn result_text(result: &PlanningTurnResultV2) -> String { + result + .payload + .get("text") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +fn persist_turn_failure_v2( + root: &Path, + client_turn_id: &str, + session_id: &str, + turn_index: u64, + elapsed_seconds: f64, + error: &PlanningErrorV2, +) -> Result { + let _lock = acquire_project_write_lock(root, "planning.v2.turn.fail")?; + let mut session = + read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if session.session_id != session_id || session.turn_index != turn_index { + return Err("Planning V2 Session 回合身份发生变化".to_string()); + } + append_planning_message_v2( + root, + &PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: format!("msg-{}", Uuid::new_v4().simple()), + client_turn_id: client_turn_id.to_string(), + turn_index, + at_utc: current_plan_timestamp_utc(), + role: "assistant".to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }, + )?; + session.status = "provider_failed".to_string(); + session.processing_seconds += elapsed_seconds.max(0.0); + session.updated_at_utc = current_plan_timestamp_utc(); + session.last_error = Some(error.clone()); + write_planning_session_v2(root, &session)?; + Ok(session) +} + +async fn run_turn_v2( + root: &Path, + start: PlanningTurnStartV2, + client_turn_id: String, + prompt: String, + mut emit: F, +) -> Result +where + F: FnMut(PlanningSessionStreamEventV2) + Send, +{ + if let Some(result) = start.replay { + return Ok(PlanningSessionCommandResultV2 { + session: start.session, + result: Some(result), + current_artifact: current_planning_artifact_v2(root)?, + replayed: true, + }); + } + let session_id = start.session.session_id.clone(); + let turn_index = start.session.turn_index; + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + result: None, + error: None, + }); + let started_at = Instant::now(); + let mut accumulated = String::new(); + let mut attempt_prompt = prompt.clone(); + let mut policy_retry = 0_u8; + let policy_output = loop { + accumulated.clear(); + let provider_result = invoke_provider_v2( + &start.session, + &attempt_prompt, + start.context_messages.clone(), + |all, _delta, _finish_reason| { + accumulated = all.to_string(); + }, + ) + .await; + let result = match provider_result { + Ok(result) => result, + Err(detail) => { + let error = safe_error("PROVIDER_FAILED", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + started_at.elapsed().as_secs_f64(), + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id, + client_turn_id, + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + return Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + }); + } + }; + match parse_planning_policy_output_v2(&result).and_then(|output| { + validate_planning_policy_output_v2(&output)?; + if matches!(output, PlanningPolicyOutputV2::Question(_)) + && start + .session + .question_limit + .is_some_and(|limit| start.session.question_count >= limit) + { + return Err( + "PLANNING_QUESTION_LIMIT: questionCount 已达到上限,必须直接输出 GDD" + .to_string(), + ); + } + Ok(output) + }) { + Ok(output) => break output, + Err(detail) if policy_retry < 1 => { + policy_retry = policy_retry.saturating_add(1); + attempt_prompt = format!( + "{}\n\n【Runtime】上一次输出未通过策划协议:{}。请只返回合法 JSON;当前只允许返回合法 question 或完整 GDD,不要输出解释文字。{}", + prompt, + detail, + if start.session.question_limit.is_some_and(|limit| { + start.session.question_count >= limit + }) { + "当前问题数已达到上限,禁止再提问,必须直接输出 GDD。" + } else { + "" + } + ); + } + Err(detail) => { + let error = safe_error("PLANNING_INVALID_OUTPUT", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + started_at.elapsed().as_secs_f64(), + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id, + client_turn_id, + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + return Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + }); + } + } + }; + if !accumulated.is_empty() { + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "delta".to_string(), + delta_text: accumulated.clone(), + accumulated_text: accumulated.clone(), + finish_reason: None, + result: None, + error: None, + }); + } + let elapsed_seconds = started_at.elapsed().as_secs_f64(); + match persist_planning_policy_output_v2( + root, + &client_turn_id, + &session_id, + turn_index, + elapsed_seconds, + policy_output, + ) { + Ok(persisted) => { + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: if accumulated.is_empty() { + result_text(&persisted.result) + } else { + accumulated + }, + finish_reason: persisted + .result + .payload + .get("finishReason") + .and_then(Value::as_str) + .map(ToString::to_string), + result: Some(persisted.result.clone()), + error: None, + }); + Ok(PlanningSessionCommandResultV2 { + session: persisted.session, + result: Some(persisted.result), + current_artifact: persisted.current_artifact, + replayed: false, + }) + } + Err(detail) => { + let error = safe_error("PLANNING_PERSIST_FAILED", detail); + let session = persist_turn_failure_v2( + root, + &client_turn_id, + &session_id, + turn_index, + elapsed_seconds, + &error, + )?; + emit(PlanningSessionStreamEventV2 { + session_id: session_id.clone(), + client_turn_id: client_turn_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: accumulated, + finish_reason: None, + result: None, + error: Some(error.clone()), + }); + Ok(PlanningSessionCommandResultV2 { + session, + result: Some(PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": error.code, "summary": error.summary}), + }), + current_artifact: current_planning_artifact_v2(root)?, + replayed: false, + }) + } + } +} + +#[tauri::command] +pub(crate) async fn start_planning_session_v2( + app: tauri::AppHandle, + project_path: String, + client_turn_id: String, + prompt: String, + mode: Option, +) -> Result { + run_planning_session_v2_command(&app, project_path, None, client_turn_id, prompt, mode, true) + .await +} + +#[tauri::command] +pub(crate) async fn continue_planning_session_v2( + app: tauri::AppHandle, + project_path: String, + session_id: String, + client_turn_id: String, + input: Value, +) -> Result { + run_planning_session_v2_command( + &app, + project_path, + Some(session_id), + client_turn_id, + normalize_planning_input(input)?, + None, + false, + ) + .await +} + +async fn run_planning_session_v2_command( + app: &tauri::AppHandle, + project_path: String, + expected_session_id: Option, + client_turn_id: String, + prompt: String, + mode: Option, + is_start: bool, +) -> Result { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + enforce_project_permission_policy(&root, "conversation.write")?; + let prompt = validate_prompt(&prompt)?; + let client_turn_id = validate_client_turn_id(&client_turn_id)?; + let project_id = read_project_id_v2(&root)?; + let _active_guard = try_acquire_planning_v2_active(&project_id)?; + if let Some(expected) = expected_session_id.as_deref() { + let actual = read_planning_session_v2(&root)? + .ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if actual.session_id != expected.trim() { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + } + let start = prepare_turn_v2(&root, &client_turn_id, &prompt, mode.as_deref(), is_start)?; + let event_app = app.clone(); + run_turn_v2(&root, start, client_turn_id, prompt, move |event| { + let _ = event_app.emit("planning-session-v2-stream", event); + }) + .await +} + +#[tauri::command] +pub(crate) fn hydrate_planning_session_v2( + project_path: String, + session_id: Option, +) -> Result, String> { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + let _lock = acquire_project_write_lock(&root, "planning.v2.hydrate")?; + let Some(mut session) = read_planning_session_v2(&root)? else { + return Ok(None); + }; + let project_id = read_project_id_v2(&root)?; + if session.project_id != project_id { + return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); + } + if let Some(session_id) = session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if session.session_id != session_id { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + } + if session.status == "planning" && !planning_v2_is_active(&session.project_id) { + let messages = read_planning_messages_v2(&root)?; + if has_successful_assistant_for_turn(&messages, session.turn_index) { + session.status = "awaiting_user".to_string(); + session.last_error = None; + } else { + session.status = "provider_failed".to_string(); + session.last_error = Some(safe_error( + "RECOVERY_REQUIRED", + "上次 Planning V2 Provider 回合在进程退出前未完成,请重新提交用户输入", + )); + } + session.updated_at_utc = current_plan_timestamp_utc(); + write_planning_session_v2(&root, &session)?; + } + Ok(Some(PlanningSessionCommandResultV2 { + session, + result: None, + current_artifact: current_planning_artifact_v2(&root)?, + replayed: false, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_turn_id_and_prompt_are_trimmed_and_bounded() { + assert_eq!(validate_client_turn_id(" turn-1 ").unwrap(), "turn-1"); + assert_eq!(validate_prompt(" 做方案 ").unwrap(), "做方案"); + assert!(validate_client_turn_id(&"x".repeat(129)).is_err()); + assert!(validate_prompt(" ").is_err()); + } + + #[test] + fn existing_turn_result_replays_assistant_text_or_error() { + let messages = vec![PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "msg-1".to_string(), + client_turn_id: "turn-1".to_string(), + turn_index: 1, + at_utc: "2026-09-03T00:00:00.000Z".to_string(), + role: "assistant".to_string(), + kind: "text".to_string(), + payload: serde_json::json!({"text": "已完成"}), + }]; + let result = existing_turn_result_v2(&messages, "turn-1").expect("replay result"); + assert_eq!(result.kind, "assistant_text"); + assert_eq!(result_text(&result), "已完成"); + } + + #[test] + fn error_message_is_not_treated_as_successful_idempotent_replay() { + let messages = vec![PlanningMessageV2 { + schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), + message_id: "msg-1".to_string(), + client_turn_id: "turn-1".to_string(), + turn_index: 1, + at_utc: "2026-09-03T00:00:00.000Z".to_string(), + role: "assistant".to_string(), + kind: "error".to_string(), + payload: serde_json::json!({"code": "PROVIDER_FAILED"}), + }]; + assert!(existing_turn_result_v2(&messages, "turn-1").is_none()); + } + + #[test] + fn session_validation_rejects_unknown_status_or_schema() { + let mut session = new_session_v2("project-1".to_string(), "gdd".to_string()); + assert!(validate_planning_session_v2(&session).is_ok()); + session.status = "unknown".to_string(); + assert!(validate_planning_session_v2(&session).is_err()); + session.status = "idle".to_string(); + session.schema_version = "planning-session.v1".to_string(); + assert!(validate_planning_session_v2(&session).is_err()); + } + + #[test] + fn continue_input_accepts_contract_shaped_option_payload() { + assert_eq!( + normalize_planning_input(serde_json::json!({ + "kind": "option", + "optionIndex": 0, + "optionLabel": "工作台设计迭代" + })) + .unwrap(), + "按选项:工作台设计迭代" + ); + assert_eq!( + normalize_planning_input(serde_json::json!({ "optionIndex": 1 })).unwrap(), + "按第 2 个选项做" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 1d6e98691..2731c09d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -658,7 +658,7 @@ fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), Plannin Ok(()) } -fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { +pub(crate) fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { validate_text(&game.title, "game.title", 1, 80)?; validate_text(&game.genre.primary, "game.genre.primary", 1, 40)?; if let Some(fusion) = &game.genre.fusion { 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 b49bd19d9..785c78b65 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2493,6 +2493,10 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + start_planning_session_v2, + continue_planning_session_v2, + decide_planning_artifact_v2, + hydrate_planning_session_v2, start_game_creator_agent_runtime_task, start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 062a74e3c..05b5dde7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -305,6 +305,7 @@ pub(crate) fn list_local_project_files_at( if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) + || is_agent_planning_storage_path(&relative_path) { continue; } @@ -408,10 +409,11 @@ pub(crate) fn reject_agent_runtime_private_control_path( /// the planning Agent needs `file.read`/`file.list` observations while its /// durable writer is still the only component allowed to mutate the sidecar. pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool { - normalized_path.eq_ignore_ascii_case(".agent/planning") - || normalized_path - .to_ascii_lowercase() - .starts_with(".agent/planning/") + let normalized_path = normalized_path.to_ascii_lowercase(); + normalized_path == ".agent/planning" + || normalized_path.starts_with(".agent/planning/") + || normalized_path == ".agent/planning-v2" + || normalized_path.starts_with(".agent/planning-v2/") } pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { @@ -424,7 +426,7 @@ pub(crate) fn reject_agent_planning_storage_write_path( ) -> Result<(), String> { if is_agent_planning_managed_write_path(normalized_path) { return Err( - "`.agent/planning/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" + "`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" .to_string(), ); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b13e630ea..a433e79b7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:现有“做方案”依赖 `project-supervisor-plan` 根 Run、`project-planning` 子 Run、静态委派、delivery、Acceptance Graph 和审批前 evidence。新策划 Agent 只需要单 Agent 会话、问询、GDD 和审批;继续在旧 Runtime 上逐条放宽会保留身份/编排耦合。未来策划 Agent 可能支持无限多轮、MCP 和 Skill,需要避免把当前 8 题/GDD/no-tools 固化为 Runtime 根结构。 - 决策:新增独立 `PlanningSessionRuntime`,复用 Provider/流式、会话持久化、项目锁、原子写和基础错误恢复;当前启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。新建“做方案”会话不创建 Supervisor root、planning child、delegation 或 acceptance evidence。V2 使用独立 `.agent/planning-v2/` 与 V2 schema,继续输出 `game/fast_gdd.md`;不自动转换旧会话。 - 兼容性:Session 保存 `mode`、可空 `questionLimit`、`capabilities.tools/skills`;完整会话记录与 Provider 请求上下文分离;消息模型预留 tool/skill 事件类型但本期不执行 MCP/Skill。无限问询、上下文摘要、多产物和能力执行以后作为策略/能力层扩展,不重新引入 Supervisor 身份模型。 -- 当前进度:P0 合同冻结已完成,已固定 `planning-session.v2`、`planning-message.v2`、`planning-turn-result.v2`、`plan-gdd.v2`、`plan-approval.v2`、四个 V2 command、`questionLimit=8` 及旧链路 `legacy-cutover.json` 封存边界;P1 尚未开工。 +- 当前进度:P0 合同冻结、P1 会话内核和 P2 GDD/审批核心已落地;P3 正式入口/UI 接入与 P5 旧链路退役尚未开始。 - 退役:V2 切换时旧链路直接封存;所有未完成旧会话投影为 `legacy_retired` 失败,禁止继续问询、审批、恢复或 continuation。旧 GDD、approval、conversation 和 `.agent/planning` 文件只读保留;旧入口 caller 关闭,但不删除旧代码、旧测试或旧数据。 - 影响范围:AGC 做方案入口、Rust/Tauri planning session/Provider adapter、GDD/审批 V2、前端 planning lane、阶段任务与 BDD 验收;做游戏/做素材 DirectProject 不变。 - 验证方式:按 `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` 的 P0~P5 阶段验收执行;至少覆盖第 8 个问题、上限后 question 抑制、Provider 失败、非法输出、批准/修改/退回、重启恢复、旧会话切换强制失败、迟到 Provider 结果丢弃和当前空能力快照。 @@ -7970,3 +7970,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - UI 编辑器导出的 `ui/generated-*.js` 是派生本地产物。代码生成只写文件,绝不推进项目 revision、UI State revision、manifest 阶段或 Runtime 验证门;写入失败只返回生成错误,不能把生成文件写入冒充项目 mutation。 - 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。 - Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。 + +## 2026-09-03 策划会话 Runtime V2 P1 内核落地 + +- 新增 `PlanningSessionRuntime V2` 内核入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。P1 只负责单 Agent 会话生命周期、Provider 流式/普通调用、消息持久化、回合幂等、单项目单活跃回合、耗时和失败恢复,不接入 GDD 解析、审批或旧 Supervisor。 +- V2 会话快照落在 `.agent/planning-v2/session.json`,完整消息落在 `.agent/planning-v2/conversation.jsonl`。同一 `clientTurnId` 已有成功 assistant 记录时直接重放;若只有 error 记录则允许沿用同一用户意图重试,不重复追加用户消息。进程退出后 hydrate 发现 `planning` 会投影为 `provider_failed/RECOVERY_REQUIRED`,不伪造成功或自动重试。 +- Provider 调用复用现有 `platform-llm` 的 API-kind、流式解析、超时和重试配置;当前策略快照固定 `mode=gdd`、`questionLimit=8`、`tools=[]`、`skills=[]`。GDD 业务规则留给 P2,生产 UI 接入留给 P3。 + +## 2026-09-03 策划会话 Runtime V2 P2 产物闭环 + +- V2 新增 `GddPlanningPolicy`:Provider 输出只接受合法 question 或完整 GDD JSON;question 在 `questionCount < questionLimit` 时保存并展示,第 8 个问题仍可展示,第 8 个之后再次返回 question 时只允许一次内部强制出稿重试,额外 question 不落盘、不进入用户等待态。 +- V2 GDD 使用独立 `plan-gdd.v2`,只保留项目身份、版本、游戏内容、决定、原型验证项和指纹,不带旧 Supervisor/Run/delegation 字段。每个版本写入 `.agent/planning-v2/gdd.vN.json`,同步更新 V2 index 和 `game/fast_gdd.md`;旧版本不可覆盖。 +- 新增 `decide_planning_artifact_v2` 与 `plan-approval.v2`。批准、修改、退回均绑定当前 Session、artifact、version 和 fingerprint;修改/退回必须有意见,修改后 Session 回到 `revision_requested`,下一轮由同一 V2 Session 继续。`answerSource` 缺失或未知值回退,不成为单独阻断项。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 65f301737..4f88c86c0 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -1,7 +1,7 @@ # 策划会话 Runtime V2 接入与旧链路退役方案 - 日期:2026-09-03 -- 状态:P0 合同冻结已完成,P1 待开工;本文是新生产实现的目标方案与阶段验收合同 +- 状态:P0 合同冻结、P1 内核和 P2 产物闭环核心已完成;P3~P5 待实施;本文是新生产实现的目标方案与阶段验收合同 - 适用范围:AGC 桌面 App 的“做方案”入口、策划会话、GDD 产物与审批 > 本文只规定新策划 Agent 的生产接入和旧链路退役方式,不修改当前生产代码。现有 `project-supervisor-plan` / `project-planning` 链路在 V2 切换前仍是存量实现;V2 切换时旧链路直接封存,所有未完成旧会话强制失败,旧 Fast GDD 文档之后只作为历史记录依据。 @@ -616,6 +616,12 @@ hydrate_planning_session_v2 ### P1:PlanningSessionRuntime 内核 +状态:实现已落在 AGC Tauri Runtime(2026-09-03)。当前仅提供内核闭环,尚未接入 GDD 解析、审批或生产 UI。 + +已落地的 P1 入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。会话快照和完整消息分别持久化到 `.agent/planning-v2/session.json` 与 `.agent/planning-v2/conversation.jsonl`;Provider 请求沿用现有 `platform-llm`,按配置执行流式或普通调用,并把流式增量统一投影为 `planning-session-v2-stream` 事件。 + +当前 P1 的恢复语义是轻量且显式的:进程退出时若快照仍为 `planning`,hydrate 将其投影为 `provider_failed/RECOVERY_REQUIRED`,要求用户重新提交当前意图;不会伪造成功或自动制造 GDD。`clientTurnId` 命中已有成功 assistant 记录时直接等值重放;若只有 error 记录,则沿用原用户意图重试且不重复追加用户消息。 + 目标:在不包含 GDD 业务规则的情况下,跑通单 Agent 会话、流式响应、持久化和恢复。 任务: @@ -642,6 +648,10 @@ hydrate_planning_session_v2 ### P2:GddPlanningPolicy 与 V2 产物闭环 +状态:核心实现已落地(2026-09-03)。当前已支持 question/GDD 解析、最多 8 个有效问题、达到上限后的单次强制出稿、`plan-gdd.v2` 版本文件、`game/fast_gdd.md` 投影和 `plan-approval.v2` 审批记录;审批修改后的下一轮仍由同一 V2 Session 继续。策略校验完成前的流式内容只在回合成功后对外转发,达到上限而被丢弃的 question、非法输出和重试内容不会泄露给调用方。等待审批时不能直接提交新的策划输入。P3 仍需把正式“做方案”入口和现有 UI 切到这些 command。 + +已落地入口:`decide_planning_artifact_v2`。`answerSource` 缺失或未知值按当前状态回退为 `user_freeform` / `default`,不作为单独阻断项;GDD 结构、必填业务字段、版本、指纹和当前项目身份仍必须合法。 + 目标:把新版原型的策划行为落到生产 V2,不把旧 Supervisor 协议带回来。 任务: