建立 Provider 推理旁路契约

为 LlmStreamDelta 和 LlmRunResponse 增加独立 reasoning 字段

新增默认关闭的 reasoning 捕获开关并保持请求体不变

补齐 AGC、api-server 和 handoff 构造点及第一轮验收计划
This commit is contained in:
2026-09-14 11:42:19 +08:00
parent 33336d6242
commit 6ab7047eff
17 changed files with 152 additions and 0 deletions
@@ -2842,6 +2842,8 @@ impl CodexAppServerConnection {
callback(&platform_llm::LlmStreamDelta {
accumulated_text: streamed_text.clone(),
delta_text: delta,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None,
});
}
@@ -3146,6 +3148,7 @@ fn parse_game_creator_codex_app_server_text(
} else {
String::new()
},
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some(thread_id.to_string()),
usage: None,
@@ -589,6 +589,7 @@ fn parse_game_creator_codex_cli_response(
} else {
String::new()
},
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id,
usage,
@@ -1285,6 +1285,7 @@ mod tests {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "fake-design".into(),
text: text.into(),
reasoning: String::new(),
finish_reason: Some(if calls.is_empty() {
"stop".into()
} else {
@@ -409,6 +409,8 @@ where
(self.on_delta)(&platform_llm::LlmStreamDelta {
accumulated_text,
delta_text,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason,
});
}
@@ -499,6 +501,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible,
model: "interaction-test".to_string(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("interaction-response".to_string()),
usage: None,
@@ -115,6 +115,7 @@ fn persist_tool_plan_handoff_repair_chain(
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -146,6 +147,8 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
platform_llm::LlmStreamDelta {
accumulated_text: accumulated_text.to_string(),
delta_text: delta_text.to_string(),
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None,
}
}
@@ -1003,6 +1006,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(),
text: private_response.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1116,6 +1120,7 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1302,6 +1307,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: format!("capacity response {loop_iteration}"),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1436,6 +1442,7 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1552,6 +1559,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "cleanup handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1623,6 +1631,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "terminal handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -1718,6 +1727,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(),
text: "已成功但尚未消费的回复".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: None,
usage: None,
@@ -798,6 +798,7 @@ mod provider_reconciliation_diagnostic_tests {
let response = platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "test-model".to_string(),
reasoning: String::new(),
text: "C:\\private\\response".to_string(),
finish_reason: Some("completed".to_string()),
response_id: Some("response-1".to_string()),
@@ -905,6 +905,7 @@ mod tests {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "context-compaction-test".to_string(),
text: summary.into(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("context-compaction-response".to_string()),
usage: Some(platform_llm::LlmTokenUsage {
@@ -51,6 +51,7 @@ impl AgentRuntimeProviderHandoffRecord {
provider: self.response.provider,
model: self.response.model.clone(),
text: self.response.text.clone(),
reasoning: String::new(),
finish_reason: self.response.finish_reason.clone(),
response_id: self.response.response_id.clone(),
usage: self.response.usage.clone(),
@@ -340,6 +341,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible,
model: "handoff-model".to_string(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("response-handoff".to_string()),
usage: Some(LlmTokenUsage {
@@ -2775,6 +2775,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "provider-handoff-runner-test".to_string(),
text: "durable final reply".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("provider-handoff-response".to_string()),
usage: None,
@@ -4475,6 +4475,7 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "real-e2e-checkpoint-model".to_string(),
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()),
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
usage: None,
@@ -4720,6 +4721,7 @@ fn agent_tool_plan_llm_response(
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "mock-game-model".to_string(),
text: text.into(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()),
response_id: Some("response-tool-plan-test".to_string()),
usage: None,
@@ -127,6 +127,7 @@ impl AgentRuntimeToolPlanHandoffEntry {
provider: self.response.provider,
model: self.response.model.clone(),
text,
reasoning: String::new(),
finish_reason: self.response.finish_reason.clone(),
response_id: self.response.response_id.clone(),
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
@@ -84,6 +84,7 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
provider: LlmProvider::OpenAiCompatible,
model: "tool-plan-handoff-model".to_string(),
text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()),
response_id: Some("tool-plan-handoff-response".to_string()),
usage: Some(LlmTokenUsage {
@@ -0,0 +1,67 @@
# 【实施计划】Provider 推理契约与兼容开关
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md` |
| Status | awaiting-review |
| Owner | Codex |
## 修改边界
允许修改:
- `server-rs/crates/platform-llm/src/lib.rs` 中的 Provider 请求/响应共享类型;
- 受新增字段影响的 Rust 构造点和测试辅助函数;
- 与共享字段语义直接相关的单元测试;
- 本实施计划文档。
明确不修改:
- Responses / Chat reasoning 解析逻辑;
- `design_runtime.rs`、Tauri 事件和前端 UI
- GameAgent 的正文过滤、工具调用和消息展示逻辑;
- HTTP、OpenAPI、SpacetimeDB 和持久化 schema。
## 实现顺序
1.`LlmStreamDelta``LlmRunResponse` 中增加独立 reasoning 字段,并明确空值语义。
2.`LlmRunRequest` 增加默认关闭的 reasoning 捕获开关,确保既有请求默认行为不变。
3. 补齐共享层及 AGC Rust 侧全部结构体构造点,新增字段统一使用空值。
4. 增加契约级测试,证明默认关闭时正文、工具调用和 finish reason 语义不受影响。
5. 检查 GameAgent 及其它调用方仍只读取正文字段,形成第一轮验收证据。
## 验证命令
```text
cargo test -p platform-llm
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml
npm run check:encoding
git diff --check
```
## 风险与回滚点
- 共享结构体字段增加会触及多个构造点;以编译错误和全量搜索确保没有遗漏。
- reasoning 字段只能作为旁路数据,任何正文拼接都不属于本轮范围。
- 若发现现有调用方依赖结构体字段数量或序列化形状,停止扩展并回滚共享契约改动。
- 本轮可整体回退共享类型与构造点提交,不影响既有策划 UI 预留代码。
## 第一轮执行记录
- 已完成:`LlmRunRequest.capture_reasoning` 默认关闭,并提供显式 builder;该字段不会进入 Provider 请求体。
- 已完成:`LlmStreamDelta` 增加独立 reasoning 增量/累计字段;本轮所有现有调用点填空值。
- 已完成:`LlmRunResponse` 增加独立 reasoning 字段;本轮所有现有解析和恢复构造点填空值。
- 已确认:未修改 reasoning 解析逻辑、策划事件、前端 UI、GameAgent 正文过滤和工具调用规则。
### 第一轮验收证据
| 证据 | 结果 |
| --- | --- |
| `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS146 个测试通过 |
| `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests` | PASS |
| `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | PASS |
| `cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check` | PASS |
| `npm run check:encoding` | PASS |
| `git diff --check` | PASS |
第一轮完成后暂停,等待评审通过再进入 Provider reasoning 解析和策划 Runtime 接通。
@@ -120,6 +120,7 @@ pub async fn proxy_llm_chat_completions(
request_timeout_ms: None,
response_reasoning_effort: None,
response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(),
tool_choice: None,
};
@@ -55,6 +55,7 @@ pub fn build_gpt5_multimodal_request(
api_kind: LlmApiKind::OpenAiChat,
response_reasoning_effort: None,
response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(),
tool_choice: None,
}
+54
View File
@@ -180,6 +180,8 @@ pub struct LlmRunRequest {
pub request_timeout_ms: Option<u64>,
pub response_reasoning_effort: Option<LlmResponseReasoningEffort>,
pub response_text_verbosity: Option<LlmResponseTextVerbosity>,
/// 是否把 Provider 返回的内部 reasoning 作为独立旁路字段暴露给调用方;默认关闭。
pub capture_reasoning: bool,
pub function_tools: Vec<LlmFunctionTool>,
pub tool_choice: Option<LlmToolChoice>,
}
@@ -237,6 +239,10 @@ impl LlmResponseTextVerbosity {
pub struct LlmStreamDelta {
pub accumulated_text: String,
pub delta_text: String,
/// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。
pub accumulated_reasoning: String,
/// 当前回调的推理增量;不得追加到正文。
pub reasoning_delta: String,
pub finish_reason: Option<String>,
}
@@ -254,6 +260,8 @@ pub struct LlmRunResponse {
pub provider: LlmProvider,
pub model: String,
pub text: String,
/// 与正文分离的 Provider 推理文本;未捕获或没有数据时为空。
pub reasoning: String,
pub finish_reason: Option<String>,
pub response_id: Option<String>,
pub usage: Option<LlmTokenUsage>,
@@ -1253,6 +1261,7 @@ impl LlmRunRequest {
request_timeout_ms: None,
response_reasoning_effort: None,
response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(),
tool_choice: None,
}
@@ -1318,6 +1327,12 @@ impl LlmRunRequest {
self
}
/// 只设置本地捕获意图,不改变请求的模型、推理档位和协议请求体。
pub fn with_reasoning_capture(mut self, enabled: bool) -> Self {
self.capture_reasoning = enabled;
self
}
pub fn with_function_tools(mut self, function_tools: Vec<LlmFunctionTool>) -> Self {
self.function_tools = function_tools;
self
@@ -1914,6 +1929,7 @@ impl LlmClient {
provider: self.config.provider(),
model: resolved_model,
text: content,
reasoning: String::new(),
finish_reason: accumulation.finish_reason,
response_id,
usage: accumulation.usage,
@@ -2360,6 +2376,8 @@ where
let update = LlmStreamDelta {
accumulated_text: accumulation.text.clone(),
delta_text,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: Some(event_finish_reason),
};
on_delta(&update);
@@ -2368,6 +2386,8 @@ where
let update = LlmStreamDelta {
accumulated_text: accumulation.text.clone(),
delta_text,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None,
};
on_delta(&update);
@@ -3234,6 +3254,7 @@ fn parse_chat_completions_response(
provider,
model: parsed.model.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: String::new(),
finish_reason: first_choice.finish_reason.clone(),
response_id: parsed.id,
usage: parsed.usage,
@@ -3278,6 +3299,7 @@ fn parse_responses_response(
provider,
model: parsed.model.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: String::new(),
finish_reason: parsed.status,
response_id: parsed.id,
usage: parsed.usage.map(|usage| LlmTokenUsage {
@@ -3319,6 +3341,7 @@ fn parse_anthropic_response(
provider,
model: parsed.model.unwrap_or_else(|| fallback_model.to_string()),
text: content,
reasoning: String::new(),
finish_reason: parsed.stop_reason,
response_id: parsed.id,
usage: parsed.usage.map(map_anthropic_usage),
@@ -4305,9 +4328,40 @@ mod tests {
let request = LlmRunRequest::single_turn("系统", "用户");
assert_eq!(request.api_kind, LlmApiKind::OpenAiResponses);
assert!(!request.capture_reasoning);
assert!(
request
.clone()
.with_reasoning_capture(true)
.capture_reasoning
);
assert_eq!(request.with_openai_chat().api_kind, LlmApiKind::OpenAiChat);
}
#[test]
fn reasoning_capture_switch_does_not_change_provider_request_body() {
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
"https://example.com/v1".to_string(),
"secret".to_string(),
"model-a".to_string(),
DEFAULT_REQUEST_TIMEOUT_MS,
DEFAULT_MAX_RETRIES,
DEFAULT_RETRY_BACKOFF_MS,
)
.expect("config should be valid");
let request = LlmRunRequest::single_turn("系统", "用户");
let normal = serde_json::to_value(build_request_body(&request, &config, false))
.expect("normal body should serialize");
let capture = serde_json::to_value(build_request_body(
&request.clone().with_reasoning_capture(true),
&config,
false,
))
.expect("capture body should serialize");
assert_eq!(normal, capture);
}
fn native_responses_output_fixture() -> Vec<serde_json::Value> {
vec![
serde_json::json!({
@@ -419,6 +419,7 @@ pub fn llm_response_from_provider_response(
provider,
model: response.model().to_string(),
text: text_parts.join(""),
reasoning: String::new(),
finish_reason: response.finish_reason().map(str::to_string),
response_id: response.response_id().map(str::to_string),
usage: response.usage().map(|usage| crate::LlmTokenUsage {
@@ -882,6 +883,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible,
model: "model-1".to_string(),
text: "完成".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("upstream-response".to_string()),
usage: Some(LlmTokenUsage {