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 779c86293..5b2294bc0 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 @@ -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 { + 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 { 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( + root: &Path, session: &PlanningSessionV2, + client_turn_id: &str, + provider_attempt: u8, + debug_call_id: &str, prompt: &str, context_messages: Vec, 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, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9f0c6dd29..f44490445 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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//` 保存每次尝试的 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`,项目会卡死。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 2f0d97a0b..2d49ca831 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -441,11 +441,17 @@ V2 使用独立目录,避免被旧 `planning_storage.rs` 的 Supervisor 身份 ├─ index.json ├─ gdd.v1.json ├─ gdd.v2.json +├─ debug/ +│ ├─ /requests/attempt-*.json +│ ├─ /responses/attempt-*.json +│ └─ /events.jsonl └─ approvals/ ├─ v1.json └─ v2.json ``` +`debug//` 只保存每次 Provider 尝试的请求、原始响应和解析分类,供失败诊断使用;它不进入会话上下文,不参与恢复、重试或 GDD 业务判断。诊断产物写入失败不改变主流程结果。 + 继续生成同一用户可见路径: ```text