补充策划 Provider 诊断持久化
保存 Planning V2 每次 Provider 尝试的请求、响应和解析分类产物 诊断写入不参与工作流、恢复、重试或 GDD 判断 同步 Planning V2 技术方案与项目决策记录
This commit is contained in:
+295
-15
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user