建立 Provider 推理旁路契约
为 LlmStreamDelta 和 LlmRunResponse 增加独立 reasoning 字段 新增默认关闭的 reasoning 捕获开关并保持请求体不变 补齐 AGC、api-server 和 handoff 构造点及第一轮验收计划
This commit is contained in:
@@ -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,
|
||||
|
||||
+10
@@ -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` | PASS,146 个测试通过 |
|
||||
| `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,
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user