From c1d967b17d78b1e57352e876d106d6ae06b6f6dc Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 4 Sep 2026 10:10:50 +0000 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E7=AD=96=E5=88=92=20V2=20=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E4=BB=8E=E6=AD=A3=E6=96=87=20JSON=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E5=8D=8F=E8=AE=AE=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 挂 plan_ask_question / plan_submit_gdd,tool_choice 固定 auto 删除正文 JSON 解析、骨架提示词和入参 schemaVersion 必填 形状改由工具 schema 承担,既有校验门禁与落盘 plan-gdd.v2 不变 同步技术方案、决策记录和 DeepSeek thinking 排障 --- .../runtime_protocol/planning_policy_v2.rs | 473 ++++++++++++++---- .../runtime_protocol/planning_session_v2.rs | 196 +++----- .../shared-memory/decision-log.md | 8 + docs/project-memory/shared-memory/pitfalls.md | 6 + ...策划会话RuntimeV2接入与旧链路退役-2026-09-03.md | 19 +- 5 files changed, 481 insertions(+), 221 deletions(-) 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 index beb4bc9c8..43311f189 100644 --- 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 @@ -1,7 +1,7 @@ use super::*; use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; +use serde_json::Value; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -14,6 +14,8 @@ 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; +pub(crate) const PLAN_ASK_QUESTION_TOOL_NAME: &str = "plan_ask_question"; +pub(crate) const PLAN_SUBMIT_GDD_TOOL_NAME: &str = "plan_submit_gdd"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -46,7 +48,8 @@ pub(crate) struct PlanningGddDecisionInputV2 { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(crate) struct PlanningGddInputV2 { - pub schema_version: String, + #[serde(default)] + pub schema_version: Option, pub game: PlanSubmitGame, pub decisions: Vec, pub prototype_validation_items: Vec, @@ -153,50 +156,284 @@ 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 decision_state_schema() -> Value { + serde_json::json!({ + "type": "string", + "enum": ["confirmed", "assumption_pending", "prototype_pending"], + "description": "confirmed=已确认;assumption_pending=Agent 推断待确认;prototype_pending=待原型验证" + }) } -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 planning_v2_function_tools() -> Vec { + let decision_state = decision_state_schema(); + vec![ + platform_llm::LlmFunctionTool::new( + PLAN_ASK_QUESTION_TOOL_NAME, + "向用户提出一个关键问题。options 可包含一项「需要原型验证」,表示先做小原型验证。", + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "question": { + "type": "object", + "additionalProperties": false, + "description": "决策卡内容,只问一个最关键的问题", + "properties": { + "id": {"type": "string", "description": "snake_case 问题 id"}, + "header": {"type": "string", "description": "当前要决定的主题"}, + "question": {"type": "string", "description": "问用户的问题正文"}, + "options": { + "type": "array", + "description": "可选方案", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": {"type": "string", "description": "选项标签"}, + "description": {"type": "string", "description": "该选项的具体方案"} + }, + "required": ["label", "description"] + } + } + }, + "required": ["id", "header", "question", "options"] + } + }, + "required": ["question"] + }), + ), + platform_llm::LlmFunctionTool::new( + PLAN_SUBMIT_GDD_TOOL_NAME, + "提交完整 GDD。", + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "gdd": { + "type": "object", + "additionalProperties": false, + "description": "立项方案全文", + "properties": { + "game": { + "type": "object", + "additionalProperties": false, + "description": "玩法方案", + "properties": { + "title": {"type": "string", "description": "中文标题"}, + "genre": { + "type": "object", + "additionalProperties": false, + "description": "游戏类型", + "properties": { + "primary": {"type": "string", "description": "主类型"}, + "fusion": { + "type": ["string", "null"], + "description": "融合类型,没有则为 null" + } + }, + "required": ["primary"] + }, + "artStyle": { + "type": "object", + "additionalProperties": false, + "description": "美术风格", + "properties": { + "visualType": {"type": "string", "description": "视觉类型"}, + "keywords": { + "type": "array", + "description": "风格关键词", + "minItems": 3, + "maxItems": 5, + "items": {"type": "string"} + }, + "moodAndColor": {"type": "string", "description": "氛围与色彩"}, + "mvpArtBoundary": {"type": "string", "description": "MVP 美术边界"} + }, + "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"] + }, + "oneLiner": {"type": "string", "description": "一句话概念"}, + "pillars": { + "type": "array", + "description": "游戏支柱", + "minItems": 2, + "maxItems": 4, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": {"type": "string", "description": "支柱名称"}, + "playerFeel": {"type": "string", "description": "玩家感受"}, + "mechanism": {"type": "string", "description": "实现机制"}, + "decisionState": decision_state.clone() + }, + "required": ["name", "playerFeel", "mechanism", "decisionState"] + } + }, + "coreLoop": { + "type": "array", + "description": "单局核心循环步骤", + "minItems": 4, + "maxItems": 8, + "items": {"type": "string"} + }, + "targetUsers": { + "type": "object", + "additionalProperties": false, + "description": "目标用户", + "properties": { + "coreUsers": {"type": "string", "description": "核心用户"}, + "preferences": {"type": "string", "description": "用户偏好"}, + "sessionLength": {"type": "string", "description": "单局时长"}, + "referenceGames": { + "type": "array", + "description": "参考游戏,没有则为 []", + "items": {"type": "string"} + } + }, + "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"] + }, + "mvpSystems": { + "type": "array", + "description": "MVP 必须有的系统", + "minItems": 3, + "maxItems": 6, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "system": {"type": "string", "description": "系统名称"}, + "minimalFunction": {"type": "string", "description": "最小功能"}, + "whyRequired": {"type": "string", "description": "为什么必须有"}, + "verifyMethod": {"type": "string", "description": "验证方式"}, + "decisionState": decision_state.clone() + }, + "required": [ + "system", + "minimalFunction", + "whyRequired", + "verifyMethod", + "decisionState" + ] + } + }, + "outOfScope": { + "type": "array", + "description": "暂不做的内容", + "minItems": 1, + "maxItems": 12, + "items": {"type": "string"} + }, + "creatorTips": { + "type": "object", + "additionalProperties": false, + "description": "给创作者的提示", + "properties": { + "doFirst": {"type": "string", "description": "先做什么"}, + "deferForNow": {"type": "string", "description": "暂缓什么"}, + "howToVerify": {"type": "string", "description": "如何验证"}, + "expandWhen": {"type": "string", "description": "何时扩展"} + }, + "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"] + } + }, + "required": [ + "title", + "genre", + "artStyle", + "oneLiner", + "pillars", + "coreLoop", + "targetUsers", + "mvpSystems", + "outOfScope", + "creatorTips" + ] + }, + "decisions": { + "type": "array", + "description": "已确认或待确认的决定", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string", "description": "决定 id"}, + "topic": {"type": "string", "description": "主题"}, + "state": decision_state.clone(), + "answerSource": { + "type": "string", + "description": "来源标记,如 user_freeform、user_choice、agent_inferred" + }, + "round": { + "type": "integer", + "minimum": 0, + "description": "对应回合,初始需求为 0" + }, + "answerSummary": {"type": "string", "description": "结论摘要"} + }, + "required": ["id", "topic", "state", "round", "answerSummary"] + } + }, + "prototypeValidationItems": { + "type": "array", + "description": "无 prototype_pending 时为 [];有则按决定 id 对应填写", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": {"type": "string", "description": "与待验证决定对应的 id"}, + "question": {"type": "string", "description": "要验证的问题"}, + "microPrototype": {"type": "string", "description": "最小原型做法"}, + "observation": {"type": "string", "description": "观察什么"}, + "passCriterion": {"type": "string", "description": "通过标准"} + }, + "required": ["id", "question", "microPrototype", "observation", "passCriterion"] + } + } + }, + "required": ["game", "decisions", "prototypeValidationItems"] + } + }, + "required": ["gdd"] + }), + ), + ] +} + +fn parse_tool_arguments(raw: &Value) -> Result { + match raw { + Value::String(text) => serde_json::from_str(text) + .map_err(|error| format!("PLANNING_INVALID_OUTPUT: 工具参数不是合法 JSON:{error}")), + Value::Object(_) => Ok(raw.clone()), + _ => Err("PLANNING_INVALID_OUTPUT: 工具参数必须是 JSON object".to_string()), + } } 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 calls = result + .payload + .get("toolCalls") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if calls.len() != 1 { + return Err( + "PLANNING_INVALID_OUTPUT: 必须且只能调用 plan_ask_question 或 plan_submit_gdd 其中一个,不要在正文输出 JSON" + .to_string(), + ); + } + let call = &calls[0]; + let name = call.get("name").and_then(Value::as_str).unwrap_or(""); + let args = parse_tool_arguments(call.get("arguments").unwrap_or(&Value::Null))?; + match name { + PLAN_ASK_QUESTION_TOOL_NAME => { + let question_value = args.get("question").cloned().ok_or_else(|| { + "PLANNING_INVALID_QUESTION: plan_ask_question 缺少 question 参数".to_string() + })?; let question = serde_json::from_value::(question_value).map_err(|error| { format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}") @@ -204,19 +441,16 @@ pub(crate) fn parse_planning_policy_output_v2( validate_question_v2(&question)?; Ok(PlanningPolicyOutputV2::Question(question)) } - "gdd" | "artifact" => { - let gdd_value = object + PLAN_SUBMIT_GDD_TOOL_NAME => { + let gdd_value = args .get("gdd") - .or_else(|| object.get("payload")) - .unwrap_or(&value) - .clone(); + .cloned() + .ok_or_else(|| "PLANNING_INVALID_GDD: plan_submit_gdd 缺少 gdd 参数".to_string())?; 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}" - )), + _ => Err(format!("PLANNING_INVALID_OUTPUT: 未知策划工具:{name}")), } } @@ -489,11 +723,6 @@ pub(crate) fn validate_planning_policy_output_v2( 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_v2_game(&game) @@ -1200,49 +1429,109 @@ mod tests { 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": "连续三局都能理解后再扩展"} + fn sample_gdd_value() -> Value { + serde_json::json!({ + "game": { + "title": "萤火守夜者", + "genre": {"primary": "轻策略", "fusion": null}, + "artStyle": { + "visualType": "手绘平面", + "keywords": ["暖色", "剪影", "纸感"], + "moodAndColor": "夜色中的暖黄灯火", + "mvpArtBoundary": "只做可复用占位素材" }, - "decisions": [{"id": "initial-request", "topic": "初始需求", "state": "confirmed", "round": 0, "answerSummary": "做一个短局守夜策略游戏"}], - "prototypeValidationItems": [] - } - }); - let mut result = PlanningTurnResultV2 { + "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": [] + }) + } + + fn tool_turn_result(name: &str, arguments: Value) -> PlanningTurnResultV2 { + 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()}), - }; + payload: serde_json::json!({ + "text": "", + "toolCalls": [{ + "id": "call-1", + "name": name, + "arguments": arguments + }] + }), + } + } + + #[test] + fn parses_v2_gdd_from_submit_tool_without_schema_version() { + let result = tool_turn_result( + PLAN_SUBMIT_GDD_TOOL_NAME, + serde_json::json!({"gdd": sample_gdd_value()}), + ); 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()); + let PlanningPolicyOutputV2::Gdd(gdd) = parsed else { + panic!("expected gdd"); + }; + assert!(gdd.schema_version.is_none()); + validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(gdd)).expect("valid gdd"); + let text_json = PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": "{\"kind\":\"gdd\",\"gdd\":{\"schemaVersion\":\"plan-gdd.v2\"}}" + }), + }; + assert!(parse_planning_policy_output_v2(&text_json).is_err()); + } + + #[test] + fn parses_v2_question_from_ask_tool() { + let result = tool_turn_result( + PLAN_ASK_QUESTION_TOOL_NAME, + serde_json::json!({ + "question": { + "id": "core_loop", + "header": "当前要决定:核心循环", + "question": "玩家主要做什么?", + "options": [ + {"label": "方案 A", "description": "先设计再验证"}, + {"label": "方案 B", "description": "先战斗再调整"} + ] + } + }), + ); + let parsed = parse_planning_policy_output_v2(&result).expect("parse question"); + assert!(matches!(parsed, PlanningPolicyOutputV2::Question(_))); + } + + #[test] + fn planning_v2_tool_schema_describes_nested_gdd_without_schema_version() { + let tools = planning_v2_function_tools(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name, PLAN_ASK_QUESTION_TOOL_NAME); + assert_eq!(tools[1].name, PLAN_SUBMIT_GDD_TOOL_NAME); + let gdd_properties = tools[1].parameters["properties"]["gdd"]["properties"] + .as_object() + .expect("gdd properties"); + assert!(gdd_properties.contains_key("game")); + assert!(gdd_properties.contains_key("decisions")); + assert!(!gdd_properties.contains_key("schemaVersion")); + assert!(gdd_properties["game"]["properties"] + .as_object() + .expect("game properties") + .contains_key("title")); } } 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 index dbebfdf74..a6bb19ee2 100644 --- 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 @@ -551,109 +551,42 @@ fn prepare_turn_v2( }) } +fn planning_v2_question_policy(session: &PlanningSessionV2) -> String { + match session.question_limit { + Some(limit) if session.question_count >= limit => format!( + "当前已向用户展示 {} 个有效问题;已达到问询上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd 提交完整 GDD。", + session.question_count + ), + Some(_limit) => format!( + "当前已向用户展示 {} 个有效问题;只有确实影响首个可玩闭环且无法合理默认的空白才调用 plan_ask_question。", + session.question_count + ), + None => format!( + "当前已向用户展示 {} 个有效问题,问题数不设上限;只有确实影响首个可玩闭环且无法合理默认的空白才调用 plan_ask_question。", + session.question_count + ), + } +} + +fn planning_v2_system_prompt(session: &PlanningSessionV2) -> String { + // `question_limit` 是 Runtime 对“已展示问题数”的硬上限;模型提示词中的 + // 问询策略只是偏好,两者不要求数值一致。这里仅传当前已展示数, + // 达到硬上限时再明确禁止本次继续提问。 + format!( + "你是立项策划 Agent。当前会话 {},回合 {}。每轮必须且只能调用一个工具:问询用 plan_ask_question,出稿用 plan_submit_gdd。不要在正文输出 JSON、Markdown、解释或代码围栏。\n\n{}\n\n出稿前必须先确认三项核心闭环:玩家核心行为(每一局反复做什么)、单局目标/核心循环(怎样算完成一局)、MVP 制作边界(首个可玩版本做什么、不做什么)。其中一项仍只是推断、没有出现在用户需求或回答中,就只问一个最关键的问题,不能用 assumption_pending 代替。主题包装、美术、数值和次要系统可以用 assumption_pending,answerSource 记为 agent_inferred。不要重复已回答的问题。修改以最新用户意见为准,提交完整 GDD,不要打补丁。", + session.session_id, + session.turn_index, + planning_v2_question_policy(session), + ) +} + 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 - ), - }; - // `question_limit` 是 Runtime 对“已展示问题数”的硬上限;模型提示词中的 - // “默认最多三轮”只是策略偏好,两者不要求数值一致。这里仅传当前已展示数, - // 不把硬上限数值直接广告给模型;达到硬上限时再明确禁止本次继续提问。 - let output_shape = r#" -问询: -{ - "kind": "question", - "question": { - "id": "snake_case_id", - "header": "当前要决定:...", - "question": "...", - "options": [ - {"label": "方案 A", "description": "..."}, - {"label": "方案 B", "description": "..."} - ] - } -} - -GDD: -{ - "kind": "gdd", - "gdd": { - "schemaVersion": "plan-gdd.v2", - "game": { - "title": "中文标题", - "genre": {"primary": "类型", "fusion": null}, - "artStyle": { - "visualType": "视觉类型", - "keywords": ["关键词"], - "moodAndColor": "氛围与色彩", - "mvpArtBoundary": "MVP 美术边界" - }, - "oneLiner": "一句话概念", - "pillars": [ - {"name": "支柱名称", "playerFeel": "玩家感受", "mechanism": "实现机制", "decisionState": "confirmed"} - ], - "coreLoop": ["核心循环步骤"], - "targetUsers": { - "coreUsers": "核心用户", - "preferences": "用户偏好", - "sessionLength": "单局时长", - "referenceGames": [] - }, - "mvpSystems": [ - {"system": "系统名称", "minimalFunction": "最小功能", "whyRequired": "为什么必须有", "verifyMethod": "验证方式", "decisionState": "confirmed"} - ], - "outOfScope": ["暂不做的内容"], - "creatorTips": { - "doFirst": "先做什么", - "deferForNow": "暂缓什么", - "howToVerify": "如何验证", - "expandWhen": "何时扩展" - } - }, - "decisions": [ - { - "id": "initial-request", - "topic": "初始需求", - "state": "confirmed", - "answerSource": "user_freeform", - "round": 0, - "answerSummary": "用户的初始需求" - } - ], - "prototypeValidationItems": [] - } -} - -按骨架填全字段,不要增删或改名。 -game 到 creatorTips 结束。decisions、prototypeValidationItems 与 game 同级,不要放进 game。 -schemaVersion 固定 plan-gdd.v2;不要输出 platformFacts。 -state / decisionState 只能是 confirmed、assumption_pending、prototype_pending。 -decisions 第一项必须是 initial-request / confirmed / round=0。 -有 prototype_pending 才写 prototypeValidationItems,且按 id 一一对应,否则 []。 -options 2-4;keywords 3-5;pillars 2-4;coreLoop 4-8;mvpSystems 3-6;outOfScope 1-12;oneLiner 45-90 字。 -"#; - let system = platform_llm::LlmMessage::system(format!( - "你是立项策划 Agent。当前会话 {},回合 {}。只返回一个合法 JSON object,不要 Markdown、解释或代码围栏。\n\n{}\n\n出稿前必须先确认三项核心闭环:玩家核心行为(每一局反复做什么)、单局目标/核心循环(怎样算完成一局)、MVP 制作边界(首个可玩版本做什么、不做什么)。其中一项仍只是推断、没有出现在用户需求或回答中,就只问一个最关键的问题,不能用 assumption_pending 代替。主题包装、美术、数值和次要系统可以用 assumption_pending,answerSource 记为 agent_inferred。不要重复已回答的问题。\n\n{}", - session.session_id, - session.turn_index, - question_policy, - output_shape - )); + let system = platform_llm::LlmMessage::system(planning_v2_system_prompt(session)); let mut messages = vec![system]; messages.extend(context_messages); messages.push(platform_llm::LlmMessage::user(prompt.to_string())); @@ -662,10 +595,32 @@ options 2-4;keywords 3-5;pillars 2-4;coreLoop 4-8;mvpSystems 3-6;outOf .with_api_kind(api_kind) .with_model(llm.model.clone()) .with_max_output_tokens(4_096) - .with_request_timeout_ms(llm.request_timeout_ms); + .with_request_timeout_ms(llm.request_timeout_ms) + .with_function_tools(planning_v2_function_tools()) + .with_tool_choice(platform_llm::LlmToolChoice::Auto); apply_game_creator_llm_reasoning_effort(request, llm) } +fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> PlanningTurnResultV2 { + PlanningTurnResultV2 { + schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), + kind: "assistant_text".to_string(), + payload: serde_json::json!({ + "text": response.text, + "toolCalls": response.tool_calls.iter().map(|call| { + serde_json::json!({ + "id": call.id, + "name": call.name, + "arguments": call.arguments, + }) + }).collect::>(), + "finishReason": response.finish_reason, + "responseId": response.response_id, + "model": response.model, + }), + } +} + async fn invoke_provider_v2( session: &PlanningSessionV2, prompt: &str, @@ -690,16 +645,7 @@ where }) .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(), - }), - }) + Ok(planning_turn_result_from_llm(&response)) } else { let response = client .run(request) @@ -711,16 +657,7 @@ where 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, - }), - }) + Ok(planning_turn_result_from_llm(&response)) } } @@ -869,13 +806,13 @@ where policy_retry = policy_retry.saturating_add(1); let retry_detail = detail.replace('\n', "\n- "); attempt_prompt = format!( - "{}\n\n【阻断校验失败】Runtime 拒绝了上一次输出,具体原因如下:\n- {}\n请针对以上原因逐项修复,保持未涉及内容不变,只返回合法 JSON;当前只允许返回合法 question 或完整 GDD,不要输出解释文字。{}", + "{}\n\n【阻断校验失败】Runtime 拒绝了上一次输出,具体原因如下:\n- {}\n请针对以上原因逐项修复,保持未涉及内容不变,并调用 plan_ask_question 或 plan_submit_gdd;不要在正文输出 JSON,不要解释。{}", prompt, retry_detail, if start.session.question_limit.is_some_and(|limit| { start.session.question_count >= limit }) { - "当前问题数已达到上限,禁止再提问,必须直接输出 GDD。" + "当前问题数已达到上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd。" } else { "" } @@ -1181,4 +1118,23 @@ mod tests { "按第 2 个选项做" ); } + + #[test] + fn system_prompt_requires_protocol_tools_and_drops_json_skeleton() { + let session = new_session_v2("project-1".to_string(), "gdd".to_string()); + let prompt = planning_v2_system_prompt(&session); + assert!(prompt.contains("plan_ask_question")); + assert!(prompt.contains("plan_submit_gdd")); + assert!(!prompt.contains("\"kind\": \"question\"")); + assert!(!prompt.contains("按骨架填全字段")); + assert!(!prompt.contains("schemaVersion 固定")); + let limited = PlanningSessionV2 { + question_count: 8, + question_limit: Some(8), + ..session + }; + let limited_prompt = planning_v2_system_prompt(&limited); + assert!(limited_prompt.contains("禁止再调用 plan_ask_question")); + assert!(limited_prompt.contains("plan_submit_gdd")); + } } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 06c66de6d..5637c017b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-04 PlanningSessionRuntime V2 用协议工具输出问询和 GDD + +- 背景:原型已验证 `plan_ask_question` / `plan_submit_gdd` 两个协议工具、深层 schema、提示词只留策略、`tool_choice=auto` 可跑通;生产 V2 仍解析正文 `{kind,question|gdd}` JSON,并把形状骨架写在 system prompt 里。浅 schema + 正文 JSON 会误导模型把 GDD 写成普通文本;`tool_choice=required` 与 DeepSeek thinking 不能同时使用。 +- 决策:V2 Provider 请求固定挂这两个协议工具,`tool_choice=auto`,`strict=false`。模型必须恰好调用其中一个;Runtime 解析 `toolCalls` 归一为 Question/Artifact,正文 JSON 视为非法。system prompt 只保留问询/出稿策略和当前问询进度,不再附 JSON 骨架或数量清单。入参不再要求模型回声 `schemaVersion`,落盘 GDD 仍由 Runtime 写入 `plan-gdd.v2`。既有结构门禁(含 `initial-request` 首项、`validate_plan_game` 数量/字数)不变,失败仍回灌一次。不把协议工具写入 `capabilities.tools`,不执行 MCP/Skill,不把 `tool_call`/`tool_result` 写入会话消息。 +- 影响范围:`planning_session_v2.rs` 请求构造、重试文案与 Provider 结果投影;`planning_policy_v2.rs` 工具 schema、解析和入参 `schemaVersion`;V2 技术方案。 +- 验证方式:Planning V2 定向 Rust 测试覆盖工具解析、缺 `schemaVersion` 的合法 GDD、正文 JSON 拒收、工具 schema 含嵌套 `game` 字段、提示词不再含骨架;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。 + ## 2026-09-03 新建策划会话采用 PlanningSessionRuntime V2,旧 Supervisor 链路直接退役 - 背景:现有“做方案”依赖 `project-supervisor-plan` 根 Run、`project-planning` 子 Run、静态委派、delivery、Acceptance Graph 和审批前 evidence。新策划 Agent 只需要单 Agent 会话、问询、GDD 和审批;继续在旧 Runtime 上逐条放宽会保留身份/编排耦合。未来策划 Agent 可能支持无限多轮、MCP 和 Skill,需要避免把当前 8 题/GDD/no-tools 固化为 Runtime 根结构。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 722ae47c9..492963658 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,12 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-04 DeepSeek thinking 不能与 tool_choice=required 同时使用 + +- **现象**:DeepSeek V4(默认 thinking)对 `tool_choice=required` 或指定函数返回 HTTP 400:`Thinking mode does not support this tool_choice`。 +- **处理**:策划 V2 协议工具固定 `tool_choice=auto`,由 Runtime 校验必须恰好调用 `plan_ask_question` 或 `plan_submit_gdd`。不要按模型名分支,也不要用 required 强行出稿。 +- **验证**:请求体含 `tools` 且 `tool_choice=auto`;无工具调用时走既有非法输出重试。 + ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 03e049137..94efd332b 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -25,14 +25,15 @@ V2 复用底层能力,但不复用旧策划编排身份: - 复用 Provider 连接、流式响应、超时/瞬态重试、会话消息持久化、项目路径边界、单项目并发控制和原子文件写入。 - 不经过 Project Supervisor,不创建 `project-planning` 子 Run,不使用 `agent.delegate`、delivery、continuation、Acceptance Graph 或 acceptance evidence。 - 当前只启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。 -- 当前不启用 MCP、Skill、工具调用或无限问询,但会在会话、消息、上下文和能力快照中预留兼容插槽。 +- 问询和出稿通过两个协议 function tools(`plan_ask_question` / `plan_submit_gdd`)输出,`tool_choice=auto`;Runtime 解析工具参数后归一为 Question/Artifact,不执行工具、不把 `tool_call` 写入会话消息。 +- 当前不启用 MCP、Skill、第三方工具或无限问询;`capabilities.tools/skills` 仍为空,预留未来能力快照。 - 新旧会话分开持久化,不自动转换旧会话;切换时所有未完成旧会话强制失败;同一项目同一时间只允许一条策划权威会话推进。 ### 1.1 本次必须达到的结果 1. 新的“做方案”入口不再创建 `project-supervisor-plan` 根 Run。 2. 单个策划 Agent 能在同一会话中完成提问、回答、GDD 生成、审批、修改和退回。 -3. `PLAN_MAX_TURNS=8` 表示最多向用户展示 8 个有效问题;第 8 个问题允许展示,达到 8 后再次返回 question 不得展示,内部最多重试一次要求直接出 GDD。 +3. `PLAN_MAX_TURNS=8` 表示最多向用户展示 8 个有效问题;第 8 个问题允许展示,达到 8 后再次调用 `plan_ask_question` 不得展示,内部最多重试一次要求调用 `plan_submit_gdd`。 4. GDD、非法输出、Provider 请求失败和用户修改不增加有效问题数。 5. Provider 失败、进程重启或页面重新打开后,不重复已完成的 Provider 副作用,不丢失已经持久化的用户消息和 GDD 版本。 6. V2 切换时旧链路直接退役;所有未完成旧会话进入明确的 `legacy_retired` 失败状态,旧产物仍可读取。 @@ -154,11 +155,11 @@ Provider 回合在 Runtime 内统一归一为以下结果之一: ```text Question(question) Artifact(artifact) -ToolCall(toolCall) # 当前不启用,仅保留消息/事件类型 -AssistantText(text) # 当前策略只允许作为非法输出处理;未来可由 conversation 模式使用 +ToolCall(toolCall) # 仅保留消息/事件类型;当前不写入 conversation +AssistantText(text) # 当前策略视为非法输出;未来可由 conversation 模式使用 ``` -当前 `GddPlanningPolicy` 只接受 `Question` 或 `Artifact(kind=gdd)`。其它结果不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。 +Provider 请求携带 `plan_ask_question` 与 `plan_submit_gdd`,`tool_choice=auto`。`GddPlanningPolicy` 只接受恰好一个已知工具,并将其参数归一为 `Question` 或 `Artifact(kind=gdd)`。正文 JSON、多个工具或未知工具不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。 ## 4. 会话与状态合同 @@ -226,7 +227,7 @@ revision_requested | approved | rejected | provider_failed | stopped } ``` -`role` 冻结为 `user | assistant | system | tool`;`kind` 冻结为 `text | question | artifact | tool_call | tool_result | skill_reference | error`。当前 GDD 策略只产生 `text`、`question`、`artifact` 和 `error`,不执行或广告 `tool_call`、`tool_result`、`skill_reference`。未来启用 MCP/Skill 时使用已有 kind,不把工具结果伪装成普通 assistant 文本。 +`role` 冻结为 `user | assistant | system | tool`;`kind` 冻结为 `text | question | artifact | tool_call | tool_result | skill_reference | error`。当前 GDD 策略只把成功结果写成 `question`、`artifact` 和失败 `error`;协议工具只存在于 Provider 请求/响应,不把 `tool_call`/`tool_result` 写入 `conversation.jsonl`。未来启用 MCP/Skill 时使用已有 kind,不把工具结果伪装成普通 assistant 文本。 ### 4.2.2 回合结果与能力快照 @@ -240,7 +241,7 @@ Provider 适配层输出 `planning-turn-result.v2`: } ``` -`kind` 冻结为 `question | artifact | assistant_text | tool_call | error`。当前 `GddPlanningPolicy` 只接受 `question` 或 `artifact(kind=gdd)`;其它结果按非法输出处理。 +`kind` 冻结为 `question | artifact | assistant_text | tool_call | error`。协议工具解析成功后,`GddPlanningPolicy` 只落盘 `question` 或 `artifact(kind=gdd)`;正文 JSON 和其它结果按非法输出处理。 能力快照冻结为: @@ -369,7 +370,7 @@ questionCount=8,本次返回 question 其它规则: -- 非法 JSON/GDD 不增加 `questionCount`。 +- 非法工具输出/GDD 不增加 `questionCount`。 - Provider 请求失败不增加 `questionCount`,也不创建 GDD 版本。 - 用户修改不受 `questionLimit` 限制,但修改回合仍不能再次向用户展示 question;若 Provider 返回 question,按一次内部出稿重试处理。 - 达到内部输出重试上限后,保留当前会话和错误摘要,允许用户再次提交或恢复,不伪造 GDD。 @@ -717,7 +718,7 @@ hydrate_planning_session_v2 - 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。 - P3 已完成;旧会话 `legacy_retired` 封存、入口彻底关闭和真实 Provider/UI 全链路回归仍属于 P4/P5。 -P3 首轮人工测试暴露的问题已在进入 P4 前修正:做方案创建工作区不再额外调用自动项目命名 Provider;策划等待态立即显示处理中提示;V2 提示词给出问询/GDD 嵌套骨架、`game` 与 `decisions` / `prototypeValidationItems` 同级边界和一行易错数量范围,不把逐字段长度清单写入 system prompt;输出校验失败的重试提示携带具体阻断原因,要求逐项修复;失败结果不重复渲染,GDD 结构错误给出可操作的重试提示。严格解析和失败不落盘成功产物的规则保持不变。 +P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDD;Provider 请求挂 `plan_ask_question` / `plan_submit_gdd`,`tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量、字数和 `initial-request` 仍由既有校验器在失败时回灌。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。 ### P4:灰度、真实 Provider 与回归验收