Compare commits

...

9 Commits

Author SHA1 Message Date
lhk229 fd88e516b8 Merge branch 'master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Frontend tests (pull_request) Successful in 3m15s
Project CI / Backend tests (pull_request) Successful in 5m26s
Project CI / Native shell tests (pull_request) Successful in 21m10s
2026-09-14 13:24:15 +08:00
lhk229 1b7bc95914 Merge remote-tracking branch 'origin/master' into opt/design_agent
Project CI / Repository checks (pull_request) Successful in 2m35s
Project CI / Frontend tests (pull_request) Successful in 3m20s
Project CI / Backend tests (pull_request) Successful in 8m8s
Project CI / Native shell tests (pull_request) Successful in 20m14s
2026-09-14 13:08:33 +08:00
lhk229 b4a9be4581 清理 reasoning 改造临时计划
删除已完成的五份实施计划

保留里程碑规范与最终验收证据
2026-09-14 13:03:21 +08:00
lhk229 e174b6dcf4 完成 reasoning 最终回归与验收
补充 Responses reasoning 与工具调用共存回归测试

补充策划 Runtime 重试清理 reasoning 测试

同步 platform-llm README 与策划 Agent 技术方案

记录第五轮验收证据与未验证边界
2026-09-14 13:01:19 +08:00
lhk229 e8f9929630 接通策划 Agent reasoning 展示
仅为策划 Provider 请求开启 reasoning 捕获

将流式与终态 reasoning 映射到已有折叠事件

补齐回合重试、错误和项目切换的状态清理测试
2026-09-14 12:46:30 +08:00
lhk229 deb327ce1f 锁定 Provider 适配层兼容行为
验证 neutral adapter 默认关闭 reasoning 捕获

补充带 reasoning 上游响应的正文流回归覆盖

确认 GameAgent 与 AGC Runtime 继续只消费正文字段
2026-09-14 12:31:29 +08:00
lhk229 0635ddfdb1 实现 Provider reasoning 协议解析
解析 Chat 与 Responses 的 reasoning 字段和流式事件

保持正文、工具调用与 GameAgent 默认行为不变

补充 reasoning 分流、终态快照与兼容开关测试
2026-09-14 12:10:59 +08:00
lhk229 6ab7047eff 建立 Provider 推理旁路契约
为 LlmStreamDelta 和 LlmRunResponse 增加独立 reasoning 字段

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

补齐 AGC、api-server 和 handoff 构造点及第一轮验收计划
2026-09-14 11:42:19 +08:00
lhk229 33336d6242 制定 Provider 推理与正文分离改造方案
新增 Issue #331 的分步里程碑方案

明确策划 Agent 与 GameAgent 的兼容边界

补充验收标准、风险与回滚点
2026-09-14 11:07:51 +08:00
20 changed files with 744 additions and 28 deletions
@@ -2829,6 +2829,8 @@ impl CodexAppServerConnection {
callback(&platform_llm::LlmStreamDelta { callback(&platform_llm::LlmStreamDelta {
accumulated_text: streamed_text.clone(), accumulated_text: streamed_text.clone(),
delta_text: delta, delta_text: delta,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None, finish_reason: None,
}); });
} }
@@ -3133,6 +3135,7 @@ fn parse_game_creator_codex_app_server_text(
} else { } else {
String::new() String::new()
}, },
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some(thread_id.to_string()), response_id: Some(thread_id.to_string()),
usage: None, usage: None,
@@ -589,6 +589,7 @@ fn parse_game_creator_codex_cli_response(
} else { } else {
String::new() String::new()
}, },
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id, response_id,
usage, usage,
@@ -104,6 +104,17 @@ fn design_event(
} }
} }
fn design_reasoning_event(
root: &Path,
turn_id: &str,
id: Option<&str>,
reasoning: String,
) -> DesignEvent {
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
event.reasoning_text = Some(reasoning);
event
}
fn design_project_id(root: &Path) -> Result<String, String> { fn design_project_id(root: &Path) -> Result<String, String> {
validate_project_root(root)?; validate_project_root(root)?;
Ok(read_existing_manifest_for_project(root)?.project_id) Ok(read_existing_manifest_for_project(root)?.project_id)
@@ -462,6 +473,7 @@ fn build_design_request(
.with_tool_choice(platform_llm::LlmToolChoice::Auto) .with_tool_choice(platform_llm::LlmToolChoice::Auto)
.with_web_search(false); .with_web_search(false);
apply_game_creator_llm_reasoning_effort(request, llm) apply_game_creator_llm_reasoning_effort(request, llm)
.map(|request| request.with_reasoning_capture(true))
} }
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。 // 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
@@ -548,6 +560,12 @@ async fn request_design_provider(
Some(String::new()), Some(String::new()),
None, None,
)); ));
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
let result = if llm.stream { let result = if llm.stream {
let mut stream_sequence = 0_u64; let mut stream_sequence = 0_u64;
client client
@@ -565,18 +583,30 @@ async fn request_design_provider(
"model": llm.model, "model": llm.model,
"deltaChars": delta.delta_text.chars().count(), "deltaChars": delta.delta_text.chars().count(),
"accumulatedChars": delta.accumulated_text.chars().count(), "accumulatedChars": delta.accumulated_text.chars().count(),
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
"deltaText": delta.delta_text, "deltaText": delta.delta_text,
"finishReason": delta.finish_reason, "finishReason": delta.finish_reason,
}), }),
); );
emit(design_event( if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
root, emit(design_event(
&turn_id, root,
"text", &turn_id,
Some(&message_id), "text",
Some(delta.accumulated_text.clone()), Some(&message_id),
None, Some(delta.accumulated_text.clone()),
)); None,
));
}
if !delta.reasoning_delta.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
delta.accumulated_reasoning.clone(),
));
}
}) })
.await .await
} else { } else {
@@ -584,6 +614,14 @@ async fn request_design_provider(
}; };
match result { match result {
Ok(response) => { Ok(response) => {
if !response.reasoning.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
response.reasoning.clone(),
));
}
design_debug( design_debug(
root, root,
"response", "response",
@@ -606,6 +644,12 @@ async fn request_design_provider(
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false) || game_creator_agent_runtime_transient_provider_error_kind(&error, false)
.is_none() .is_none()
{ {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err(detail); return Err(detail);
} }
tokio::time::sleep(Duration::from_millis( tokio::time::sleep(Duration::from_millis(
@@ -654,8 +698,24 @@ async fn request_scripted_design_provider(
Some(String::new()), Some(String::new()),
None, None,
)); ));
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
match fake_provider::take() { match fake_provider::take() {
Some(Ok(response)) => return Ok(response), Some(Ok(response)) => {
if !response.reasoning.is_empty() {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
response.reasoning.clone(),
));
}
return Ok(response);
}
Some(Err(error)) => { Some(Err(error)) => {
let detail = redact_agent_runtime_error( let detail = redact_agent_runtime_error(
root, root,
@@ -666,10 +726,24 @@ async fn request_scripted_design_provider(
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false) || game_creator_agent_runtime_transient_provider_error_kind(&error, false)
.is_none() .is_none()
{ {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err(detail); return Err(detail);
} }
} }
None => return Err("假 Provider 脚本耗尽".into()), None => {
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
String::new(),
));
return Err("假 Provider 脚本耗尽".into());
}
} }
} }
unreachable!() unreachable!()
@@ -1285,6 +1359,7 @@ mod tests {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "fake-design".into(), model: "fake-design".into(),
text: text.into(), text: text.into(),
reasoning: String::new(),
finish_reason: Some(if calls.is_empty() { finish_reason: Some(if calls.is_empty() {
"stop".into() "stop".into()
} else { } else {
@@ -1345,6 +1420,69 @@ mod tests {
.clone() .clone()
} }
#[test]
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
let session = new_design_session("project", "quality");
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
.expect("design request");
assert!(request.capture_reasoning);
}
#[tokio::test(flavor = "current_thread")]
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
let (_temp, root, _resources) = init_design_project();
let mut session = new_design_session("design-fake", "quality");
begin_design_turn(&mut session, "turn-reasoning");
let mut response = fake_response("reasoning", "正文", Vec::new());
response.reasoning = "先分析需求,再组织方案。".into();
let _fake = fake_provider::install(vec![Ok(response)], 0);
let mut events = Vec::new();
let response =
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
.await
.expect("scripted provider");
let reasoning_events = events
.iter()
.filter_map(|event| event.reasoning_text.as_deref())
.collect::<Vec<_>>();
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
assert_eq!(response.text, "正文");
assert!(session.history.is_empty());
}
#[tokio::test(flavor = "current_thread")]
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
let (_temp, root, _resources) = init_design_project();
let mut session = new_design_session("design-fake", "quality");
begin_design_turn(&mut session, "turn-reasoning-retry");
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
response.reasoning = "重试后的推理".into();
let _fake = fake_provider::install(
vec![
Err(platform_llm::LlmError::Upstream {
status_code: 503,
message: "busy".into(),
}),
Ok(response),
],
1,
);
let mut events = Vec::new();
let response =
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
.await
.expect("scripted retry provider");
let reasoning_events = events
.iter()
.filter_map(|event| event.reasoning_text.as_deref())
.collect::<Vec<_>>();
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
assert_eq!(response.text, "重试后的正文");
assert!(session.history.is_empty());
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn fake_provider_walks_five_phases_and_enters_consultant() { async fn fake_provider_walks_five_phases_and_enters_consultant() {
let (_temp, root, resources) = init_design_project(); let (_temp, root, resources) = init_design_project();
@@ -409,6 +409,8 @@ where
(self.on_delta)(&platform_llm::LlmStreamDelta { (self.on_delta)(&platform_llm::LlmStreamDelta {
accumulated_text, accumulated_text,
delta_text, delta_text,
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason, finish_reason,
}); });
} }
@@ -499,6 +501,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible, provider: LlmProvider::OpenAiCompatible,
model: "interaction-test".to_string(), model: "interaction-test".to_string(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some("interaction-response".to_string()), response_id: Some("interaction-response".to_string()),
usage: None, usage: None,
@@ -115,6 +115,7 @@ fn persist_tool_plan_handoff_repair_chain(
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -146,6 +147,8 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
platform_llm::LlmStreamDelta { platform_llm::LlmStreamDelta {
accumulated_text: accumulated_text.to_string(), accumulated_text: accumulated_text.to_string(),
delta_text: delta_text.to_string(), delta_text: delta_text.to_string(),
accumulated_reasoning: String::new(),
reasoning_delta: String::new(),
finish_reason: None, finish_reason: None,
} }
} }
@@ -1003,6 +1006,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(), model: old_llm.model.clone(),
text: private_response.to_string(), text: private_response.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: 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, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: old_llm.model.clone(), model: old_llm.model.clone(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -1302,6 +1307,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: format!("capacity response {loop_iteration}"), text: format!("capacity response {loop_iteration}"),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: 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, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -1552,6 +1559,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: "cleanup handoff".to_string(), text: "cleanup handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -1623,6 +1631,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: "terminal handoff".to_string(), text: "terminal handoff".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -1718,6 +1727,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: llm.model.clone(), model: llm.model.clone(),
text: "已成功但尚未消费的回复".to_string(), text: "已成功但尚未消费的回复".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: None, response_id: None,
usage: None, usage: None,
@@ -798,6 +798,7 @@ mod provider_reconciliation_diagnostic_tests {
let response = platform_llm::LlmRunResponse { let response = platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "test-model".to_string(), model: "test-model".to_string(),
reasoning: String::new(),
text: "C:\\private\\response".to_string(), text: "C:\\private\\response".to_string(),
finish_reason: Some("completed".to_string()), finish_reason: Some("completed".to_string()),
response_id: Some("response-1".to_string()), response_id: Some("response-1".to_string()),
@@ -905,6 +905,7 @@ mod tests {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "context-compaction-test".to_string(), model: "context-compaction-test".to_string(),
text: summary.into(), text: summary.into(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some("context-compaction-response".to_string()), response_id: Some("context-compaction-response".to_string()),
usage: Some(platform_llm::LlmTokenUsage { usage: Some(platform_llm::LlmTokenUsage {
@@ -51,6 +51,7 @@ impl AgentRuntimeProviderHandoffRecord {
provider: self.response.provider, provider: self.response.provider,
model: self.response.model.clone(), model: self.response.model.clone(),
text: self.response.text.clone(), text: self.response.text.clone(),
reasoning: String::new(),
finish_reason: self.response.finish_reason.clone(), finish_reason: self.response.finish_reason.clone(),
response_id: self.response.response_id.clone(), response_id: self.response.response_id.clone(),
usage: self.response.usage.clone(), usage: self.response.usage.clone(),
@@ -340,6 +341,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible, provider: LlmProvider::OpenAiCompatible,
model: "handoff-model".to_string(), model: "handoff-model".to_string(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some("response-handoff".to_string()), response_id: Some("response-handoff".to_string()),
usage: Some(LlmTokenUsage { usage: Some(LlmTokenUsage {
@@ -2775,6 +2775,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "provider-handoff-runner-test".to_string(), model: "provider-handoff-runner-test".to_string(),
text: "durable final reply".to_string(), text: "durable final reply".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some("provider-handoff-response".to_string()), response_id: Some("provider-handoff-response".to_string()),
usage: None, usage: None,
@@ -4475,6 +4475,7 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "real-e2e-checkpoint-model".to_string(), model: "real-e2e-checkpoint-model".to_string(),
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(), text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()), finish_reason: Some("tool_calls".to_string()),
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()), response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
usage: None, usage: None,
@@ -4720,6 +4721,7 @@ fn agent_tool_plan_llm_response(
provider: platform_llm::LlmProvider::OpenAiCompatible, provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "mock-game-model".to_string(), model: "mock-game-model".to_string(),
text: text.into(), text: text.into(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()), finish_reason: Some("tool_calls".to_string()),
response_id: Some("response-tool-plan-test".to_string()), response_id: Some("response-tool-plan-test".to_string()),
usage: None, usage: None,
@@ -127,6 +127,7 @@ impl AgentRuntimeToolPlanHandoffEntry {
provider: self.response.provider, provider: self.response.provider,
model: self.response.model.clone(), model: self.response.model.clone(),
text, text,
reasoning: String::new(),
finish_reason: self.response.finish_reason.clone(), finish_reason: self.response.finish_reason.clone(),
response_id: self.response.response_id.clone(), response_id: self.response.response_id.clone(),
usage: self.response.usage.as_ref().map(LlmTokenUsage::from), 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, provider: LlmProvider::OpenAiCompatible,
model: "tool-plan-handoff-model".to_string(), model: "tool-plan-handoff-model".to_string(),
text: text.to_string(), text: text.to_string(),
reasoning: String::new(),
finish_reason: Some("tool_calls".to_string()), finish_reason: Some("tool_calls".to_string()),
response_id: Some("tool-plan-handoff-response".to_string()), response_id: Some("tool-plan-handoff-response".to_string()),
usage: Some(LlmTokenUsage { usage: Some(LlmTokenUsage {
+4
View File
@@ -1041,6 +1041,7 @@ export function App({
setChatAgentBusy(true); setChatAgentBusy(true);
setProjectSupervisorRuntimeError(''); setProjectSupervisorRuntimeError('');
setPlanningV2TransientReplyTarget(''); setPlanningV2TransientReplyTarget('');
setPlanningV2Reasoning('');
try { try {
const view = await invoke<DesignView>('continue_design_agent_session', { const view = await invoke<DesignView>('continue_design_agent_session', {
projectPath: nextProjectPath, projectPath: nextProjectPath,
@@ -1555,6 +1556,7 @@ export function App({
setProjectSupervisorRuntimeError(''); setProjectSupervisorRuntimeError('');
setPlanningV2Session(null); setPlanningV2Session(null);
setPlanningV2TransientReplyTarget(''); setPlanningV2TransientReplyTarget('');
setPlanningV2Reasoning('');
setPlanningV2Active(planningStartMode); setPlanningV2Active(planningStartMode);
planningV2ActiveRef.current = planningStartMode; planningV2ActiveRef.current = planningStartMode;
designAgentLaneRef.current = planningStartMode; designAgentLaneRef.current = planningStartMode;
@@ -6072,6 +6074,7 @@ export function App({
setChatAgentBusy(true); setChatAgentBusy(true);
setProjectSupervisorRuntimeError(''); setProjectSupervisorRuntimeError('');
setPlanningV2TransientReplyTarget(''); setPlanningV2TransientReplyTarget('');
setPlanningV2Reasoning('');
try { try {
const result = currentSessionId const result = currentSessionId
? await invoke<PlanningSessionCommandResultV2>( ? await invoke<PlanningSessionCommandResultV2>(
@@ -11825,6 +11828,7 @@ export function App({
clientTurnId, clientTurnId,
}; };
setPlanningV2TransientReplyTarget(''); setPlanningV2TransientReplyTarget('');
setPlanningV2Reasoning('');
setChatAgentBusy(true); setChatAgentBusy(true);
setPlanGddDecisionBusy(true); setPlanGddDecisionBusy(true);
void invoke<DesignView>('decide_design_phase', { void invoke<DesignView>('decide_design_phase', {
@@ -0,0 +1,194 @@
# 【里程碑】Provider 推理与正文分离及策划 Agent 展示
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | awaiting-review |
| Date | 2026-09-14 |
| Parent Spec | `docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md` |
| Related Issue | `GenarrativeAI/Genarrative#331` |
## 一句话交付结果
让策划 Agent 能在流式回合中单独收到 Provider reasoning,并在 UI 中以默认折叠的思考过程展示;用户可见正文、工具调用和 GameAgent 现有行为保持不变。
## 背景与现状
- `platform-llm` 当前只向上层提供正文累计值、正文增量和结束状态。
- Chat 兼容响应中的 `reasoning``reasoning_content` 以及 reasoning content part 会被正文提取器过滤。
- Responses 响应中的 reasoning 类型 output item 也不会进入独立的上层字段。
- 策划 Agent 已经预留 `DesignEvent.reasoningText``planningV2Reasoning` 和默认折叠 UI,但 Provider 解析链没有产出数据,因此折叠区通常不出现。
- GameAgent 当前只消费 `delta_text``accumulated_text``finish_reason`,没有消费策划 Agent 的 `reasoningText`
## 目标
1. 为 Provider 流式响应增加独立 reasoning 增量和累计通道。
2. 为非流式终态响应提供独立 reasoning 字段。
3. 支持 Responses 和 Chat 兼容协议的 reasoning 解析。
4. 仅由策划 Agent 显式启用 reasoning 捕获和 UI 转发。
5. 保证 reasoning 不进入用户可见正文、工具调用参数或 GameAgent 消息流。
6. 在无 reasoning、reasoning 解析异常、重试和工具调用共存场景下保持可恢复行为。
## 非目标
- 不改变 GameAgent 的正文展示、工具调用、`<think>` 过滤和运行时状态语义。
- 不把 reasoning 自动拼接到 `delta_text``accumulated_text` 或正式 assistant 消息。
- 不把 reasoning 作为新的业务消息类型写入策划会话历史。
- 不新增通用 reasoning UI,不改造 Direct/Codex 的过程卡展示。
- 不修改 Provider 请求模型、推理档位或 token 预算。
- 不为 reasoning 增加新的 SpacetimeDB 表、公开 API 或持久化 schema。
## 受影响模块与边界
### Provider 共享层
`server-rs/crates/platform-llm` 负责协议解析和流式累计:
- `LlmStreamDelta` 增加 `reasoning_delta``accumulated_reasoning`
- `LlmRunResponse` 增加终态 reasoning 字段。
- `LlmRunRequest` 增加默认关闭的 reasoning 捕获开关。
- 正文提取继续排除隐藏 reasoning partreasoning 进入旁路字段。
- reasoning 解析失败只丢弃 reasoning,不影响正文和工具调用。
### 策划 Runtime
`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs` 仅在策划专用请求中打开 reasoning 捕获:
- 流式 reasoning 更新映射到已有 `DesignEvent.reasoningText`
- 正文继续使用已有 `text` 事件。
- 新回合、重试、项目切换和请求失败时清理旧 reasoning。
- debug 记录与正文记录分开,内容受现有 debug 开关和长度限制约束。
### 其它调用方
GameAgent、Agent Interaction、Direct/Codex 适配层和通用 runtime 继续只读取正文字段。新增 reasoning 字段默认为空,不改变这些调用方的业务判断。
### 前端
复用现有 `ProjectSupervisorView``designReasoning` 和默认折叠 `<details>` 展示。只补事件生命周期和状态清理,不新建平行组件或平行状态协议。
## 分步实施方案
### 第一步:冻结共享契约与兼容开关
明确字段语义、空值语义和捕获开关:
- reasoning 字段只表示 Provider 返回的内部推理内容,不代表用户正文。
- 捕获开关默认关闭;未启用时新增字段为空。
- 正文、工具调用、finish reason 和 Responses 原生 output 的现有语义保持不变。
- 该步只更新规范、类型定义和构造点,不接入策划 UI。
验收重点:所有现有 Rust 构造点可编译,GameAgent 现有调用仍只依赖正文字段。
### 第二步:实现 `platform-llm` 协议解析
分别补齐:
- Responses reasoning 增量事件;
- Responses 终态 reasoning output item / summary
- Chat `reasoning``reasoning_content` 和 reasoning content part
- 正文与 reasoning 的独立累计;
- reasoning 与正文、工具调用同时出现时的顺序和去重;
- reasoning 解析失败时的降级行为。
Responses 的原生 output 仍按当前方式保留,用于后续 Responses 会话回放;新增 reasoning 字段只用于上层展示和调试消费。
验收重点:正文永远不含 reasoning;无 reasoning 的响应与当前行为一致。
### 第三步:补齐共享适配层并锁定 GameAgent 不变
更新 `LlmStreamDelta` 构造点、适配器和测试辅助函数,使它们为新增字段提供空值。检查并锁定:
- GameAgent 正文流不读取 reasoning
- 工具调用判断不读取 reasoning;
- Direct/Codex 过程卡不显示 reasoning
- 通用 response stream 过滤逻辑不因新增字段改变。
验收重点:现有工具调用、正文流式、Direct 和 Agent Interaction 测试无行为回归。
### 第四步:接通策划 Runtime 与现有 UI
仅在策划 Agent Provider 请求中启用捕获开关:
- 收到 reasoning 增量时发出独立 `reasoningText`
- 收到正文增量时继续发出原有 `text`
- 重试时替换同一回合的临时 reasoning,不残留上一 attempt
- 正式回合结束后保留本回合展示,下一回合开始时清理;
- UI 默认折叠,展开后显示累计 reasoning,不影响正文滚动和输入。
验收重点:策划 Agent 能看到独立 reasoning,正文气泡不重复、不混入推理文本。
### 第五步:完成回归、文档和验收证据
形成逐条证据矩阵,至少覆盖:
- Responses reasoning 增量和终态;
- Chat reasoning 字段和 content part
- 正文与 reasoning 分离;
- reasoning 与工具调用并存;
- reasoning 解析失败降级;
- 无 reasoning 兼容行为;
- 策划 Runtime 事件映射和 UI 生命周期;
- GameAgent 正文与工具调用回归。
## 第五轮验收证据
| 验收面 | 证据 | 结果 |
| --- | --- | --- |
| Chat / Responses reasoning 解析 | `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS152 个单元测试;含字段、content part、SSE 增量、终态快照和正文隔离 |
| reasoning 与工具调用共存 | `responses_response_captures_reasoning_alongside_tool_call`、既有 Chat/Responses 流式工具测试 | PASS |
| 默认关闭与请求兼容 | `run_request_defaults_to_openai_responses_api_kind``reasoning_capture_switch_does_not_change_provider_request_body` | PASS |
| 策划 Runtime 生命周期 | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell design_runtime` | PASS10 个测试;含事件映射、history 隔离、重试清理和失败清理 |
| GameAgent / Direct/Codex 正文回归 | `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests` 与现有 response stream / direct tests 编译 | PASS;新增字段未进入正文消费路径 |
| 前端与文档门禁 | `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit``npm run check:encoding``npm run check:doc-index``git diff --check` | PASS |
| 格式门禁 | `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`、AGC Tauri 同命令 | PASS |
真实 Provider、浏览器运行时 smoke 和 `check-config.mjs` 的 Windows 私有 DACL 路径本轮未验证;前者需要凭据和运行环境,后者受当前沙箱权限限制,不能据此扩大验收结论。
## 契约与持久化策略
- 不修改 HTTP API、OpenAPI、SpacetimeDB schema 或生成绑定。
- 不新增正式持久化字段;策划会话仍保存既有对话和 Responses 原生 output。
- reasoning 捕获开关属于 Provider 请求的内部调用语义,默认关闭,不改变已有请求的默认指纹和展示行为。
- reasoning 不作为下一轮普通用户可见正文回灌;Responses 原生 output 的恢复语义保持现状。
## 失败、重试与恢复
- reasoning 解析失败:保留正文和工具调用,reasoning 字段置空或保留已累计部分。
- Provider 瞬态重试:reasoning 与正文使用同一回合、同一响应槽,新的 attempt 替换临时值。
- 流中断:沿用现有 Provider 错误和策划会话恢复规则,不把未完成 reasoning 误判为正式消息。
- UI 刷新或项目恢复:只从当前事件/状态恢复 reasoning,不隐式唤醒 Provider。
## 风险与回滚点
| 风险 | 控制措施 | 回滚点 |
| --- | --- | --- |
| 共享结构体新增字段导致构造点遗漏 | 先补齐所有构造点和编译检查 | 回退共享字段提交 |
| Provider 把 reasoning 混入正文 | 保留独立提取器和正文过滤测试 | 关闭 reasoning 捕获开关 |
| Responses summary 事件重复累计 | 以增量事件为主,终态仅做快照/兜底 | 关闭对应事件解析 |
| GameAgent 意外展示 reasoning | 捕获默认关闭,调用方只读正文字段 | 回退策划开关,不影响共享解析 |
| 重试残留旧 reasoning | 按回合和响应槽清理/替换 | 回退 UI 事件消费 |
## 验收命令
代码实现阶段按里程碑执行,不在本计划阶段运行业务测试。预计命令:
```text
cargo test -p platform-llm
cargo test -p ai-game-creator-shell
npm run typecheck
npm run check:encoding
git diff --check
```
文档阶段已要求补充运行:
```text
npm run check:doc-index
npm run check:encoding
git diff --check
```
## 当前状态与下一步
当前仅完成问题定位和方案设计,未修改业务代码。进入实现前应先评审本里程碑的字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 是否进入 debug 记录;评审通过后再为单个里程碑建立对应的 `【实施计划】` 文档。
@@ -346,8 +346,8 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide
开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。 开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。
## 15. 策划 Agent reasoning 展示现状 ## 15. 策划 Agent reasoning 展示
右侧栏已预留策划 Agent 的 `reasoningText` 事件字段和默认折叠的展示样式,但当前 Provider 解析链仍会过滤 reasoning 内容,尚未向策划 Runtime 产出该字段。因此现阶段只展示用户可见正文工具状态;reasoning 折叠区在没有数据时不会出现 策划 Agent 的 Provider 请求显式开启 `capture_reasoning`,共享 `platform-llm` 将 Chat / Responses 的 reasoning 通过独立字段旁路传递,策划 Runtime 映射为已有 `DesignEvent.reasoningText`,前端复用右侧栏默认折叠的思考过程展示。正文工具调用参数、正式 assistant message 和会话 history 继续使用原有字段;GameAgent、Direct/Codex 与通用 response stream 保持只消费正文的行为
后续若补充 reasoning,需要在策划 Agent 专用 Provider 解析层接入,不能直接修改共享 Provider 以免影响 GameAgent reasoning 捕获默认关闭。新回合和 Provider 重试会先清空同一响应槽的临时 reasoning,失败路径也会清理,避免旧内容残留。该能力不新增公开 API、SpacetimeDB 字段或独立 UI 组件
@@ -120,6 +120,7 @@ pub async fn proxy_llm_chat_completions(
request_timeout_ms: None, request_timeout_ms: None,
response_reasoning_effort: None, response_reasoning_effort: None,
response_text_verbosity: None, response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(), function_tools: Vec::new(),
tool_choice: None, tool_choice: None,
}; };
@@ -55,6 +55,7 @@ pub fn build_gpt5_multimodal_request(
api_kind: LlmApiKind::OpenAiChat, api_kind: LlmApiKind::OpenAiChat,
response_reasoning_effort: None, response_reasoning_effort: None,
response_text_verbosity: None, response_text_verbosity: None,
capture_reasoning: false,
function_tools: Vec::new(), function_tools: Vec::new(),
tool_choice: None, tool_choice: None,
} }
+1 -1
View File
@@ -19,7 +19,7 @@
2. `OpenAiChat``OpenAiResponses``Anthropic` 三类 API kind 都支持 JSON 请求、非流式响应和 SSE 流式响应;默认 API kind 仍为 `OpenAiResponses` 2. `OpenAiChat``OpenAiResponses``Anthropic` 三类 API kind 都支持 JSON 请求、非流式响应和 SSE 流式响应;默认 API kind 仍为 `OpenAiResponses`
3. 三类协议都使用统一的 `function_tools` / `tool_choice` 输入和 `LlmRunResponse.tool_calls` 输出。Anthropic 请求使用顶层 `tools[].input_schema` 与对象形态 `tool_choice`Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages` 3. 三类协议都使用统一的 `function_tools` / `tool_choice` 输入和 `LlmRunResponse.tool_calls` 输出。Anthropic 请求使用顶层 `tools[].input_schema` 与对象形态 `tool_choice`Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages`
4. Anthropic 当前仍不支持 `web_search`、图片内容和纯 system 消息;至少需要一条非 system 文本消息。角色动画、图片、视频、资产轮询仍留在其他平台适配和业务模块任务里。 4. Anthropic 当前仍不支持 `web_search`、图片内容和纯 system 消息;至少需要一条非 system 文本消息。角色动画、图片、视频、资产轮询仍留在其他平台适配和业务模块任务里。
5. 流式 `on_delta` 发送文本增量与完成原因;工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。上下文管理、后台执行和业务状态不写回本 crate。 5. 流式 `on_delta` 发送正文增量、可选的独立 reasoning 增量与完成原因;reasoning 只有在 `LlmRunRequest.capture_reasoning=true` 时才累计,默认关闭。工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。reasoning 不进入 `delta_text``accumulated_text`、正式 assistant message 或工具参数;上下文管理、后台执行和业务状态不写回本 crate。
6. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。 6. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。
7. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API。 7. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API。
8. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。 8. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。
File diff suppressed because it is too large Load Diff
@@ -419,6 +419,7 @@ pub fn llm_response_from_provider_response(
provider, provider,
model: response.model().to_string(), model: response.model().to_string(),
text: text_parts.join(""), text: text_parts.join(""),
reasoning: String::new(),
finish_reason: response.finish_reason().map(str::to_string), finish_reason: response.finish_reason().map(str::to_string),
response_id: response.response_id().map(str::to_string), response_id: response.response_id().map(str::to_string),
usage: response.usage().map(|usage| crate::LlmTokenUsage { usage: response.usage().map(|usage| crate::LlmTokenUsage {
@@ -807,6 +808,7 @@ mod tests {
assert_eq!(mapped.max_output_tokens, Some(2048)); assert_eq!(mapped.max_output_tokens, Some(2048));
assert_eq!(mapped.request_timeout_ms, Some(3000)); assert_eq!(mapped.request_timeout_ms, Some(3000));
assert!(mapped.enable_web_search); assert!(mapped.enable_web_search);
assert!(!mapped.capture_reasoning);
assert_eq!(mapped.function_tools.len(), 1); assert_eq!(mapped.function_tools.len(), 1);
assert!(mapped.function_tools[0].strict); assert!(mapped.function_tools[0].strict);
assert_eq!(mapped.tool_choice, Some(LlmToolChoice::Required)); assert_eq!(mapped.tool_choice, Some(LlmToolChoice::Required));
@@ -882,6 +884,7 @@ mod tests {
provider: LlmProvider::OpenAiCompatible, provider: LlmProvider::OpenAiCompatible,
model: "model-1".to_string(), model: "model-1".to_string(),
text: "完成".to_string(), text: "完成".to_string(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()), finish_reason: Some("stop".to_string()),
response_id: Some("upstream-response".to_string()), response_id: Some("upstream-response".to_string()),
usage: Some(LlmTokenUsage { usage: Some(LlmTokenUsage {
@@ -1089,7 +1092,7 @@ mod tests {
( (
"text/event-stream", "text/event-stream",
concat!( concat!(
"data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"\"},\"finish_reason\":null}]}\n\n", "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"reasoning_content\":\"隐藏\",\"content\":\"\"},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}]}\n\n", "data: {\"id\":\"loopback-stream\",\"choices\":[{\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}]}\n\n",
"data: [DONE]\n\n" "data: [DONE]\n\n"
), ),
@@ -1097,7 +1100,7 @@ mod tests {
} else { } else {
( (
"application/json", "application/json",
r#"{"id":"loopback-non-stream","model":"loopback-model","choices":[{"message":{"content":"可用"},"finish_reason":"stop"}]}"#, r#"{"id":"loopback-non-stream","model":"loopback-model","choices":[{"message":{"reasoning_content":"隐藏","content":"可用"},"finish_reason":"stop"}]}"#,
) )
}; };
let response = format!( let response = format!(