补充策划 Provider 诊断持久化

保存 Planning V2 每次 Provider 尝试的请求、响应和解析分类产物

诊断写入不参与工作流、恢复、重试或 GDD 判断

同步 Planning V2 技术方案与项目决策记录
This commit is contained in:
2026-09-04 13:22:34 +00:00
parent 9a692ec543
commit 1b764fd878
3 changed files with 309 additions and 15 deletions
@@ -17,6 +17,8 @@ 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_DEBUG_ROOT: &str = ".agent/planning-v2/debug";
const PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION: &str = "planning-debug.v1";
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;
@@ -163,6 +165,179 @@ fn planning_conversation_path(root: &Path) -> PathBuf {
root.join(PLANNING_SESSION_V2_CONVERSATION_PATH)
}
fn planning_debug_call_dir(root: &Path, debug_call_id: &str) -> PathBuf {
root.join(PLANNING_SESSION_V2_DEBUG_ROOT)
.join(debug_call_id)
}
fn write_planning_debug_json(
root: &Path,
debug_call_id: &str,
relative: &str,
value: &Value,
) -> Option<String> {
let path = planning_debug_call_dir(root, debug_call_id).join(relative);
let mut bytes = serde_json::to_vec_pretty(value).ok()?;
bytes.push(b'\n');
write_game_creator_private_file(&path, &bytes, "Planning V2 Provider 诊断")
.ok()
.map(|_| format!("{PLANNING_SESSION_V2_DEBUG_ROOT}/{debug_call_id}/{relative}"))
}
fn append_planning_debug_event(root: &Path, debug_call_id: &str, event: Value) {
let path = planning_debug_call_dir(root, debug_call_id).join("events.jsonl");
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let mut content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(_) => return,
};
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&line);
content.push('\n');
let _ =
write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 Provider 诊断事件");
}
fn persist_planning_debug_request_v2(
root: &Path,
debug_call_id: &str,
session: &PlanningSessionV2,
client_turn_id: &str,
provider_attempt: u8,
stream: bool,
request: &platform_llm::LlmRunRequest,
) {
let snapshot = serde_json::json!({
"schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION,
"kind": "provider_request",
"debugCallId": debug_call_id,
"sessionId": &session.session_id,
"projectId": &session.project_id,
"clientTurnId": client_turn_id,
"turnIndex": session.turn_index,
"providerAttempt": provider_attempt,
"model": request.model.as_deref(),
"apiKind": request.api_kind,
"stream": stream,
"maxOutputTokens": request.max_output_tokens,
"requestTimeoutMs": request.request_timeout_ms,
"reasoningEffort": format!("{:?}", request.response_reasoning_effort),
"textVerbosity": format!("{:?}", request.response_text_verbosity),
"toolChoice": request.tool_choice,
"messages": &request.messages,
"tools": &request.function_tools,
});
let file = format!("requests/attempt-{provider_attempt}.json");
let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot);
append_planning_debug_event(
root,
debug_call_id,
serde_json::json!({
"schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION,
"eventType": "provider_attempt_started",
"debugCallId": debug_call_id,
"sessionId": &session.session_id,
"projectId": &session.project_id,
"clientTurnId": client_turn_id,
"turnIndex": session.turn_index,
"providerAttempt": provider_attempt,
"requestFile": path,
"atUtc": current_plan_timestamp_utc(),
}),
);
}
fn persist_planning_debug_response_v2(
root: &Path,
debug_call_id: &str,
session: &PlanningSessionV2,
client_turn_id: &str,
provider_attempt: u8,
response: Option<&platform_llm::LlmRunResponse>,
error: Option<&str>,
) {
let response_value = response.map(|response| {
serde_json::json!({
"provider": response.provider,
"model": &response.model,
"responseId": &response.response_id,
"finishReason": &response.finish_reason,
"usage": &response.usage,
"text": &response.text,
"toolCalls": &response.tool_calls,
})
});
let snapshot = serde_json::json!({
"schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION,
"kind": "provider_response",
"debugCallId": debug_call_id,
"sessionId": &session.session_id,
"projectId": &session.project_id,
"clientTurnId": client_turn_id,
"turnIndex": session.turn_index,
"providerAttempt": provider_attempt,
"response": response_value,
"error": error,
"atUtc": current_plan_timestamp_utc(),
});
let file = format!("responses/attempt-{provider_attempt}.json");
let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot);
append_planning_debug_event(
root,
debug_call_id,
serde_json::json!({
"schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION,
"eventType": "provider_attempt_finished",
"debugCallId": debug_call_id,
"sessionId": &session.session_id,
"projectId": &session.project_id,
"clientTurnId": client_turn_id,
"turnIndex": session.turn_index,
"providerAttempt": provider_attempt,
"responseFile": path,
"error": error,
"atUtc": current_plan_timestamp_utc(),
}),
);
}
fn persist_planning_debug_classification_v2(
root: &Path,
debug_call_id: &str,
session: &PlanningSessionV2,
client_turn_id: &str,
provider_attempt: u8,
parsed_type: Option<&str>,
accepted: bool,
retry_scheduled: bool,
error: Option<&str>,
) {
append_planning_debug_event(
root,
debug_call_id,
serde_json::json!({
"schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION,
"eventType": "provider_output_classified",
"debugCallId": debug_call_id,
"sessionId": &session.session_id,
"projectId": &session.project_id,
"clientTurnId": client_turn_id,
"turnIndex": session.turn_index,
"providerAttempt": provider_attempt,
"parsedType": parsed_type,
"accepted": accepted,
"retryScheduled": retry_scheduled,
"error": error,
"atUtc": current_plan_timestamp_utc(),
}),
);
}
fn validate_client_turn_id(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.chars().count() > 128 || value.contains(['\r', '\n', '\0']) {
@@ -664,7 +839,11 @@ fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> Pla
}
async fn invoke_provider_v2<F>(
root: &Path,
session: &PlanningSessionV2,
client_turn_id: &str,
provider_attempt: u8,
debug_call_id: &str,
prompt: &str,
context_messages: Vec<platform_llm::LlmMessage>,
mut on_delta: F,
@@ -676,6 +855,15 @@ where
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)?;
persist_planning_debug_request_v2(
root,
debug_call_id,
session,
client_turn_id,
provider_attempt,
llm.stream,
&request,
);
if llm.stream {
let response = client
.stream_run(request, |delta| {
@@ -685,21 +873,69 @@ where
delta.finish_reason.as_deref(),
);
})
.await
.map_err(|error| format!("Planning V2 Provider 流式调用失败:{error}"))?;
Ok(planning_turn_result_from_llm(&response))
.await;
match response {
Ok(response) => {
persist_planning_debug_response_v2(
root,
debug_call_id,
session,
client_turn_id,
provider_attempt,
Some(&response),
None,
);
Ok(planning_turn_result_from_llm(&response))
}
Err(error) => {
let detail = format!("Planning V2 Provider 流式调用失败:{error}");
persist_planning_debug_response_v2(
root,
debug_call_id,
session,
client_turn_id,
provider_attempt,
None,
Some(detail.as_str()),
);
Err(detail)
}
}
} 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(planning_turn_result_from_llm(&response))
let response = client.run(request).await;
match response {
Ok(response) => {
let text = response.text.clone();
on_delta(
text.as_str(),
text.as_str(),
response.finish_reason.as_deref(),
);
persist_planning_debug_response_v2(
root,
debug_call_id,
session,
client_turn_id,
provider_attempt,
Some(&response),
None,
);
Ok(planning_turn_result_from_llm(&response))
}
Err(error) => {
let detail = format!("Planning V2 Provider 调用失败:{error}");
persist_planning_debug_response_v2(
root,
debug_call_id,
session,
client_turn_id,
provider_attempt,
None,
Some(detail.as_str()),
);
Err(detail)
}
}
}
}
@@ -782,10 +1018,16 @@ where
let mut accumulated = String::new();
let mut attempt_prompt = prompt.clone();
let mut policy_retry = 0_u8;
let debug_call_id = format!("call-{}", Uuid::new_v4().simple());
let policy_output = loop {
accumulated.clear();
let provider_attempt = policy_retry.saturating_add(1);
let provider_result = invoke_provider_v2(
root,
&start.session,
&client_turn_id,
provider_attempt,
&debug_call_id,
&attempt_prompt,
start.context_messages.clone(),
|all, _delta, _finish_reason| {
@@ -843,8 +1085,35 @@ where
}
Ok(output)
}) {
Ok(output) => break output,
Ok(output) => {
persist_planning_debug_classification_v2(
root,
&debug_call_id,
&start.session,
&client_turn_id,
provider_attempt,
Some(match &output {
PlanningPolicyOutputV2::Question(_) => "question",
PlanningPolicyOutputV2::Gdd(_) => "gdd",
}),
true,
false,
None,
);
break output;
}
Err(detail) if policy_retry < 1 => {
persist_planning_debug_classification_v2(
root,
&debug_call_id,
&start.session,
&client_turn_id,
provider_attempt,
None,
false,
true,
Some(detail.as_str()),
);
policy_retry = policy_retry.saturating_add(1);
let retry_detail = detail.replace('\n', "\n- ");
attempt_prompt = format!(
@@ -861,6 +1130,17 @@ where
);
}
Err(detail) => {
persist_planning_debug_classification_v2(
root,
&debug_call_id,
&start.session,
&client_turn_id,
provider_attempt,
None,
false,
false,
Some(detail.as_str()),
);
let error = safe_error("PLANNING_INVALID_OUTPUT", detail);
let session = persist_turn_failure_v2(
root,
@@ -23,6 +23,14 @@
- 验证方式:定向 Rust 测试确认 schema 不含 ID、无 ID 输入可生成 Runtime ID;并运行 `cargo fmt --check``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`
## 2026-09-04 Planning V2 持久化每次 Provider 尝试的诊断产物
- 背景:Provider 已成功返回但策略解析失败时,原 V2 只保留最终错误,无法核对实际请求、原始工具参数和单次重试结果。
- 决策:在 `.agent/planning-v2/debug/<call-id>/` 保存每次尝试的 request、response 和分类事件;诊断文件不进入会话上下文,不参与恢复、重试或 GDD 业务判断,写入失败不改变主流程结果。
- 影响范围:`planning_session_v2.rs` 的 Provider 调用外围和 Planning V2 技术方案持久化目录说明。
- 验证方式:通过 Provider 请求/响应产物可还原每次尝试及 `toolCalls.arguments`,并确认主流程仍按原有解析、重试和状态转换执行。
- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md``apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`
## 2026-09-04 Planning V2 把 `gdd.vN.json` 创建成功当作提交点
- 背景:V2 persist 先 create-only 写入不可变 GDD,再更新 index、Markdown、conversation 和 session。后续任一步失败会把 session 标成 `provider_failed`,但不回滚已创建文件;重试会重新生成 UUID/时间戳并撞上“已存在且内容不同”,hydrate 又只信 `current_artifact_version`,项目会卡死。
@@ -441,11 +441,17 @@ V2 使用独立目录,避免被旧 `planning_storage.rs` 的 Supervisor 身份
├─ index.json
├─ gdd.v1.json
├─ gdd.v2.json
├─ debug/
│ ├─ <call-id>/requests/attempt-*.json
│ ├─ <call-id>/responses/attempt-*.json
│ └─ <call-id>/events.jsonl
└─ approvals/
├─ v1.json
└─ v2.json
```
`debug/<call-id>/` 只保存每次 Provider 尝试的请求、原始响应和解析分类,供失败诊断使用;它不进入会话上下文,不参与恢复、重试或 GDD 业务判断。诊断产物写入失败不改变主流程结果。
继续生成同一用户可见路径:
```text