将策划 V2 输出从正文 JSON 改为协议工具
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Native shell tests (pull_request) Successful in 17m12s
Project CI / Frontend tests (pull_request) Successful in 3m43s

挂 plan_ask_question / plan_submit_gdd,tool_choice 固定 auto

删除正文 JSON 解析、骨架提示词和入参 schemaVersion 必填

形状改由工具 schema 承担,既有校验门禁与落盘 plan-gdd.v2 不变

同步技术方案、决策记录和 DeepSeek thinking 排障
This commit is contained in:
2026-09-04 10:10:50 +00:00
parent 67c2f85743
commit c1d967b17d
5 changed files with 481 additions and 221 deletions
File diff suppressed because it is too large Load Diff
@@ -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_pendinganswerSource 记为 agent_inferred。不要重复已回答的问题。修改以最新用户意见为准,提交完整 GDD,不要打补丁。",
session.session_id,
session.turn_index,
planning_v2_question_policy(session),
)
}
fn build_provider_request_v2(
session: &PlanningSessionV2,
context_messages: Vec<platform_llm::LlmMessage>,
prompt: &str,
llm: &GameCreatorLlmConfig,
) -> Result<platform_llm::LlmRunRequest, String> {
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-4keywords 3-5pillars 2-4coreLoop 4-8mvpSystems 3-6outOfScope 1-12oneLiner 45-90 字。
"#;
let system = platform_llm::LlmMessage::system(format!(
"你是立项策划 Agent。当前会话 {},回合 {}。只返回一个合法 JSON object,不要 Markdown、解释或代码围栏。\n\n{}\n\n出稿前必须先确认三项核心闭环:玩家核心行为(每一局反复做什么)、单局目标/核心循环(怎样算完成一局)、MVP 制作边界(首个可玩版本做什么、不做什么)。其中一项仍只是推断、没有出现在用户需求或回答中,就只问一个最关键的问题,不能用 assumption_pending 代替。主题包装、美术、数值和次要系统可以用 assumption_pendinganswerSource 记为 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-4keywords 3-5pillars 2-4coreLoop 4-8mvpSystems 3-6outOf
.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::<Vec<_>>(),
"finishReason": response.finish_reason,
"responseId": response.response_id,
"model": response.model,
}),
}
}
async fn invoke_provider_v2<F>(
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"));
}
}
@@ -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 根结构。
@@ -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 失败。
@@ -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 输出问询/GDDProvider 请求挂 `plan_ask_question` / `plan_submit_gdd``tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量、字数和 `initial-request` 仍由既有校验器在失败时回灌。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。
### P4:灰度、真实 Provider 与回归验收