使策划Agent按回合应用模型与推理档选择

用户发起执行时读取全局选择并保存最小回合快照
工具循环和自动重试沿用快照,兼容旧会话恢复
补充请求字段与用户操作测试,同步方案和项目约定
This commit is contained in:
2026-09-20 21:15:45 +08:00
parent 78ab1e4887
commit 0af6793954
9 changed files with 347 additions and 26 deletions
@@ -334,6 +334,7 @@ fn begin_design_turn(session: &mut DesignSession, id: &str) {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.last_error = None;
session.updated_at = unix_timestamp();
@@ -444,6 +445,45 @@ fn prepare_design_decision(
Ok(approved)
}
fn select_design_turn_model(
session: &mut DesignSession,
config: &GameCreatorAppConfig,
) -> Result<(), String> {
let turn = session.turn.as_mut().ok_or("缺少当前回合")?;
session.model_id = if config.selected_model_id.trim().is_empty() {
config.llm.model.clone()
} else {
config.selected_model_id.clone()
};
turn.model_selection = Some(DesignModelSelection {
model: session.model_id.clone(),
reasoning_effort: config.llm.reasoning_effort.clone(),
});
Ok(())
}
fn resolve_design_turn_llm_config(
session: &mut DesignSession,
config: &GameCreatorAppConfig,
) -> Result<GameCreatorLlmConfig, String> {
let turn = session.turn.as_mut().ok_or("缺少当前回合")?;
// 旧活动回合恢复时保留已知模型;缺失的推理档只能从当前配置补齐一次。
let selection = turn
.model_selection
.get_or_insert_with(|| DesignModelSelection {
model: if session.model_id.trim().is_empty() {
config.llm.model.clone()
} else {
session.model_id.clone()
},
reasoning_effort: config.llm.reasoning_effort.clone(),
});
let mut llm = resolve_game_creator_llm_config_for_agent(config, "design-agent");
llm.model = selection.model.clone();
llm.reasoning_effort = selection.reasoning_effort.clone();
Ok(llm)
}
fn checkpoint_design(root: &Path, session: &DesignSession) -> Result<(), String> {
let _write =
acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "design.session")?;
@@ -696,10 +736,7 @@ async fn request_design_provider(
return request_scripted_design_provider(root, session, emit).await;
}
let config = load_game_creator_app_config()?;
let mut llm = resolve_game_creator_llm_config_for_agent(&config, "design-agent");
if !session.model_id.trim().is_empty() {
llm.model = session.model_id.clone();
}
let mut llm = resolve_design_turn_llm_config(session, &config)?;
// 此循环统一处理流中断与 HTTP 瞬态错误,避免与传输重试相乘。
let max_retries = llm.max_retries;
llm.max_retries = 0;
@@ -1003,6 +1040,14 @@ async fn finish_design_command(
run: bool,
mut emit: impl FnMut(DesignEvent) + Send,
) -> Result<DesignView, String> {
if run
&& session
.turn
.as_ref()
.is_some_and(|turn| turn.model_selection.is_none())
{
resolve_design_turn_llm_config(&mut session, &load_game_creator_app_config()?)?;
}
checkpoint_design(root, &session)?;
let turn_id = session
.turn
@@ -1071,15 +1116,15 @@ pub(crate) async fn continue_design_agent_at(
.ok_or("策划 Agent 当前正在工作")?;
let mut session = match read_design_session(root)? {
Some(session) => session,
None => new_design_session(
&project_id,
&load_game_creator_app_config()?.selected_model_id,
),
None => new_design_session(&project_id, ""),
};
if session.project_id != project_id {
return Err("策划会话与当前项目不匹配".into());
}
let run = prepare_design_input(&mut session, id, input)?;
if run {
select_design_turn_model(&mut session, &load_game_creator_app_config()?)?;
}
finish_design_command(root, resources, session, active, run, emit).await
}
@@ -1110,6 +1155,9 @@ pub(crate) async fn decide_design_phase_at(
return Err("策划会话与当前项目不匹配".into());
}
let run = prepare_design_decision(&mut session, id, request_id, approved)?;
if run {
select_design_turn_model(&mut session, &load_game_creator_app_config()?)?;
}
finish_design_command(root, resources, session, active, run, emit).await
}
@@ -1541,6 +1589,223 @@ mod tests {
fs::write(root.join("design_artifacts/project/速览卡.md"), "速览").expect("write");
}
#[test]
fn design_turn_selection_survives_config_changes_and_legacy_recovery() {
let mut config = GameCreatorAppConfig::default();
config.selected_model_id = "chosen-model".into();
config.llm.reasoning_effort = "max".into();
config.agent_llm.insert(
"design-agent".into(),
serde_json::from_value(json!({
"model": "agent-override", "reasoningEffort": "low"
}))
.unwrap(),
);
let mut session = new_design_session("project", "old-model");
begin_design_turn(&mut session, "new-turn");
select_design_turn_model(&mut session, &config).unwrap();
let saved = serde_json::to_value(&session).unwrap();
assert_eq!(
saved["turn"]["modelSelection"],
json!({
"model": "chosen-model", "reasoningEffort": "max"
})
);
config.selected_model_id = "later-model".into();
config.llm.model = "later-model".into();
config.llm.reasoning_effort = "medium".into();
let mut restored: DesignSession = serde_json::from_value(saved.clone()).unwrap();
let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap();
assert_eq!(llm.model, "chosen-model");
let request = build_design_request(&restored, &pack(), &llm).unwrap();
assert_eq!(
request.response_reasoning_effort,
Some(platform_llm::LlmResponseReasoningEffort::Max)
);
let mut legacy = saved;
legacy["turn"]
.as_object_mut()
.unwrap()
.remove("modelSelection");
let mut restored: DesignSession = serde_json::from_value(legacy).unwrap();
let llm = resolve_design_turn_llm_config(&mut restored, &config).unwrap();
assert_eq!(llm.model, "chosen-model");
assert_eq!(llm.reasoning_effort, "medium");
config.llm.reasoning_effort = "low".into();
assert_eq!(
resolve_design_turn_llm_config(&mut restored, &config)
.unwrap()
.reasoning_effort,
"medium"
);
}
#[tokio::test(flavor = "current_thread")]
async fn design_user_actions_apply_selection_and_tool_requests_keep_the_turn_snapshot() {
let (_temp, root, resources) = init_design_project();
let config_dir = tempfile::tempdir().unwrap();
let _config_guard = crate::tests::use_test_runtime_config_dir(config_dir.path().into());
let (sender, receiver) = std::sync::mpsc::channel();
let tool_response = json!({"id":"tool-response", "status":"completed", "output":[{
"type":"function_call", "id":"tool-item", "call_id":"status-call",
"name":"get_workflow_status", "arguments":"{}"
}]});
let final_response = json!({"id":"final-response", "status":"completed", "output":[{
"type":"message", "id":"reply", "role":"assistant", "status":"completed",
"content":[{"type":"output_text", "text":"完成", "annotations":[]}]
}]});
let base_url = crate::tests::spawn_mock_llm_raw_responses_with_capture(
vec![
tool_response,
final_response.clone(),
final_response.clone(),
final_response.clone(),
final_response.clone(),
final_response,
],
Some(sender),
);
let save_selection = |model: &str, effort: &str| {
fs::write(
config_dir.path().join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::to_vec(&json!({
"selectedModelId": model, "selectedModelIsDefault": false,
"llm": {"customEnabled":true, "visibleModels":["model-a","model-b"],
"apiKey":"test-design-key", "baseUrl":base_url, "model":model,
"apiKind":"openai_responses", "reasoningEffort":effort,
"stream":false, "maxRetries":0, "requestTimeoutMs":10000}
}))
.unwrap(),
)
.unwrap();
};
let expect_request = |model: &str, effort: Option<&str>| {
let raw = receiver.recv_timeout(Duration::from_secs(2)).unwrap();
let body: Value = serde_json::from_str(raw.split_once("\r\n\r\n").unwrap().1).unwrap();
assert_eq!(body["model"], model);
assert_eq!(body["reasoning"]["effort"].as_str(), effort);
};
save_selection("model-a", "high");
let mut changed = false;
let first = continue_design_agent_at(
&root,
&resources,
"first",
DesignInput::Message {
text: "需求".into(),
},
|event| {
if !changed && event.text.as_deref() == Some("正在请求 Provider…") {
save_selection("model-b", "low");
changed = true;
}
},
)
.await
.unwrap();
assert!(first.session.last_error.is_none());
assert!(changed);
expect_request("model-a", Some("high"));
expect_request("model-a", Some("high"));
let second = continue_design_agent_at(
&root,
&resources,
"second",
DesignInput::Message {
text: "继续".into(),
},
|_| {},
)
.await
.unwrap();
assert!(second.session.last_error.is_none());
expect_request("model-b", Some("low"));
let mut session = read_design_session(&root).unwrap().unwrap();
let session_id = session.session_id.clone();
session.pending_clarification = Some(DesignClarificationRequest {
request_id: "question".into(),
question: "平台?".into(),
options: vec!["PC".into()],
created_at: 1,
});
write_design_session(&root, &session).unwrap();
save_selection("model-a", "medium");
let answered = continue_design_agent_at(
&root,
&resources,
"answer",
DesignInput::Clarification {
request_id: "question".into(),
option_index: Some(0),
text: None,
},
|_| {},
)
.await
.unwrap();
assert!(answered.session.last_error.is_none());
expect_request("model-a", Some("medium"));
let mut session = read_design_session(&root).unwrap().unwrap();
session.turn.as_mut().unwrap().pending = true;
session.last_error = Some("provider failure".into());
write_design_session(&root, &session).unwrap();
save_selection("model-b", "max");
let retried =
continue_design_agent_at(&root, &resources, "retry", DesignInput::Retry, |_| {})
.await
.unwrap();
assert!(retried.session.last_error.is_none());
expect_request("model-b", Some("max"));
let mut session = read_design_session(&root).unwrap().unwrap();
concept_artifacts(&root);
let approval = submit_design_phase_for_approval(&root, &mut session).unwrap();
write_design_session(&root, &session).unwrap();
save_selection("model-a", "default");
let approved = decide_design_phase_at(
&root,
&resources,
"approve",
&approval.request_id,
true,
|_| {},
)
.await
.unwrap();
assert!(approved.session.last_error.is_none());
assert_eq!(approved.session.current_phase, "top_design");
assert_eq!(approved.session.session_id, session_id);
expect_request("model-a", None);
let saved = read_design_session(&root).unwrap().unwrap();
assert!(saved
.history
.iter()
.any(|item| item["call_id"] == "status-call"));
assert!(!serde_json::to_string(&saved)
.unwrap()
.contains("test-design-key"));
// 已处理的审批命令不采样新配置,也不重新请求 Provider。
save_selection("model-b", "low");
decide_design_phase_at(
&root,
&resources,
"approve",
&approval.request_id,
true,
|_| {},
)
.await
.unwrap();
assert_eq!(
read_design_session(&root).unwrap().unwrap().turn,
saved.turn
);
}
#[test]
fn approval_submission_skips_remaining_tools() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1552,6 +1817,7 @@ mod tests {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![
@@ -2202,6 +2468,7 @@ mod tests {
pending: true,
request_index: 0,
attempt: 0,
model_selection: None,
});
session.pending_batch = Some(DesignToolBatch {
calls: vec![call],
@@ -81,7 +81,7 @@ pub(crate) struct DesignSession {
pub(crate) engine: String,
pub(crate) session_id: String,
pub(crate) project_id: String,
/// 入口选择的 AGC 模型目录 ID;同一会话内保持稳定,不保存上游真实模型名
/// 最近一次用户执行采用的模型标识;旧活动回合缺快照时也据此恢复
#[serde(default)]
pub(crate) model_id: String,
pub(crate) current_phase: String,
@@ -124,6 +124,16 @@ pub(crate) struct DesignTurn {
pub(crate) pending: bool,
pub(crate) request_index: u64,
pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>,
}
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DesignModelSelection {
pub(crate) model: String,
pub(crate) reasoning_effort: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -280,7 +280,7 @@ pub(crate) struct TestConfigGuard {
previous: Option<Vec<u8>>,
}
struct TestRuntimeConfigDirGuard {
pub(crate) struct TestRuntimeConfigDirGuard {
_lock: StdMutexGuard<'static, ()>,
previous: Option<PathBuf>,
}
@@ -1325,7 +1325,7 @@ fn test_local_config_defaults_mock_provider_to_non_streaming_and_preserves_expli
assert_eq!(defaulted["llm"]["stream"], false);
}
fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
pub(crate) fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard {
let lock = TEST_CONFIG_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -2477,7 +2477,7 @@ fn spawn_mock_llm_tool_plan_then_transient_final_compaction(
(base_url, handle)
}
fn spawn_mock_llm_raw_responses_with_capture(
pub(crate) fn spawn_mock_llm_raw_responses_with_capture(
response_bodies: Vec<serde_json::Value>,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
@@ -0,0 +1,45 @@
# 策划 Agent 回合模型选择生效实施计划
| 字段 | 值 |
| --- | --- |
| Milestone | [回合模型选择生效里程碑](./【里程碑】策划Agent回合模型选择生效-2026-09-20.md) |
| Status | implemented(待验收) |
| Owner | 当前任务 Agent |
## 修改边界与顺序
1. 在本地 `DesignTurn` 中追加可缺省的模型/推理档选择快照,仅存两个非敏感字段。现有 UI 只投影会话摘要,无需扩展前端 DTO。
2. 策划普通发送、澄清回答、用户重试和批准阶段实际开始执行时,从现有配置加载结果获取全局模型和推理档,覆盖上一轮选择;重复命令、拒绝审批和纯读取不重新采样。
3. Provider 请求使用当前回合快照,工具循环与自动重试期间不随全局选择变化。保留其它连接配置的原解析,策划专属配置不能覆盖这两个用户选择字段。
4. 自动恢复沿用活动回合快照;旧记录缺快照时保留已有会话模型,并从当前全局配置补推理档,再由原检查点保存。保持工具幂等和审批流程。
5. 补充既有 Rust 测试,验证请求字段、新旧会话、重试、审批/澄清和恢复;更新主规范与稳定项目记忆。
## 非目标
不改两个前端控件、GameAgent、模型目录、默认值、供应商适配;不新增模型可用性检查、自动换模型或提交门禁。不保存凭据或完整配置,不引入通用快照框架。
## 验证
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_runtime::tests -- --test-threads=1`
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml design_session -- --test-threads=1`
- 改动文件 Rust 格式检查;文档索引、编码检查和 `git diff --check`
- 优先本地 HTTP fixture 核对发送给 Provider 的模型与推理档,真实 Provider 未运行时明确记录。
## 风险与回滚
模型选择快照必须在首个副作用前持久化,配置读取失败不能改变既有持久状态;旧记录自动恢复不推断历史推理档。新增可缺省字段支持现有会话读取,回滚不删除会话或产物。
时间盒:先完成最小采样/请求闭环和定向测试,再核对恢复与文档。仅在影响本里程碑判据时扩大范围。
## 验证记录
| 验证 | 结果 |
| --- | --- |
| 改动文件 Rust 格式检查、diff 检查 | 通过 |
| 文档索引、编码检查 | 通过 |
| Rust 定向测试 | 已尝试编译,未执行测试用例;不记为通过 |
| 本地 HTTP fixture | 已补充真实请求字段断言,尚未执行 |
| 桌面/真实 Provider smoke | 未运行 |
| 独立静态审查 | 已检查采样入口、重放/恢复边界、请求循环和测试辅助函数可见性,无可操作发现 |
新增用例验证回合快照与旧数据读取,以及普通消息、跨工具请求、澄清回答、用户重试、审批批准、重复审批的配置生效边界。已有恢复用例继续覆盖不确定文件操作不重复执行。控件和 GameAgent 无本步修改。
@@ -3,7 +3,7 @@
| 字段 | 值 |
| --- | --- |
| Milestone | [控件接入里程碑](./【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) |
| Status | accepted-with-baseline-limitations |
| Status | accepted |
| Owner | 当前任务 Agent |
## 修改边界
@@ -43,10 +43,7 @@
| --- | --- |
| 源码边界核对 | 两个控件文件、策划动作回调、GameAgent 提交校验及 Rust 均无修改;仅扩展控件排适用条件与策划布局 |
| 定向 ESLint | `ProjectSupervisorView.tsx``design-agent.suite.ts``harness.ts` 通过 |
| 定向界面测试 | 收集失败,执行 0 条;基线 `packages/shared/src/index.ts` 引用缺失的 `./contracts/gameDistribution` |
| AGC 类型检查 | 失败;另有 `App.tsx` 引用缺失的 `./components/game-distribution/GameDistributionPublishPanel`;未报告本次修改文件错误 |
| 桌面与窄面板 smoke、真实 Provider | 未运行;应用现有缺失导入尚未解决,不将静态检查视为实机证据 |
定向测试命令:`npm test -- apps/ai-game-creator-shell/tests/appSurface.test.ts -t 'Design Agent|design session|clarification free text|reasoning effort control|selecting the model|model dropdown|chat composer an inset|current turn reasoning|historical reasoning'`
缺失文件通过 Git HEAD 核对为已有问题。本次不补替身或顺带修复;用户接受此验证限制并授权提交、进入第二步。基线恢复后可重跑上述测试、类型检查及宽窄布局 smoke,未运行项不记为通过。
用户已授权提交第一步、进入第二步。
@@ -3,9 +3,9 @@
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | proposed |
| Status | implemented(待验收) |
| Date | 2026-09-20 |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择提案2026-09-20) |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) |
## 目标
@@ -3,9 +3,9 @@
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | accepted(用户接受基线验证限制并授权提交、进入第二步) |
| Status | accepted(用户授权提交、进入第二步) |
| Date | 2026-09-20 |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择提案2026-09-20) |
| Parent Spec | [策划 Agent 生产迁移与工作区浏览方案 §4.1](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md#41-策划对话模型与推理档选择) |
## 目标
@@ -50,4 +50,4 @@
已接入原控件及策划布局,按用户最终要求保留策划提交、审批、澄清、重试与报错流程,没有新增模型检查。原控件与 GameAgent 的提交逻辑未修改;旧策划会话固定模型的限制仍由第二步解决。
新增三条控件集成用例(保存重进、运行中选择、保存失败反馈),但测试尚未执行:仓库基线缺少 `packages/shared/src/contracts/gameDistribution.ts`,界面测试在收集阶段失败;类型检查另报告缺少 `GameDistributionPublishPanel`。这些路径在当前 HEAD 中也不存在,并非本次删除。运行时和宽窄面板 smoke 尚未验证。用户已明确将这些问题视为 master 缺陷、不作为本次阻塞项,并授权提交第一步、进入第二步;未运行项仍不记为通过。完整验证记录见本里程碑的实施计划
新增三条控件集成用例(保存重进、运行中选择、保存失败反馈)。用户已授权提交第一步、进入第二步
@@ -22,6 +22,8 @@
- 策划 Agent 的顾问态由用户指示驱动,不自主推进项目、主动安排下一步或提交阶段审批;完成单次请求不结束顾问态。五个策划阶段的审批用于检阅已完成产物,关键选择先问询;过程文档按需记录且不重复正式正文。顶层设计按需保留易混淆方向及排除理由,提示词精简应保留这些行为与设计边界。详见策划 Agent 生产迁移与工作区浏览方案。
- 策划 Agent 复用现有模型/推理档控件,宿主不另加模型检查或自动换模型。用户发起执行时采样全局选择,同轮工具循环和自动重试固定使用回合快照;自动恢复复用该快照,旧记录保留已知模型并补齐一次推理档。只持久化模型和档位,不保存连接凭据;GameAgent 保持原逻辑。详见策划 Agent 生产迁移与工作区浏览方案 §4.1。
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 在展开正文走既有安全渲染,折叠预览使用纯文本。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色。
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
@@ -116,13 +116,13 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退
会话/回合/工具调用身份用于生产恢复和重复请求处理,与 Agent 自行写在策划文档头部的版本号无关。
当前实现:策划会话在创建时保存入口选择的 AGC 模型目录 ID(例如 `quality``fast`),同一会话后续回合沿用该 ID;客户端不保存或推断上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。下节提出按回合更新选择的变更,尚未实施,不将提案视为现行能力。
策划每次用户发起执行时采用客户端当前保存的模型和推理档,保存在当前回合中;同一执行内的工具调用与自动重试保持该选择,后续用户执行重新读取。官方模式保存 AGC 模型目录 ID(例如 `quality``fast`),自定义模式保存已选择的型号;客户端不推断官方上游真实模型名。官方 `platform-llm` 直连 api-server 时携带 AGC 客户端标记,由 api-server 根据模型目录解析实际模型,不能在客户端硬编码某个上游模型替代目录选择。
### 4.1 策划对话模型与推理档选择(提案,2026-09-20
### 4.1 策划对话模型与推理档选择
**交付结果**:策划 Agent 对话复用 GameAgent 的模型、推理档选择控件及配置通道,用户的新选择对后续策划回合实际生效,并保持 GameAgent 原有行为兼容。
本节是本次变更的唯一主规范;状态为 `in-progress`。现有生产迁移的“已完成”状态不代表本节已完成。控件接入代码已完成,验证受仓库基线缺失导入阻断;运行时模型生效逻辑尚未修改。拆分与验收见 [控件接入里程碑](../project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 和 [模型生效里程碑](../project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md)。实施前只为当前已评审里程碑编写实现计划,第一步验收后再进入第二步。
本节是本次变更的唯一主规范。控件接入已完成并提交,运行时模型生效逻辑已实现、待验收。拆分与验收见 [控件接入里程碑](../project-memory/plans/【里程碑】策划Agent模型与推理档控件接入-2026-09-20.md) 和 [模型生效里程碑](../project-memory/plans/【里程碑】策划Agent回合模型选择生效-2026-09-20.md)。
#### 范围与非目标
@@ -158,7 +158,7 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退
- 控件保存失败和目录不可用沿用控件现有提示;策划发送、审批、澄清、重试和 Provider 报错流程保持原样。原控件内部的目录同步与默认模型处理不在本次改造范围,也不在策划宿主另加同类逻辑。
- Provider 不接受所选模型或历史上下文时,沿用现有可见错误和重试;不静默换模型、清历史或另建跨模型上下文转换系统。
- 已有设计会话无需离线迁移:下次用户发起执行时采用新选择。自动恢复的旧活动回合优先保留已有模型;若尚无推理档快照,使用当次有效配置补齐一次并固定,不伪称还原了历史档位。
- 持久化仅增加恢复必需的模型选择标识和推理档快照,旧记录缺字段可读;不保存完整配置、端点凭据、Token 或 API Key。不增加平行会话账本,不改模型目录 ID 与真实型号的边界。
- 持久化在当前回合追加可缺省的 `modelSelection`,只含 `model``reasoningEffort`;已有会话 `modelId` 表示最近一次用户执行采用的模型,兼作旧活动回合恢复依据。旧记录缺字段可读;不保存完整配置、端点凭据、Token 或 API Key。不增加平行会话账本,不改模型目录 ID 与真实型号的边界。
- 本次不涉及公开 HTTP API、OpenAPI 或 SpacetimeDB schema。若本地设计会话投影需要新增字段,同步其现有 Rust/TypeScript 定义和恢复用例。
#### 两步交付与验收