策划会话瞬态Provider故障自动退避重试
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

- invoke_provider_v2 错误携带主 Runtime 同款瞬态分类,timeout/connectivity/transport/空响应/反序列化/断流/上游408、429、5xx 不再直接判死
- run_turn_v2 对瞬态故障按 maxRetries 预算指数退避自动重试本回合,耗尽后才投 provider_failed 并附已重试次数,上游 4xx 硬错误仍直接失败
- 同步策划会话 Runtime V2 文档:tool_choice 口径改为 required,补充瞬态故障重试规则
This commit is contained in:
2026-09-07 04:51:12 +00:00
parent 8eeaa43081
commit df6d885d2c
2 changed files with 69 additions and 11 deletions
@@ -841,6 +841,20 @@ fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> Pla
}
}
struct PlanningProviderFailureV2 {
detail: String,
transient_kind: Option<&'static str>,
}
impl PlanningProviderFailureV2 {
fn terminal(detail: String) -> Self {
Self {
detail,
transient_kind: None,
}
}
}
async fn invoke_provider_v2<F>(
root: &Path,
session: &PlanningSessionV2,
@@ -850,14 +864,17 @@ async fn invoke_provider_v2<F>(
prompt: &str,
context_messages: Vec<platform_llm::LlmMessage>,
mut on_delta: F,
) -> Result<PlanningTurnResultV2, String>
) -> Result<PlanningTurnResultV2, PlanningProviderFailureV2>
where
F: FnMut(&str, &str, Option<&str>) + Send,
{
let config = load_game_creator_app_config()?;
let config =
load_game_creator_app_config().map_err(PlanningProviderFailureV2::terminal)?;
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)?;
let client = build_game_creator_llm_client_from_llm_config(&llm, "planning.v2")
.map_err(PlanningProviderFailureV2::terminal)?;
let request = build_provider_request_v2(session, context_messages, prompt, &llm)
.map_err(PlanningProviderFailureV2::terminal)?;
persist_planning_debug_request_v2(
root,
debug_call_id,
@@ -891,6 +908,8 @@ where
Ok(planning_turn_result_from_llm(&response))
}
Err(error) => {
let transient_kind =
game_creator_agent_runtime_transient_provider_error_kind(&error, false);
let detail = format!("Planning V2 Provider 流式调用失败:{error}");
persist_planning_debug_response_v2(
root,
@@ -901,7 +920,10 @@ where
None,
Some(detail.as_str()),
);
Err(detail)
Err(PlanningProviderFailureV2 {
detail,
transient_kind,
})
}
}
} else {
@@ -926,6 +948,8 @@ where
Ok(planning_turn_result_from_llm(&response))
}
Err(error) => {
let transient_kind =
game_creator_agent_runtime_transient_provider_error_kind(&error, false);
let detail = format!("Planning V2 Provider 调用失败:{error}");
persist_planning_debug_response_v2(
root,
@@ -936,7 +960,10 @@ where
None,
Some(detail.as_str()),
);
Err(detail)
Err(PlanningProviderFailureV2 {
detail,
transient_kind,
})
}
}
}
@@ -1024,10 +1051,23 @@ where
let mut accumulated = String::new();
let mut attempt_prompt = prompt.clone();
let mut policy_retry = 0_u8;
let mut provider_retry = 0_u32;
let mut provider_attempt = 0_u8;
let provider_retry_llm = load_game_creator_app_config()
.map(|config| resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2"))
.ok();
let provider_max_retries = provider_retry_llm
.as_ref()
.map(|llm| llm.max_retries)
.unwrap_or(0);
let provider_retry_backoff_ms = provider_retry_llm
.as_ref()
.map(|llm| llm.retry_backoff_ms)
.unwrap_or(0);
let debug_call_id = format!("call-{}", Uuid::new_v4().simple());
let policy_output = loop {
accumulated.clear();
let provider_attempt = policy_retry.saturating_add(1);
provider_attempt = provider_attempt.saturating_add(1);
let provider_result = invoke_provider_v2(
root,
&start.session,
@@ -1043,7 +1083,24 @@ where
.await;
let result = match provider_result {
Ok(result) => result,
Err(detail) => {
Err(failure) => {
if failure.transient_kind.is_some() && provider_retry < provider_max_retries {
provider_retry = provider_retry.saturating_add(1);
let backoff_ms = game_creator_agent_runtime_transient_retry_backoff_ms(
provider_retry_backoff_ms,
provider_retry,
);
tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
continue;
}
let detail = if failure.transient_kind.is_some() && provider_max_retries > 0 {
format!(
"{}(瞬态故障已自动重试 {}/{} 次)",
failure.detail, provider_retry, provider_max_retries
)
} else {
failure.detail
};
let error = safe_error("PROVIDER_FAILED", detail);
let session = persist_turn_failure_v2(
root,
@@ -25,7 +25,7 @@ V2 复用底层能力,但不复用旧策划编排身份:
- 复用 Provider 连接、Provider 流式传输能力、超时/瞬态重试、会话消息持久化、项目路径边界、单项目并发控制和原子文件写入。这里的“Provider 流式传输能力”只描述底层请求实现,不承诺把每个文本 delta 投影给前端或用户。
- 不经过 Project Supervisor,不创建 `project-planning` 子 Run,不使用 `agent.delegate`、delivery、continuation、Acceptance Graph 或 acceptance evidence。
- 当前只启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。
- 问询和出稿通过两个协议 function tools`plan_ask_question` / `plan_submit_gdd`)输出,`tool_choice=auto`;Runtime 解析工具参数后归一为 Question/Artifact,不执行工具、不把 `tool_call` 写入会话消息。
- 问询和出稿通过两个协议 function tools`plan_ask_question` / `plan_submit_gdd`)输出,`tool_choice=required`;Runtime 解析工具参数后归一为 Question/Artifact,不执行工具、不把 `tool_call` 写入会话消息。
- 当前不启用 MCP、Skill、第三方工具或无限问询;`capabilities.tools/skills` 仍为空,预留未来能力快照。
- 新旧会话分开持久化,不自动转换旧会话;切换时所有未完成旧会话强制失败;同一项目同一时间只允许一条策划权威会话推进。
@@ -170,7 +170,7 @@ ToolCall(toolCall) # 仅保留消息/事件类型;当前不写入 conver
AssistantText(text) # 当前策略视为非法输出;未来可由 conversation 模式使用
```
Provider 请求携带 `plan_ask_question``plan_submit_gdd``tool_choice=auto``GddPlanningPolicy` 只接受恰好一个已知工具,并将其参数归一为 `Question``Artifact(kind=gdd)`。正文 JSON、多个工具或未知工具不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。
Provider 请求携带 `plan_ask_question``plan_submit_gdd``tool_choice=required``GddPlanningPolicy` 只接受恰好一个已知工具,并将其参数归一为 `Question``Artifact(kind=gdd)`。正文 JSON、多个工具或未知工具不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。
## 4. 会话与状态合同
@@ -385,6 +385,7 @@ questionCount=8,本次返回 question
- Provider 请求失败不增加 `questionCount`,也不创建 GDD 版本。
- 用户修改不受 `questionLimit` 限制,但修改回合仍不能再次向用户展示 question;若 Provider 返回 question,按一次内部出稿重试处理。
- 达到内部输出重试上限后,保留当前会话和错误摘要,允许用户再次提交或恢复,不伪造 GDD。
- 瞬态 Provider 故障不直接判死:timeout、connectivity、transport、空响应、反序列化失败、流式中途断连和上游 408/429/5xx 统一按主 Agent Runtime 同款分类判定为瞬态,会话层沿用该 Agent 的 `maxRetries` / `retryBackoffMs` 预算做指数退避自动重试本回合;重试不增加 `questionCount`、不产生 GDD 版本,debug `attempt-N` 随每次物理尝试递增。耗尽后才投影 `provider_failed`,错误摘要附带已重试次数;上游 4xx 等硬错误不进入该重试,直接 `provider_failed`
## 5. Provider、上下文与未来 MCP/Skill 兼容性
@@ -737,7 +738,7 @@ hydrate_planning_session_v2
- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。
- P3 已完成;P4 的真实 Provider、前端工作台、失败恢复和安全门禁验收已通过。旧会话 `legacy_retired` 封存与旧入口彻底关闭仍属于 P5。
P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDDProvider 请求挂 `plan_ask_question` / `plan_submit_gdd``tool_choice=auto`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量和字数由既有校验器在失败时回灌。`plan_submit_gdd` 的工具参数只包含决定和原型验证内容,不包含任何 Runtime 分配的 ID;Runtime 在落盘前为决定分配首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 顺序绑定原型验证项。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。历史 V2 数据不做兼容转换,按现有恢复/失败策略处理。
P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDDProvider 请求挂 `plan_ask_question` / `plan_submit_gdd``tool_choice=required`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量和字数由既有校验器在失败时回灌。`plan_submit_gdd` 的工具参数只包含决定和原型验证内容,不包含任何 Runtime 分配的 ID;Runtime 在落盘前为决定分配首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 顺序绑定原型验证项。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。历史 V2 数据不做兼容转换,按现有恢复/失败策略处理。
### P4:灰度、真实 Provider 与回归验收(已完成)