补齐 Anthropic 原生工具与三协议流式工具调用 #112

Merged
kdletters merged 42 commits from fix/anthropic_tool into codex/ai-game-creator-app 2026-07-28 13:33:50 +08:00
18 changed files with 3213 additions and 350 deletions
@@ -609,7 +609,7 @@ function observationContext(context) {
const start = context.lastIndexOf('已有工具观察:');
if (start < 0) return '';
const tail = context.slice(start + '已有工具观察:'.length);
const end = tail.indexOf('\n\nLegacy text JSON schema');
const end = tail.indexOf('\n\n计划更新约定:');
return end < 0 ? tail : tail.slice(0, end);
}
@@ -93,17 +93,13 @@ fn agent_interaction_function_tools() -> Vec<platform_llm::LlmFunctionTool> {
.collect()
}
fn agent_interaction_system_prompt(agent_id: &str, native_tools: bool) -> String {
fn agent_interaction_system_prompt(agent_id: &str) -> String {
let role_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
game_creator_project_supervisor_chat_system_prompt()
} else {
game_creator_role_agent_chat_system_prompt()
};
let protocol = if native_tools {
"普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。"
} else {
"你必须只输出一个 JSON 对象,不要代码块或额外文字。允许的结构为:{\"action\":\"reply\",\"reply\":\"自然语言回复\"}、{\"action\":\"execute\"}、{\"action\":\"resume\"}、{\"action\":\"project_location\"}。不要根据单个关键词决定 action,要理解整句的否定、假设、范围和上下文。"
};
let protocol = "普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。";
format!(
"{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。"
)
@@ -114,7 +110,7 @@ fn build_agent_interaction_request_for_session(
agent_id: &str,
session_id: &str,
prompt: &str,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, bool), String> {
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("交互内容不能为空".to_string());
@@ -125,7 +121,6 @@ fn build_agent_interaction_request_for_session(
let (llm, config_path, context) =
build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let native_tools = api_kind != LlmApiKind::Anthropic;
let user_prompt = if context.trim().is_empty() {
format!("用户这轮输入:\n{prompt}")
} else {
@@ -133,18 +128,15 @@ fn build_agent_interaction_request_for_session(
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
)
};
let mut request = LlmRunRequest::new(vec![
LlmMessage::system(agent_interaction_system_prompt(agent_id, native_tools)),
let request = LlmRunRequest::new(vec![
LlmMessage::system(agent_interaction_system_prompt(agent_id)),
LlmMessage::user(user_prompt),
])
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS);
if native_tools {
request = request
.with_function_tools(agent_interaction_function_tools())
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
}
Ok((llm, config_path, request, native_tools))
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
.with_function_tools(agent_interaction_function_tools())
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
Ok((llm, config_path, request))
}
pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at<F>(
@@ -157,10 +149,10 @@ pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at<F>
where
F: FnMut(&platform_llm::LlmStreamDelta),
{
let (llm, config_path, request, native_tools) =
let (llm, config_path, request) =
build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?;
let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?;
let response = if native_tools && llm.stream {
let response = if llm.stream {
let fallback_request = request.clone();
match client.stream_run(request, |delta| on_delta(delta)).await {
Ok(response) => response,
File diff suppressed because one or more lines are too long
@@ -584,13 +584,30 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
}
assert_eq!(stream.status, "committed");
assert_eq!(stream.accumulated_text, fallback);
assert!(read_game_creator_agent_runtime_finalization_journal(
// finalization 先把 response stream 标为 committed,之后才调
// remove_..._finalization_recovery_sidecars 清理 sidecar,两步之间有真实时间窗。
// 上面等 committed 的循环一旦命中前半步就会退出,因此这里必须同样轮询,否则在
// CI 负载下会偶发读到尚未清理的残留。journal 是该清理链最后删除的一项,等它消失
// 即可覆盖后面几条 handoff 残留断言。
let mut finalization_residue = read_game_creator_agent_runtime_finalization_journal(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("read finalization residue")
.is_none());
.expect("read finalization residue");
for _ in 0..250 {
if finalization_residue.is_none() {
break;
}
std::thread::sleep(Duration::from_millis(20));
finalization_residue = read_game_creator_agent_runtime_finalization_journal(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
RUN_ID,
)
.expect("poll finalization residue");
}
assert!(finalization_residue.is_none());
assert!(provider_retry::read_for_run_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
@@ -380,7 +380,6 @@ async fn supervisor_collaboration_v2_batch_recovers_durable_isolated_spawn_witho
assert!(batch.collaboration_contract.is_some());
assert_eq!(batch.actions.len(), 1);
let original_batch_id = batch.batch_id.clone();
let original_agent_id = batch.agent_id.clone();
let original_session_id = batch.session_id.clone();
let original_run_id = batch.run_id.clone();
@@ -477,63 +476,30 @@ async fn supervisor_collaboration_v2_batch_recovers_durable_isolated_spawn_witho
));
let recovered_runtime =
read_game_creator_agent_runtime_at(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.expect("read recovered supervisor runtime")
wait_for_agent_runtime_lane_release_async(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.await
.state;
assert_ne!(recovered_runtime.phase, "needs-reconciliation");
assert_eq!(recovered_runtime.agent_id, original_agent_id);
assert_eq!(recovered_runtime.session_id, original_session_id);
assert_eq!(recovered_runtime.run_id, original_run_id);
let recovered_pending = read_game_creator_agent_runtime_pending_tool_action(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read recovered isolated pending action");
assert_eq!(
recovered_pending.status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
);
assert_eq!(recovered_pending.agent_id, original_agent_id);
assert_eq!(recovered_pending.session_id, original_session_id);
assert_eq!(recovered_pending.run_id, original_run_id);
assert_eq!(recovered_pending.action_id, original_action_id);
assert_eq!(
recovered_pending.action_fingerprint,
original_action_fingerprint
);
assert_eq!(
recovered_pending
.observation
.as_ref()
.map(|observation| observation.status.as_str()),
Some("ok")
);
assert!(
update_game_creator_agent_runtime_provider_batch_member(&root, &recovered_pending)
.expect("complete recovered isolated batch cursor")
!game_creator_agent_runtime_provider_action_batch_path(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.exists(),
"completed provider batch must be removed before the Agent lane releases"
);
let completed = read_game_creator_agent_runtime_provider_action_batch(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("read completed isolated provider batch");
assert_eq!(completed.batch_id, original_batch_id);
assert_eq!(completed.agent_id, original_agent_id);
assert_eq!(completed.session_id, original_session_id);
assert_eq!(completed.run_id, original_run_id);
assert_eq!(completed.next_action_index, 1);
assert_eq!(completed.status, "completed");
assert_eq!(completed.actions[0].action_id, original_action_id);
assert_eq!(
completed.actions[0].action_fingerprint,
original_action_fingerprint
);
assert_eq!(
completed.actions[0].status,
AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED
assert!(
!game_creator_agent_runtime_pending_tool_action_path(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.exists(),
"observed pending action must be removed before the Agent lane releases"
);
let summary =
@@ -556,22 +522,41 @@ async fn supervisor_collaboration_v2_batch_recovers_durable_isolated_spawn_witho
.expect("re-read isolated group after recovery");
assert_eq!(stable_group, group);
let records = read_agent_db_records_for_test(&root);
let observed_actions = records
.iter()
.filter(|record| {
record["recordType"] == "agent.runtime.tool_action.observed"
&& record["runId"] == run_id
&& record["tool"] == "agent.spawn_isolated"
})
.collect::<Vec<_>>();
assert_eq!(
records
.iter()
.filter(|record| {
record.get("recordType").and_then(Value::as_str)
== Some("agent.runtime.agent.spawn_isolated")
&& record.get("agentId").and_then(Value::as_str)
== Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
&& record.get("runId").and_then(Value::as_str) == Some(run_id)
&& record.get("actionId").and_then(Value::as_str)
== Some(original_action_id.as_str())
})
.count(),
observed_actions.len(),
1,
"recovery must persist one isolated spawn observation identity"
);
assert_eq!(observed_actions[0]["actionId"], original_action_id);
assert_eq!(
observed_actions[0]["actionFingerprint"],
original_action_fingerprint
);
assert_eq!(observed_actions[0]["observationStatus"], "ok");
let spawn_audits = records
.iter()
.filter(|record| {
record.get("recordType").and_then(Value::as_str)
== Some("agent.runtime.agent.spawn_isolated")
&& record.get("agentId").and_then(Value::as_str)
== Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
&& record.get("runId").and_then(Value::as_str) == Some(run_id)
})
.collect::<Vec<_>>();
assert_eq!(
spawn_audits.len(),
1,
"recovery must not duplicate the spawn audit",
);
assert_eq!(spawn_audits[0]["actionId"], original_action_id);
fs::remove_dir_all(root).ok();
}
@@ -148,6 +148,81 @@ fn wait_for_agent_runtime_terminal_and_lane_release(
);
}
async fn wait_for_agent_runtime_terminal_and_lane_release_async(
root: &Path,
agent_id: &str,
run_id: &str,
status: &str,
phase: &str,
) -> AgentRuntimeResult {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut last_lane_probe_error = None;
let mut result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while asynchronously waiting for terminal lane release");
loop {
let matches_terminal = result.state.run_id == run_id
&& result.state.status == status
&& result.state.phase == phase;
let lane_is_available = if matches_terminal {
match game_creator_agent_runtime_task_lock_is_available(root, agent_id) {
Ok(is_available) => is_available,
Err(error) => {
last_lane_probe_error = Some(error);
false
}
}
} else {
false
};
if lane_is_available {
let terminal = read_game_creator_agent_runtime_at(root, agent_id)
.expect("reread runtime after asynchronous terminal lane release");
if terminal.state.run_id == run_id
&& terminal.state.status == status
&& terminal.state.phase == phase
{
return terminal;
}
result = terminal;
}
assert!(
std::time::Instant::now() < deadline,
"runtime did not reach {status}/{phase} for run {run_id} before the Agent lane released; last run={} status={} phase={}; last lane probe error={}",
result.state.run_id,
result.state.status,
result.state.phase,
last_lane_probe_error.as_deref().unwrap_or("none")
);
tokio::time::sleep(Duration::from_millis(20)).await;
result = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while asynchronously waiting for terminal lane release");
}
}
async fn wait_for_agent_runtime_lane_release_async(
root: &Path,
agent_id: &str,
) -> AgentRuntimeResult {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let mut last_lane_probe_error = None;
loop {
match game_creator_agent_runtime_task_lock_is_available(root, agent_id) {
Ok(true) => {
return read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime after asynchronous lane release");
}
Ok(false) => {}
Err(error) => last_lane_probe_error = Some(error),
}
assert!(
std::time::Instant::now() < deadline,
"Agent lane did not release for {agent_id}; last lane probe error={}",
last_lane_probe_error.as_deref().unwrap_or("none")
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
fn wait_for_agent_runtime_phase(root: &Path, agent_id: &str, phase: &str) -> AgentRuntimeState {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for phase")
@@ -5505,7 +5505,10 @@ async fn background_agent_runtime_repairs_malformed_native_function_arguments()
.recv_timeout(Duration::from_secs(2))
.expect("initial native tool plan request");
assert!(initial_request.contains("\"tool_choice\":\"required\""));
assert!(initial_request.contains("提供原生函数时不得输出这段 JSON"));
// 三种协议统一使用原生工具后,legacy text JSON schema 已从 planning 请求中移除;
// 这里既断言现行原生协议约束,也防止那段失效 schema 回流。
assert!(initial_request.contains("必须直接调用当前请求提供的原生函数"));
assert!(!initial_request.contains("Legacy text JSON schema"));
assert!(initial_request.contains("arguments.input"));
assert!(initial_request.contains("禁止把 input 字段扁平到 arguments 顶层"));
assert!(initial_request.contains("必须调用 respond_to_user"));
@@ -780,6 +780,10 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
"最终回复规范化为空持久重试项目",
)
.expect("thinking-only final reply project init");
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create thinking-only final reply config dir");
let config_guard = use_test_runtime_config_dir(config_dir.clone());
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![
@@ -789,8 +793,10 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
],
Some(request_sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
replace_test_local_config(
&config_path,
format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "final-reply-empty-normalization-key",
@@ -803,7 +809,8 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
}}
}}
}}"#
));
),
);
let run_id = "provider-retry-final-reply-empty-normalization-run";
let started = start_game_creator_agent_background_task_at(
&root,
@@ -813,12 +820,12 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
)
.expect("start thinking-only final reply task");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("tool-plan Provider request");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("thinking-only final-reply Provider request");
wait_for_captured_mock_request(&request_receiver, "tool-plan Provider request").await;
wait_for_captured_mock_request(
&request_receiver,
"thinking-only final-reply Provider request",
)
.await;
let mut retry = None;
for _ in 0..250 {
retry = crate::provider_retry::read_for_run_at(&root, "design-director", run_id)
@@ -829,7 +836,7 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
{
break;
}
std::thread::sleep(Duration::from_millis(20));
tokio::time::sleep(Duration::from_millis(20)).await;
}
let retry = retry.expect("thinking-only final reply must persist retry sidecar");
assert_eq!(retry.identity.request_kind, "final-reply");
@@ -874,14 +881,20 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
.expect("force thinking-only final reply retry due");
resume_game_creator_agent_background_tasks_at(&root)
.expect("resume thinking-only final reply retry");
request_receiver
.recv_timeout(Duration::from_secs(5))
.expect("recovered final-reply Provider request");
assert!(request_receiver
.recv_timeout(Duration::from_millis(100))
.is_err());
wait_for_captured_mock_request(&request_receiver, "recovered final-reply Provider request")
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(request_receiver.try_recv().is_err());
let completed = wait_for_agent_runtime_idle(&root, "design-director");
let completed = wait_for_agent_runtime_terminal_and_lane_release_async(
&root,
"design-director",
run_id,
"idle",
"completed",
)
.await
.state;
assert_eq!(completed.phase, "completed");
assert_eq!(completed.last_response.as_deref(), Some(FINAL_RESPONSE));
assert!(
@@ -894,6 +907,11 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
.expect("read cleared thinking-only final reply handoff")
.is_none()
);
assert!(
read_game_creator_agent_runtime_finalization_journal(&root, "design-director", run_id)
.expect("read cleared thinking-only finalization journal")
.is_none()
);
let committed =
wait_for_response_stream_status(&root, "design-director", run_id, "committed", 1);
assert_eq!(committed.accumulated_text, FINAL_RESPONSE);
@@ -947,6 +965,8 @@ async fn provider_retry_final_reply_thinking_only_response_retries_before_handof
assert!(!persisted.contains(PRIVATE_THINKING));
fs::remove_dir_all(root).ok();
drop(config_guard);
fs::remove_dir_all(config_dir).ok();
}
#[test]
@@ -839,6 +839,10 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "最终回复公共失败审计测试")
.expect("project init");
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create public failure audit config dir");
let config_guard = use_test_runtime_config_dir(config_dir.clone());
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
let planning_response = serde_json::json!({
"thinkingSummary": "已有上下文足够,准备生成最终回复",
"plan": ["回复开发者"],
@@ -847,8 +851,10 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
})
.to_string();
let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response);
let _config_guard = write_test_local_config(format!(
r#"{{
replace_test_local_config(
&config_path,
format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
@@ -859,7 +865,8 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
}}
}}
}}"#
));
),
);
let run_id = "background-final-reply-public-failure-audit-run";
start_game_creator_agent_background_task_at(
&root,
@@ -869,18 +876,15 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
)
.expect("start background task");
let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read initial runtime")
.state;
for _ in 0..250 {
if runtime.status == "failed" {
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read failed runtime")
.state;
}
let runtime = wait_for_agent_runtime_terminal_and_lane_release_async(
&root,
"design-director",
run_id,
"failed",
"failed",
)
.await
.state;
assert_eq!(runtime.status, "failed");
assert_eq!(runtime.phase, "failed");
let private_error = runtime.error.clone().expect("private runtime error");
@@ -940,6 +944,8 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu
}));
fs::remove_dir_all(root).ok();
drop(config_guard);
fs::remove_dir_all(config_dir).ok();
}
#[test]
@@ -99,6 +99,8 @@
## 2026-07-16 AI 游戏创作 Agent Runtime 使用 Provider 原生工具目录
> 后续更正:本条把 Anthropic 与「历史 fixture 和旧响应」并列为 wrapper/text JSON 兼容对象的描述,已由 2026-07-27「Anthropic 与流式统一使用 Provider 原生工具」取代;Anthropic 现在与 Chat / Responses 一样发送原生工具目录,text JSON 只剩历史响应与 fixture 兼容。下文保留作历史记录。
- 背景:OpenAI-compatible planning 虽已使用 function calling,但只向 Provider 提供 `submit_agent_tool_plan` 包装函数,真实工具藏在 `actions[].tool + input` 中,具体工具名和参数主要依赖长提示词,Provider 不能按工具 schema 约束选择与输入。
- 决策:OpenAI Chat / Responses 直接获得稳定的 `update_agent_plan``respond_to_user`、每个内置 Runtime action 和动态 MCP function。内置名称从规范 tool id 映射,MCP 名称从 server/tool 身份派生;真实 MCP binding 与 fingerprint 由 Runtime 注入。每个 action 携带非空 reason 与独立 input schema,一轮最多 1 次计划更新和 3 个动作,或计划更新加最终回复;动作与回复不得共存。plan-only 是合法持久 checkpoint,应用后继续同一 run planning,未完成计划和项目验证门禁继续阻止最终化。
- 兼容与安全:新请求和 repair 不广告旧 wrapperparser 只为 Anthropic、历史 fixture 和旧响应保留 wrapper/text JSON 兼容。未知函数、重复 call id、重复计划/回复、四个动作、正文与 function calls 共存、MCP binding 冲突和非法参数均在副作用前失败。公共审计只保存协议、call 数量、函数名和 call id,不保存 arguments、正文或 MCP 参数。
@@ -493,7 +495,7 @@
- 2026-07-11 调整,2026-07-15 收口:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。低推理强度和较大的可见输出余量只用于降低空响应概率;`EmptyResponse` 仍按单次 lifecycle 的歧义失败处理,不再自动原样重放。
- 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。
- 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。
- 2026-07-12 补充:OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。Anthropic 保留文本 JSON 回退;planning 非流式,最终回复仍可流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。
- 2026-07-12 补充2026-07-27 更正OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。本条原写「Anthropic 保留文本 JSON 回退;planning 非流式」,已由 2026-07-27「Anthropic 与流式统一使用 Provider 原生工具」取代——Anthropic 同样发送原生工具目录,planning 不再因协议强制非流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。
- 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan``code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/<taskId>.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。
@@ -5379,6 +5381,8 @@
## 2026-07-24 Supervisor 与部门 Director 使用统一 Interaction Loop
> 后续更正:本条「非原生 tool Provider 使用同构严格 JSON envelope 适配」的描述已由 2026-07-27「Anthropic 与流式统一使用 Provider 原生工具」取代;三种协议都提供原生 toolinteraction loop 不再存在按协议切换 JSON envelope 的分支,开启流式时也能拿到流式工具调用。下文保留作历史记录。
- 背景:`--swarm-chat` 曾在模型调用前用字符串包含判断选择 Chat / Execute / Resume,否定句、复合请求和未列入词表的工作请求都会误路由;busy Runtime 期间的裸聊天还可能绕过 Agent lane 并与原 run 交错写同一 Session。
- 决策:删除自然语言关键词分类和硬编码自然语言直答。Project Supervisor 与角色目录中 `role.id=director` 的六个部门负责人使用统一 interaction loop;自然语言回复与 `project_location / runtime_execute / runtime_resume` 都来自同一次 Provider turn 的直接文本或原生 function tool。叶子专业 Agent 保持合同执行者,不接入该外层决策能力。
- Canonical 输入:`runtime_execute` 不允许模型提交 task 参数,真正入队始终使用用户原始消息,避免模型改写时丢失否定、范围和验收条件。非原生 tool Provider 使用同构严格 JSON envelope 适配;模型只能提出 intention,不能选择 runId、越过权限或直接执行项目副作用。
@@ -5525,3 +5529,20 @@
- 2026-07-27 补齐:AI 游戏创作客户端的密码登录、验证码发送和验证码登录统一复用共享 TypeScript 请求契约,固定把中国大陆输入拆成 `countryCode=86 + purePhoneNumber`,不再发送旧 `phone`。认证 HTTP 错误只有在响应为合法 JSON envelope 时才展示后端安全消息;Axum 422 等非 JSON 正文回退到当前动作的中文错误,不向用户展示 JSON 解析器异常或原始反序列化文本。
- 微信边界:小程序客户端仍只上传 `wechatPhoneCode``platform-auth` 必须要求微信成功响应中的 `phoneNumber``countryCode``purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。
- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。
## 2026-07-27 Anthropic 与流式统一使用 Provider 原生工具
- 背景:`platform-llm` 的 Anthropic 分支从未实现工具——请求体没有 `tools` / `tool_choice` 字段,`validate()` 还会以「Anthropic api_kind 暂不支持 function tools」本地拒绝,响应解析只取 `text` block 并硬编码 `tool_calls: Vec::new()`。App 侧因此在 `provider_request_builders.rs``interaction.rs``api_kind != Anthropic` 绕开原生工具,改用长提示词描述工具并要求模型输出单个 JSON object,等于让 Anthropic 退回 V1.26 之前的状态。三种协议的流式路径同样恒返回空工具调用,靠「无文本 → EmptyResponse → 非流式重打」兜底;模型若在工具调用前先输出解说文本,该兜底不触发,工具调用会被静默丢弃并把解说当成最终回复。
- 前提验证:MiniMax 的 Anthropic 兼容层与真实 OpenAI 均完整支持工具调用,说明这是本地实现缺口而非上游限制。实测覆盖 `tools` + 四种 `tool_choice`、并行多工具、`tool_result` 回传与流式增量;`tool_choice` 必须是对象,裸字符串返回 400。
- 决策:Anthropic 与 Chat / Responses 使用同一套原生工具目录。请求体顶层发送 `tools``name / description / input_schema`,无 `function` 包装层与 `strict`)与对象形态 `tool_choice``Auto → {"type":"auto"}``Required → {"type":"any"}`),响应解析 `tool_use` block 并把 `input` 序列化成 `arguments`;解除 `validate()` 对 Anthropic function tools 的拦截,`web_search`、图片内容和至少一条非 system 消息三条校验保留。App 侧删除两处 `api_kind != Anthropic` 守卫与对应的「Provider 不提供 function tools」提示词分支。
- 流式:三种协议的工具增量统一按槽位聚合成完整调用——Chat 用 `delta.tool_calls[].index`、Responses 用 `output_index``output_item.added` 给身份、`function_call_arguments.delta` 拼参数、`.done` 覆盖为权威值,并从 `response.completed` / `response.incomplete``output[]` 再兜底一次)、Anthropic 用 content block `index``content_block_start` 给身份,`input_json_delta` 拼参数,`content_block_start` 里的空 `input` 不得用于初始化)。收尾必须校验参数为完整 JSON,截断流不返回半截参数;`response.incomplete` 携带的工具调用按未完成响应拒绝,纯正文可作为降级结果保留。`LlmStreamDelta` 仍只承载文本,工具调用不进增量回调。上游已表明本轮是工具调用却一个都没聚合出来时返回 `StreamUnavailable`,让调用方回退非流式,不允许静默丢弃。
- 兼容边界:旧 wrapper 与 text JSON parser 只保留为历史响应、确定性 fixture 和模型不守协议时的降级解析,**不再是任何 Provider 的正常请求路径**`agent.runtime.tool_plan.protocol` 审计在 Anthropic 正常路径下取值为 `native_runtime_tools`。Chat 的 `ChatCompletionsToolCall` 字段放宽为可选并新增 `index`,否则流式后续分片(只带 `index``arguments`)会直接反序列化失败。
- 影响范围:`server-rs/crates/platform-llm``apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs`、同目录 `runtime_actions/provider_request_builders.rs`,以及 Runtime V1.1 与 App 实施计划两份技术方案。取代 2026-07-16「使用 Provider 原生工具目录」中把 Anthropic 与历史 fixture 并列的兼容描述、2026-07-12 关于 Anthropic 文本 JSON 回退的补充,以及 2026-07-24「统一 Interaction Loop」中「非原生 tool Provider 使用同构严格 JSON envelope 适配」的表述。
- 验证方式(当时记录):`cargo test -p platform-llm` 52 项通过,其中 8 个流式工具用例的 SSE 原文取自真实抓包;`server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs` 为默认 `#[ignore]` 的真实端点验收,靠 `PLATFORM_LLM_LIVE_*` 环境变量运行,已对 MiniMax(anthropic / openai_chat / openai_responses) 与 OpenAI(gpt-4.1 openai_chat / gpt-5.5 openai_responses) 五种配置确认流式解析出完整工具调用。App 侧回归用 stash 对比法确认无新增失败——本机该测试套件存在大量与改动无关的既有失败,不能直接看绝对失败数。
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-27 校正 platform-llm 流式工具验收证据边界
- 更正:上一条把固定 SSE fixture 的真实抓包来源、确定性 parser 覆盖和真实端点 smoke 合并描述,并写成“证明转录没有偏差”,超出了实际测试证据。本次验收命令为 `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm`;固定 fixture 和本地解析测试只验证 parser / 配置归一结果,实时测试只验证最终归一后的工具名、id、完整参数 JSON 和文本增量字符数。测试数量随用例自然变化,不作为共享文档中的固定契约。
- 当前口径:`server-rs/crates/platform-llm/tests/live_stream_tool_calls.rs` 是默认忽略的真实端点工具调用 smoke;`on_delta` 只接收文本,工具调用从最终 `LlmRunResponse.tool_calls` 读取。现有测试没有原始 SSE 录制、事件类型/slot/分片顺序保存或逐事件比较,因此两类测试都不能证明 raw SSE fidelity 或抓包转录无偏差。
- 现有确定性流式工具覆盖应与普通 Anthropic 文本流测试分开统计:三协议真实来源 fixture、Responses 仅有 completed / incomplete 终态事件时的恢复、并行 slot 聚合、截断参数和无片段 `StreamUnavailable` 等用例共同覆盖 parser 边界;未来若需证明转录一致性,必须另行增加受控原始 SSE capture/compare 能力。
@@ -1002,7 +1002,7 @@ V1.26 把 OpenAI-compatible planning 从单个 `submit_agent_tool_plan` 包装
- 每个内置 action function 使用独立输入 schema,并显式携带非空 `reason` 与工具 `input`。MCP function 复用经过现有目录预算和清洗的真实 input schema,但外部 description/schema/instructions 始终是不可信输入,不能改变系统规则或扩大能力。function catalog 必须进入 token 估算和自动压缩预算;目录超限、重复函数名、非法 schema 或 binding 漂移失败关闭。
- 一次 Provider 响应最多包含 1 个 `update_agent_plan`、最多 3 个 action function call,或 1 个 `respond_to_user`;计划更新既可单独作为持久进度 checkpoint,也可与 action 或最终回复同批返回,最终回复不能与 action 共存。plan-only 会先持久化单调计划,再由未完成计划 blocker 进入同一 run 的下一次 planning,不会提前写 assistant 或 completed。call id 必须非空且唯一,未知、重复、参数非对象、超预算、空回复、多个回复或多个计划更新都进入现有格式修复,不执行任何动作。action 顺序按 Provider 返回顺序稳定转换;V1.26 不宣称同一 Agent 内并行执行工具,转换后的动作继续逐个进入 durable ledger。
- `update_agent_plan` 只承载 explanation 与最多 8 个持久步骤;`respond_to_user` 只承载最终正文。直接工具协议不要求模型公开 thinking 正文,Runtime 从计划说明或首个 action reason 派生有界 thinking summary。结构化计划仍有未完成步骤时禁止最终回复,项目修改后仍必须取得当前 revision 的真实验证凭证。
- 新请求不再向 OpenAI-compatible Provider 广告 `submit_agent_tool_plan`。解析器继续接受旧 wrapper 与 text JSON,用于现有确定性 fixture、历史兼容和不提供 function tools 的 Anthropic 路径;格式修复请求必须继续广告当前原生目录,不能在同一 Provider request identity 下静默切回旧 wrapper。公共协议审计只保存协议名、function call 数量、稳定函数名和 call id 身份,不保存 arguments、写入正文、MCP 参数或最终回复。
- 新请求不再向 OpenAI-compatible Provider 广告 `submit_agent_tool_plan`。解析器继续接受旧 wrapper 与 text JSON用于现有确定性 fixture 和历史响应兼容——2026-07-27 起 Anthropic 同样发送原生工具目录并返回 `tool_use`,不再存在“不提供 function tools 的 Provider 路径”,text JSON 只能作为模型不守协议时的降级解析,不能写成任何协议的正常请求路径;格式修复请求必须继续广告当前原生目录,不能在同一 Provider request identity 下静默切回旧 wrapper。公共协议审计只保存协议名、function call 数量、稳定函数名和 call id 身份,不保存 arguments、写入正文、MCP 参数或最终回复。
- 确定性验收必须覆盖完整内置目录、函数名稳定性、核心 schema、动态 MCP binding、计划 + 多 action、计划 + reply、顺序、未知/重复/冲突/超预算拒绝、旧 wrapper/text 兼容、repair 后仍使用原生目录、token 预算和公共零 arguments。真实 Provider 必须在无工具名和参数配方的任务下自主选择至少一个只读工具、一个项目修改工具和真实验证工具,完成唯一目标文件交付;最终还要证明协议全程为原生目录、action/receipt/Provider lifecycle 唯一、无 wrapper fallback、唯一 assistant/completed、零正文/密钥/路径泄漏和隔离现场完整清理。
2026-07-16 正式 `openai_chat / gpt-5.5``project-skill` suite **PASS**。首轮真实执行在匹配 Skill 读取后暴露旧 parser 拒绝 plan-only,保留现场复验进一步证明模型会先单独调用 `update_agent_plan`;Runtime 空动作分支原本已能持久化计划并安全进入下一轮,因此移除矛盾的 parser 拒绝并补三轮确定性闭环。最终加强门禁复跑记录 31 条 task、57 条 event、94 条 Agent DB、6 个成功工具动作和 2 个确认动作;9/9 个成功工具计划与 6/6 个格式修复全部使用 `native_runtime_tools`,旧 wrapper 与 text JSON fallback 均为 0。Agent 在首个项目修改前读取匹配 Skill 1 次、无关 Skill 0 次,只修改 1 个目标文件,Agent `project.verify` 与宿主复验均通过;15 个 tool-plan 加 1 个 final-reply Provider lifecycle 全部唯一闭合,最终 assistant/completed 各 1,重复 message/receipt、遗留 finalization、Skill 正文、API Key、诱饵、项目/正式配置路径和报告泄漏均为 0,隔离 Runner、AppData 与一次性项目完整清理。确定性 Tauri 全量为 822 passed / 4 ignoredV1.26 真实行为门禁至此完成。
@@ -1493,6 +1493,7 @@ V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是
- 恢复验收必须在同一轮证明:同一 requestId 只闭合一次且不产生替代 requestIdproxy 的 `networkReplayCount=0`protocol/repair audit compare-and-append 幂等,handoff 与恢复后 durable pending/action batch 的 plan fingerprint 对应;ACK、强杀和恢复消费前不得出现由目标计划产生的 action、pending、delivery、claim 或其它副作用。终局 retry/tool-plan handoff/provider handoff/finalization/confirmation 等 sidecar、重复 lifecycle/audit/action/message、临时 capability/Runner 资源与 AppData 残留均为 `0`,公共报告中的 Provider URL、headers、正文、凭据及项目/正式配置绝对路径泄漏命中也必须为 `0`。2026-07-20 的真实外部 Provider 单轮已证明 checkpoint、Runner boot 切换、同一请求零网络重放、恢复前零副作用与唯一生命周期闭合,但随后专业 Agent 连续连接失败使整轮 FAIL;另一独立轮首批工具数不满足 fixture,同样未通过。两轮不得拼接,当前仍无该 suite 的完整外部 PASS。
- V1.43 仍不关闭“外部 Provider 已成功返回、但本地 handoff 尚未完成原子写入并回读”的 unknown-result 窗口;没有 Provider 级幂等键或结果查询能力时,该窗口继续进入人工 reconciliation,不能宣称端到端物理调用 exactly-once。手动 context-compaction 也不在本切片。
- 2026-07-20 当前确定性证据:本轮 `tool_plan_handoff_``44/44`Supervisor collaboration 相关过滤为 `55/55`,权威返工合同用例为 `1/1`Tauri/Rust 串行全量 1058 tests 为 `1054 passed / 4 ignored / 0 failed`Linux `cargo check --tests``x86_64-pc-windows-gnu cargo check --tests` 均通过。E2E self-test、typecheck、变更脚本 ESLint、encoding 与 `git diff --check` 通过。默认并发全量只作竞态诊断,不替代 `--test-threads=1`。Unix handoff 存储使用固定目录句柄、根/Agent 双层 `flock``RENAME_EXCHANGE` 安装回滚和 `RENAME_NOREPLACE` quarantineWindows 使用相对父句柄、`GetFileInformationByHandleEx` 句柄枚举与独占 temp 句柄,并拒绝 junction/reparse point 与硬链接。非协作同 UID 进程仍属于宿主 OS 信任边界,不能据此宣称完整沙箱。真实 suite 的 checkpoint 已有单轮外部证据,但整轮仍无 PASS。
- 2026-07-27 文档更正:本节及 V1.42 中的 `platform-llm 41/41` 是 2026-07-20 的历史门禁计数,不能代表本次工具协议修复后的当前结果;从仓库根目录运行 `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` 作为当前验收命令。当前证据应区分为 checked-in SSE fixture 的 parser 覆盖、本地解析单元测试和默认忽略的真实端点归一工具调用 smoke;三类测试的数量以命令实际输出为准,不作为需要手工维护的固定契约。两类外部 SSE 证据都不录制或逐事件比较原始 SSE,不能据此宣称转录无偏差。
## 验收命令
@@ -14,6 +14,7 @@
- Run 控制参考:借鉴 Harbour 的控制平面思想,只吸收 `run lifecycle`、activity/output stream、context bundle、kill/retry/resume 等本地运行治理能力;不引入 Harbour 的多租户后台、调度 UI、通用 shell workflow 或远程 runner 作为 v1 依赖。
- 本地工程参考:借鉴 Godcoder 的本地产物 checkpoint / diff / restore、上下文安全过滤、轻量项目索引、项目级写锁和项目级权限策略;不引入通用 IDE 插件、云工作区或任意代码代理。
- 代码组织:桌面客户端入口保持为薄组合层。前端把认证、Tauri 桥接、Runtime 配置、Agent Runtime 展示和项目摘要分别放入 `src/app``src/services``src/features`;Rust 项目能力和测试按功能域使用目录模块;界面测试与真实 Runtime E2E 使用薄 suite registry / entry 保留原执行顺序。后续拆分必须保持公开导出、命令契约、测试名称和行为不变,不能用 `include!`、整文件文本拼接或只移动到另一个超大文件代替真实模块边界。
- 源码门禁:`check:native-shells` 等源码扫描必须跟随真实模块归属;入口组合层只验证受控组件的挂载关系,具体实现由所属模块单独验证。模块拆分后不得为了满足旧字符串扫描把实现搬回 `App.tsx`,也不得用跨文件文本拼接代替组件归属检查。
## 开发态 Project Supervisor 纯聊天独立窗口
@@ -194,7 +195,7 @@ Agent Runtime 负责:
- 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。
- 2026-07-11 调整,2026-07-12 由 Runtime V1.2 更新:后台 planning 使用 4,000 输出 token,最终回复使用 2,400,并继续叠加最多 3 次 EmptyResponse 重试。推理档位不再硬编码为 `low`planning、普通单 Agent 聊天和最终回复统一使用解析后的 `llm.reasoningEffort``agentLlm.<agentId>.reasoningEffort` 有值时覆盖全局、缺省时继承全局;取值只允许 `default / low / medium / high`,发布默认 `high``default` 表示不向 Provider 发送推理档位。
- 2026-07-11 补充,2026-07-15 由 V1.17 更新:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / planUpdate / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求;同一次 planning 的私有 repair 请求可携带限长且经过统一敏感信息过滤的上一条模型输出或 function call 预览与协议错误,以便 Provider 真正修正格式。`.agent/agent.db``agent.runtime.tool_plan.repair` 公共审计只写 attempt/maxAttempts、protocol,以及错误、输出/调用体预览、callId 和 functionName 的 SHA-256、字符数或计数,不保存原始模型正文、错误或 function arguments。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。旧文本协议可省略 `planUpdate`,但只能继续走 legacy `plan` fallback。
- 2026-07-12 补充,2026-07-15 由 V1.17 更新:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schemaRuntime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 保留文本 JSON 回退,planning 强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 在本地拒绝无 function tools 的 tool choice Anthropic function tools,并把协议类型写入 `agent.runtime.tool_plan.protocol` 审计。
- 2026-07-12 补充,2026-07-15 由 V1.17 更新2026-07-27 由「Anthropic 与流式统一使用 Provider 原生工具」更新OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schemaRuntime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。strict arguments 中 `planUpdate` 必须出现但可为 `null`,使用结构化更新时 legacy `plan` 必须为空。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 自 2026-07-27 起与另外两种协议一致发送原生工具目录:请求体顶层携带 `tools`schema 字段名为 `input_schema`,无 `strict`)与对象形态 `tool_choice``Auto → {"type":"auto"}``Required → {"type":"any"}`,裸字符串会被上游拒绝),响应解析 `tool_use` block 并把 `input` 序列化为 `arguments`planning 不再因协议强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 在本地拒绝无 function tools 的 tool choice,但不再拒绝 Anthropic function tools协议类型继续写入 `agent.runtime.tool_plan.protocol` 审计,Anthropic 正常路径的取值为 `native_runtime_tools` 而不是 `text_json`
- 2026-07-11 调整,2026-07-15 由 V1.17 更新:工具计划五个顶层字段均为必填并拒绝未知顶层字段;`thinkingSummary`、结构化计划的 `explanation / step``action.tool` 必须非空。`planUpdate` 只接受 `null` 或最多 8 个唯一步骤,状态限于 `pending / in_progress / completed` 且至多一个 `in_progress`。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 只有在 verification、process/join/delivery 和结构化计划完成门禁都通过后才表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。
- 2026-07-15 V1.17 公共审计收紧:`thinking_summary` event 只保存固定摘要、正文 SHA-256 与字符数,legacy `plan` event 只保存步骤数;结构化计划审计只保存 explanation 的哈希与字符数,以及 step 标题哈希、状态和数量。模型 thinking、legacy plan 标题、repair 错误和调用体只允许出现在对应私有 Runtime 上下文或有界 repair 请求中,不得复制到公共 event、task 或 Agent DB 正文字段。
- 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/<agentId>.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。
@@ -652,7 +653,7 @@ game-project/
- 2026-07-16 V1.24 已完成真实验收:正式 `openai_chat / gpt-5.5``scoped-agents` suite 在无规则正文、期望内容和工具配方的任务下,让同一 Agent 只修改 `alpha / beta` 两个兄弟目录交付文件;根、父、各自叶规则全部精确命中且兄弟串用为 0,Agent `project.verify` 与宿主复验均通过。最终脚本复跑的 8 组 Provider lifecycle 唯一闭合,最终 assistant/completed 各 1,重复持久化,以及最终回复/公共审计/报告中的规则正文、API Key、诱饵、项目/配置路径泄漏均为 0,隔离现场完整清理;不再把 prompt 可见性代替模型遵循证据。
- 2026-07-16 起,同一 Runtime 文档的“V1.25 Codex 式项目 Skill 发现与渐进加载”作为项目工作流加载事实源。仓库启动上下文升级为 `repository-startup-context-v3`,只发现项目内 `.codex/skills/<name>/SKILL.md``.agents/skills/<name>/SKILL.md` 直接入口,同名时 `.codex` 优先;首轮 prompt 只注入清洗后的 `name / description / entryPath / contentSha256`,正文必须在任务命中后通过现有 `file.read` 按需读取。Skill 不能扩大工具、权限、确认、沙箱、隐私或完成门禁,与适用路径的 `AGENTS.md` 冲突时后者优先;active Skill 变化推进 repository fingerprint 并阻断旧 pending 动作后重规划。
- 2026-07-16 V1.25 已完成真实验收:正式 `openai_chat / gpt-5.5``project-skill` suite 先让 hash-only 原始验收真实失败;Agent 在首个变更前精确读取匹配 Skill 1 次、无关 Skill 0 次,以 1 个变更动作只修改目标文件,Agent `project.verify` 与宿主复验均通过。最终脚本复跑记录 25 条 task、41 条 event、57 条 Agent DB 和 5 个成功工具动作;4 组 tool-plan Provider lifecycle 唯一闭合,最终 assistant/completed 各 1Skill 正文、API Key、诱饵、项目/配置路径泄漏和重复持久化均为 0,隔离 Runner/AppData/项目完整清理。
- 2026-07-16 起,同一 Runtime 文档的“V1.26 Provider 原生工具目录”作为 OpenAI-compatible planning 协议事实源。Chat / Responses 不再只广告 `submit_agent_tool_plan` 包装函数,而是直接提供 `update_agent_plan``respond_to_user`、全部内置 Runtime action 和动态 MCP function;每个函数使用独立 schema,Runtime 继续负责身份、权限、确认、沙箱、revision、验证、恢复与副作用防重放。Anthropic 与历史 fixture 保留 text JSON / wrapper 解析兼容,但新请求和 repair 不能静默降级。plan-only 是合法持久 checkpoint,未完成计划仍阻止最终化。
- 2026-07-16 起,同一 Runtime 文档的“V1.26 Provider 原生工具目录”作为 OpenAI-compatible planning 协议事实源。Chat / Responses 不再只广告 `submit_agent_tool_plan` 包装函数,而是直接提供 `update_agent_plan``respond_to_user`、全部内置 Runtime action 和动态 MCP function;每个函数使用独立 schema,Runtime 继续负责身份、权限、确认、沙箱、revision、验证、恢复与副作用防重放。2026-07-27 起 Anthropic 也走同一套原生工具目录,text JSON / wrapper 解析只作为历史响应与确定性 fixture 的兼容入口,不再是任何 Provider 的正常请求路径;新请求和 repair 不能静默降级。plan-only 是合法持久 checkpoint,未完成计划仍阻止最终化。
- 2026-07-16 V1.26 已完成真实验收:正式 `openai_chat / gpt-5.5``project-skill` suite 中 9/9 个成功工具计划与 6/6 个格式修复全部使用 `native_runtime_tools`wrapper/text fallback 均为 0。Agent 自主读取匹配 Skill、只改唯一目标文件并完成 Agent/宿主双重验证;15 个 tool-plan 和 1 个 final-reply lifecycle 唯一闭合,最终 assistant/completed 各 1,重复、Skill 正文、API Key、诱饵、项目/配置路径和报告泄漏均为 0,隔离 Runner/AppData/项目完整清理。
- 2026-07-16 V1.26 后重新加强并复验 `goal-runtime`:Goal suite 现在把成功计划、repair、call metadata、wrapper/text fallback 和协议审计零 payload 纳入硬门禁。正式 `openai_chat / gpt-5.5` 最终复跑的成功计划 21/21、repair 17/17 全为 `native_runtime_tools`;Goal edit、旧动作失效、真实失败修复、pause、Runner 强杀、显式同 run resume、verification、finalization 和唯一回复全部 PASS,重复、重放、正文、密钥、诱饵与路径泄漏均为 0。Goal 阶段等待同时增加 terminal fail-fastProvider transport failure 不再占满 30 分钟验收超时。
- 2026-07-24 补充:`agc:test:chat / autonomous-game-build` 不再只因 `game/index.html` 可试玩就跳过已配置的画布能力。有效运行时配置存在 `editorApi.apiKey` 且项目尚无规范 `canvas / image/* / art-spritesheet` 本地素材时,缺省 Supervisor 首批协作固定加入 `art-asset-plan`,并要求真实交付 `assets/art-spritesheet.png`;该 Agent 继续通过 `canvas.asset_generate` 把结果同时写入 External Editor 画布、素材库、本地 assets 与 manifest。已有有效首版素材的后续修复轮不重复生成或扣费。该工具仅在自主构建 profile 的 `design-foundation / art-asset-plan` 视觉职责中作为固定 auto-safe 动作,Supervisor、程序和其他非视觉 Agent 一律拒绝,避免同一路径并发生成和重复扣费;普通模式和显式 deny 不变,Key 缺失时不伪造图片产物。
@@ -701,6 +702,7 @@ game-project/
- tool-plan arguments 只允许出现在 `0600` 原子 sidecar 及后续 pending/action batch,不得进入 task/event/Agent DB/CLI/report。公共 protocol/repair 审计共同保存 Agent/task/Session/run/source、loop/repair/slot、响应指纹、Provider request ID SHA-256 和 protocolprotocol 只保存 function call 数量、call ID SHA-256 数组、catalog-bound function names、response ID SHA-256/字符数及 normalization 元数据,repair 只保存 attempt/maxAttempts、协议错误/preview 哈希和 call ID/function name SHA-256,不保存原始 callId/callIds/responseId/providerRequestId,并在 Agent DB append 锁内按完整身份全历史幂等追加。为了保持执行语义,参数禁止静默脱敏;命中密钥、配置痕迹、敏感 JSON key、Provider ID 中的秘密/绝对路径、结构化可执行路径中的项目或其它绝对路径、大小/顺序/身份冲突时直接 reconciliation。源码正文和计划叙述只做密钥检查,不能把 HTML 闭合标签当绝对路径;未闭合 thinking 只留无正文无效元数据并继续 repair。账本保留到 run 终态或明确作废;steer/cancel/漂移/终态清理前先闭合整本账本的实际 requestIdRunner 恢复严格扫描 hash/primary/`.previous`/安全临时文件并回收合法终态残留,确保单动作、多动作、confirmation、协作 batch 与直接回复在下一 durable owner 建立前都有恢复来源;未知、冲突、primary、`.previous` 或损坏账本都阻止 Runner idle shutdown。
- V1.43 的确定性门禁必须覆盖 base handoff 与 repair handoff 两个 lifecycle-completed 前断点,关闭 mock Provider 后恢复零网络、原 requestId 唯一闭合、repair/protocol audit 幂等、唯一 assistant/completed/committed stream及终局零 sidecar。独立非默认真实门禁 `supervisor-swarm-tool-plan-handoff-runner-kill` 已实现并完成 Shell/Root 两级注册:它使用 sentinel-owned sibling AppData 与 metadata-only zero-fault proxy,以每轮随机 capability 严格绑定 project/Agent/run/实际 request slot;只有 tool-plan handoff 原子落盘并回读一致、同一实际 requestId lifecycle 尚未 `completed` 时才 ACK,随后通过 pidfd `SIGKILL` 强杀 suite 自有 Runner。恢复必须证明同一 requestId 唯一闭合且 `networkReplayCount=0`、protocol/repair audit 幂等、handoff 与 durable batch plan fingerprint 对应、恢复消费前 action/pending/delivery/claim 等副作用为 `0`,并在终局把 sidecar、重复记录、临时 capability/Runner/AppData 资源及公共正文、凭据、URL、项目/正式配置路径泄漏全部清零。2026-07-20 的真实外部 Provider 单轮已到达并通过 checkpoint,但随后专业 Agent 连续连接失败使整轮 FAIL;另一独立轮首批工具数不满足 fixture,也未通过。两轮不得拼接,当前仍无该 suite 的完整外部 PASS。Provider 成功到 handoff 原子落盘回读前的 unknown-result 和手动 context-compaction 仍不在本切片承诺内。
- V1.43 当前确定性实现已通过本轮 `tool_plan_handoff_ 44/44`、Supervisor collaboration 相关过滤 `55/55`、权威返工合同 `1/1`,以及 Tauri/Rust 串行全量 `1054 passed / 4 ignored / 0 failed`Linux `cargo check --tests``x86_64-pc-windows-gnu cargo check --tests` 均通过。E2E self-test、typecheck、变更脚本 ESLint、encoding 与 `git diff --check` 通过;默认并发全量只作竞态诊断,不替代串行门禁。Supervisor 真实 E2E 报告已把 `toolPlanHandoffSidecarCount` 纳入终局残留。handoff 跨平台存储使用 Unix 固定目录句柄、目录 `flock`、exchange/quarantine 与 Windows 相对父句柄、句柄枚举、独占 temp,不再根据 PID 推断写入方是否存活;主动忽略锁的同 UID 进程仍属于宿主 OS 信任边界。
- 2026-07-27 文档更正:上文 V1.42 的 `platform-llm 41/41` 保留为 2026-07-20 历史门禁计数;当前验收命令为 `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm`。platform-llm 的验收证据分为 checked-in SSE fixture parser 覆盖、本地解析单元测试与默认 `#[ignore]` 的真实端点归一工具调用 smoke;后者只校验最终工具名、id、完整参数 JSON 和文本字符数,未录制或逐事件比较原始 SSE,二者都不能证明转录无偏差。新增普通测试不需要更新固定数量,只有验收命令或测试类别边界变化时才需要更新本段。
- 2026-07-21 起,同一 Runtime 文档的“V1.44 自主可玩塔防确定性真实门禁”增加独立 loopback OpenAI Chat Provider 和 wrapper 命令。Provider 只返回原生 function calls,不直接修改项目、不伪造工具 observationwrapper 在仓库外创建带 sentinel 的临时配置,复用正式 `supervisor-autonomous-playable-lane-defense` suite,并在终局停止 Provider、删除配置和 disposable 项目。
- V1.44 固定验证两份首轮并行专业委派、只读验收回复因 revision 更新而重新规划、程序 Agent 写入并通过静态自检、首轮真实浏览器因隐藏 canvas 失败、Supervisor 直接修改被 orchestrator-only 策略拒绝,以及后续程序委派产生新 revision。若旧失败仍在父验证门且后续 delivery 已 ready,必须先用 `agent.run_status` 认领回执,再对当前 revision 完成 `game.static_smoke + preview.validate`,最后只由 Supervisor 回复;已有 3 个 active/ready delivery 时不得创建第四次委派。试玩 liveness 只以当前 revision 可归属的最新 `preview.validate` 结果收束:新 revision 的成功会取代历史失败,当前 revision 最新失败仍继续强制专业返工;每个固定 `data-playtest-id` 必须唯一匹配一个可见、启用且真实可点击的 HTMLElement。
- 本轮 V1.44 wrapper 与正式子 suite 均为 **PASS**Provider 共 `17` 次 planning、异常请求 `0`;项目从 revision `0` 推进到 `2`,最终 `game/index.html``4924` 字节;`lane-defense-v1` 的植物选择、放置、敌人移动与受伤、胜利、下一关和重开共 `37/37` 断言通过,桌面与移动浏览器验证通过;三份专业回执全部认领,Supervisor assistant 唯一,pending、confirmation、user-input、provider batch/retry/handoff、tool-plan handoff、finalization journal、reconciliation、重复和泄漏计数均为 `0`,隔离 Runner、AppData、配置和项目已清理。该确定性 loopback PASS 不能替代外部 Provider 可用性验收;外部路由仍须单独形成同轮完整 PASS。
File diff suppressed because one or more lines are too long
@@ -692,7 +692,25 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
旧结构化创作 / RPG 的 Responses `web_search` 开关已退出 api-server 配置;部署环境不再保留 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED``GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED`
`platform-llm` 文本请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。AI 游戏创作独立 App 是客户端,不读取 `.env`;发布 App 启动时会在 Tauri 应用配置目录生成 `game-creator.config.json`,主窗口“配置”面板读写该运行时文件,真实密钥和本机覆盖项写入该文件,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板,开发 CLI 无 AppHandle 时才回退读取仓库旁边的 gitignored 覆盖文件。LLM 维度由 `llm.apiKind` 控制,默认 `openai_responses`,可设为 `openai_chat` 接旧 Chat Completions 兼容网关,或 `anthropic` 接 Anthropic Messages。
`platform-llm` 请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。三种协议(`openai_chat``openai_responses``anthropic`)都使用原生 function tools,并统一从最终 `LlmRunResponse.tool_calls` 读取工具调用;流式 `on_delta` 只发送文本,不能把工具参数当作文本增量转发。Anthropic 工具请求使用 `input_schema``Required` 使用对象形态 `{ "type": "any" }`Anthropic 当前不支持 `web_search`、图片内容和纯 system 消息。AI 游戏创作独立 App 是客户端,不读取 `.env`;发布 App 启动时会在 Tauri 应用配置目录生成 `game-creator.config.json`,主窗口“配置”面板读写该运行时文件,真实密钥和本机覆盖项写入该文件,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板,开发 CLI 无 AppHandle 时才回退读取仓库旁边的 gitignored 覆盖文件。LLM 维度由 `llm.apiKind` 控制,默认 `openai_responses`,可设为 `openai_chat` 接旧 Chat Completions 兼容网关,或 `anthropic` 接 Anthropic Messages。
流式工具片段按协议 slot 聚合,Responses 允许从 `response.completed` / `response.incomplete``response.output[]` 恢复只在整体终态事件中携带的工具调用与正文。`response.incomplete` 中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,截断正文则保留为可用的降级结果。收尾时空参数默认 `{}`,非空参数必须是完整 JSON;解析失败、流式工具缺少身份或参数截断属于 `Deserialize`。流式已声明工具调用但没有聚合出工具 slot 属于 `StreamUnavailable`,由调用方决定是否回退非流式;文本和工具调用均为空才是 `EmptyResponse`
验收证据分为三类:`cargo test -p platform-llm` 的确定性用例验证 checked-in SSE fixture 的 parser 行为;`tests/live_stream_tool_calls.rs` 中默认执行的本地解析测试验证 `PLATFORM_LLM_LIVE_API_KIND` 归一与失败关闭;同文件默认忽略的真实端点用例只做归一后的工具调用 smoke,检查最终工具名、id 和完整参数 JSON,文本增量字符数仅用于打印观测。该 smoke 不录制或逐事件比较原始 SSE,fixture 即使来源于真实抓包也不能据此宣称转录无偏差。
真实端点 smoke 必填 `PLATFORM_LLM_LIVE_BASE_URL``PLATFORM_LLM_LIVE_API_KEY``PLATFORM_LLM_LIVE_MODEL``PLATFORM_LLM_LIVE_API_KIND` 可选,取值为 `anthropic` / `openai_chat` / `openai_responses`,省略或仅含空白时默认 `openai_responses`,未知非空值直接失败,避免拼写错误静默测到另一种协议。仓库根目录没有 `Cargo.toml`,必须显式指定 workspace manifest
```bash
PLATFORM_LLM_LIVE_BASE_URL=https://api.example.com/anthropic \
PLATFORM_LLM_LIVE_API_KEY='<从密钥管理处取,勿写入仓库>' \
PLATFORM_LLM_LIVE_MODEL='<模型名>' \
PLATFORM_LLM_LIVE_API_KIND=anthropic \
cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture
```
PowerShell 下按测试文件头部示例依次设置三个必填变量,并按需设置 `$env:PLATFORM_LLM_LIVE_API_KIND`,再执行同一条 `cargo test`。切换 `PLATFORM_LLM_LIVE_API_KIND` 逐个跑三种协议,才算覆盖完整;`--nocapture` 会打印解析出的工具名、id、参数和文本增量字符数,便于核对。
该用例只从进程环境变量读取凭据,不读 `.env.secrets.local`,也不会写入任何文件。真实 API Key 一律不得提交进仓库,也不要写进 `docs/`、脚本默认值或测试 fixture;临时密钥用完应在上游及时吊销。
创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行:
创意 Agent `gpt-5.4-mini` 文本链路已从 APIMart 切到 VectorEngine`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。排查或切换密钥后,可在本地运行:
+46 -13
View File
@@ -26,14 +26,18 @@ const aiGameCreatorShellAppSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/App.tsx',
'utf8',
);
const aiGameCreatorShellModelSource = fs.readFileSync(
const aiGameCreatorShellAppModelSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/app-shell/model.ts',
'utf8',
);
const aiGameCreatorShellChatPaneSource = fs.readFileSync(
const aiGameCreatorShellProjectWorkspaceChatPaneSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx',
'utf8',
);
const aiGameCreatorShellDeveloperProjectPanelsSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/DeveloperProjectPanels.tsx',
'utf8',
);
const aiGameCreatorProjectDevelopmentSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
'utf8',
@@ -2349,27 +2353,56 @@ function assertAiGameCreatorShellUserDevBoundary() {
'if (!import.meta.env.DEV)',
"return params.has('dev') || window.location.hash === '#dev';",
]) {
if (!aiGameCreatorShellModelSource.includes(snippet)) {
if (!aiGameCreatorShellAppModelSource.includes(snippet)) {
throw new Error(
`AI game creator developer-mode boundary drifted: missing ${snippet}`,
`AI game creator developer mode boundary drifted: missing ${snippet}`,
);
}
}
for (const snippet of ['{devMode ? (', 'className="developer-pane"']) {
for (const snippet of [
'projectSupervisorOnly ? false : isDeveloperMode()',
"{devMode ? (",
'className="developer-pane"',
]) {
if (!aiGameCreatorShellAppSource.includes(snippet)) {
throw new Error(
`AI game creator user/dev UI boundary drifted: missing ${snippet}`,
);
throw new Error(`AI game creator user/dev UI boundary drifted: missing ${snippet}`);
}
}
if (!aiGameCreatorShellChatPaneSource.includes('className="chat-pane"')) {
throw new Error('AI game creator chat pane boundary drifted');
if (
!aiGameCreatorShellAppSource.includes('<ProjectWorkspaceChatPane') ||
!aiGameCreatorShellProjectWorkspaceChatPaneSource.includes(
'className="chat-pane"',
)
) {
throw new Error('AI game creator user chat pane boundary drifted');
}
const previewFrameIndexes = [
...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g),
const developerProjectPanelIndexes = [
...aiGameCreatorShellAppSource.matchAll(/<DeveloperProjectPanels\b/g),
].map((match) => match.index ?? -1);
if (previewFrameIndexes.length !== 0) {
const previewFrameCount = [
...aiGameCreatorShellDeveloperProjectPanelsSource.matchAll(/<iframe\b/g),
].length;
const devModeBranchIndex = aiGameCreatorShellAppSource.indexOf('{devMode ? (');
const developerPaneIndex = aiGameCreatorShellAppSource.indexOf('className="developer-pane"');
if (previewFrameCount !== 1) {
throw new Error('AI game creator developer pane preview frame count drifted');
}
if (developerProjectPanelIndexes.length !== 1) {
throw new Error('AI game creator developer project panels mount count drifted');
}
if (
devModeBranchIndex < 0 ||
developerPaneIndex < 0 ||
developerProjectPanelIndexes.some(
(index) => index < devModeBranchIndex || index < developerPaneIndex,
)
) {
throw new Error('AI game creator preview panels must stay inside the dev-only pane');
}
// 上面几条只约束 DeveloperProjectPanels 自身的 iframe 数量和挂载位置,管不到 App.tsx
// 直接内嵌 iframe 的情况——预览必须一律委托给客户端工作台,外壳自己不持有预览框。
if ([...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g)].length !== 0) {
throw new Error(
'AI game creator app shell must delegate preview iframe to the client workbench',
);
+53 -18
View File
@@ -1,31 +1,56 @@
# platform-llm 平台适配 crate
日期:`2026-04-21`
日期:`2026-07-27`
## 1. crate 职责
`platform-llm` 是 Rust 工作区里的大模型平台适配 crate,当前首版已经落地以下能力:
`platform-llm` 是 Rust 工作区里的大模型平台适配 crate,当前已经落地以下能力:
1. 统一 Ark / DashScope / Anthropic / 其他兼容网关的文本模型配置结构
2. 统一 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 文本请求、非流式响应与 SSE 流式增量解析
2. 统一 OpenAI Chat Completions、OpenAI Responses 和 Anthropic Messages 文本 / 原生 function tool 请求、非流式响应与 SSE 流式解析
3. 统一超时、连接失败、上游错误、空响应与重试策略
4. 为后续 `module-ai``module-story``module-npc``module-custom-world` 提供可直接复用的基础 client
## 2. 当前首版边界
## 2. 当前能力边界
当前实现只覆盖“文本 run”主链,不提前混入媒体生成和业务编排:
1. 对外抽象固定为 `LlmRunRequest` / `LlmRunResponse`,不再保留旧 `LlmTextRequest` / `LlmTextResponse` 类型。
2. 支持 `OpenAiChat``OpenAiResponses` 类 API kind JSON 请求与 SSE 增量响应
3. 支持 `Anthropic` API kind 的最小文本 Messages 请求、非流式响应与 SSE 文本增量解析Anthropic URL 默认在 base URL 后拼 `/v1/messages`,如果 base URL 已以 `/v1` 结尾则只拼 `/messages`
4. 当前 run 抽象只收敛通用文本结果、finish reason、response id 和 usage;上下文管理、后台执行、provider 原生工具等高级能力后续再按 capability 显式扩展,不把 Responses 语义硬编码进业务层
5. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate。
6. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API
7. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现
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`
4. Anthropic 当前仍不支持 `web_search`、图片内容和纯 system 消息;至少需要一条非 system 文本消息。角色动画、图片、视频、资产轮询仍留在其他平台适配和业务模块任务里
5. 流式 `on_delta` 只发送文本增量与完成原因;工具调用增量在 crate 内按 slot 聚合,完整调用只从最终 `LlmRunResponse.tool_calls` 读取。上下文管理、后台执行和业务状态写回本 crate。
6. 支持按 provider 打标签,但不把业务 prompt、SSE 转发和模块状态写回本 crate
7. `DashScope` 当前只通过“调用方显式提供兼容文本网关 base url”的方式接入,不复用图像 API
8. 角色动画、图片、视频、资产轮询仍留在后续 `platform-llm` / `platform-oss` / 业务模块任务里另行实现。
## 3. 核心导出
## 3. 三协议工具支持矩阵
首版对外导出以下公共类型:
| API kind | 请求工具形态 | `tool_choice` | 非流式工具响应 | 流式工具聚合 |
| --- | --- | --- | --- | --- |
| `OpenAiChat` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `choices[0].message.tool_calls` | `delta.tool_calls[].index`;首片提供 id/name,后续拼接 arguments |
| `OpenAiResponses` | `tools[].type=function`,函数内为 `name` / `description` / `parameters` / `strict` | `"auto"` / `"required"` | `output[].type=function_call` | `output_index``output_item.added` 提供身份,`function_call_arguments.delta` 拼接,`.done` 覆盖完整参数 |
| `Anthropic` | 顶层 `tools[]``name` / `description` / `input_schema`,没有 `function` 包装层和 `strict` | `{ "type": "auto" }` / `{ "type": "any" }``Required` 映射为 `any` | `content[].type=tool_use``input` 序列化为 `arguments` | content block `index``content_block_start` 提供身份,`input_json_delta` 拼接参数 |
Responses 如果只发送 `response.completed``response.incomplete`,解析器会从其中的 `response.output[]` 恢复 `function_call`;恢复时使用 output 数组下标作为 slot。`response.incomplete` 表示上游没有完成本轮生成:其中的工具调用即使参数是完整 JSON 也返回 `Deserialize`,纯正文则保留为可用的降级结果。三种协议的并行工具调用只在平台层做 slot 聚合,不代表工具会在平台层并发执行。
## 4. 流式与参数契约
1. `LlmStreamDelta` 只包含 `accumulated_text``delta_text``finish_reason`,工具调用不会进入 `on_delta`;纯工具响应允许 `text` 为空。
2. 工具片段按协议索引聚合:Chat 使用 `delta.tool_calls[].index`Responses 使用 `output_index`Anthropic 使用 content block `index`。Responses 的 `.done``response.completed``response.incomplete` 中的完整 arguments 是权威值,可以覆盖之前的分片拼接。
3. 流结束固化工具调用时,缺少 id 或函数名返回 `Deserialize`;空参数默认保存为 `{}`;非空参数必须能反序列化为完整 JSON,截断或半截 JSON 不会交给业务层。这里是 JSON 语法完整性检查,不是针对 `parameters` 的 JSON Schema 业务校验。
4. 非流式工具调用采用不同的参数边界:缺失或空白 `arguments` 统一归一为 `{}`Chat / Responses 的非空畸形 `arguments` 不在平台层做 JSON 校验、修复或静默丢弃,而是保留参数内容(仅按统一归一策略去除首尾空白),连同 call id 和函数名交给调用方的 repair 循环。Anthropic `tool_use.input` 缺失时同样按 `{}` 归一;调用方不能把非流式参数自动假定为统一 schema 校验通过。
## 5. 错误边界
1. `Deserialize`:上游 JSON、SSE 或 UTF-8 无法解析,Chat 非流式缺少 `choices[0]`,流式工具调用缺少 id / name,或流式工具参数不是完整 JSON。
2. `StreamUnavailable`:仅用于流式响应已声明 `tool_use` / `tool_calls`,但一个工具 slot 都没有聚合出来的协议兼容失败;调用方可以据此回退一次非流式请求,不能把已收到的解说文本当成最终回复。
3. `EmptyResponse`:最终文本为空且工具调用也为空。只有文本为空但存在有效工具调用时,响应才是合法的纯工具响应。
4. 流尾部出现 `Timeout``Connectivity``Transport``Deserialize` 时,只有已形成非空文本或至少一个工具调用、观察到协议完成信号、已有完成原因且工具参数完整的响应才会保留;纯工具响应即使 `text` 为空也可以保留。工具参数半截或既没有文本也没有工具调用时继续返回错误。
## 6. 核心导出
当前对外导出以下公共类型:
1. `LlmProvider`
2. `LlmConfig`
@@ -34,12 +59,15 @@
5. `LlmRunRequest`
6. `LlmApiKind`
7. `LlmStreamDelta`
8. `LlmRunResponse`
9. `LlmTokenUsage`
10. `LlmClient`
11. `LlmError`
8. `LlmFunctionTool`
9. `LlmToolChoice`
10. `LlmToolCall`
11. `LlmRunResponse`
12. `LlmTokenUsage`
13. `LlmClient`
14. `LlmError`
## 4. 设计文档
## 7. 设计文档
## 当前文档入口
@@ -50,8 +78,15 @@
3. [../../../docs/【开发运维】本地开发验证与生产运维-2026-05-15.md](../../../docs/【开发运维】本地开发验证与生产运维-2026-05-15.md)
旧阶段设计文档不再作为实现依据。
## 5. 边界约束
## 8. 边界约束
1. `platform-llm` 只承接模型平台适配,不承接业务模块状态真相与业务规则。
2. 业务模块只能依赖这里的统一 client / DTO / 错误模型,不能再把上游请求细节散落回各 crate。
3. `api-server` 后续如果需要做 REST/SSE façade,只允许在协议层调用 `platform-llm`,不能复制一份私有实现。
## 9. 验收证据边界
1. `cargo test -p platform-llm` 的确定性用例把 checked-in SSE fixture 交给 parser,验证归一后的文本、工具调用、slot 聚合、Responses 仅有 completed / incomplete 终态事件时的恢复、参数 JSON 完整性和错误边界;同时包含 `tests/live_stream_tool_calls.rs` 中不依赖外部 Provider 的 `parse_api_kind` 本地解析测试。fixture 可以来源于真实端点抓包,但测试不保存原始 SSE,也不逐事件与端点报文比较,因此不能证明抓包转录无偏差。
2. `tests/live_stream_tool_calls.rs` 同时包含默认执行的 `parse_api_kind` 确定性测试,以及 1 个默认 `#[ignore]` 的真实端点工具调用 smoke。`PLATFORM_LLM_LIVE_API_KIND` 支持 `openai_responses``openai_chat``anthropic`,未设置或空白时默认 `openai_responses`,未知非空值会直接使验收失败。真实 smoke 只验证最终归一结果中的工具名、id 和完整参数 JSON;文本增量字符数仅用于打印观测,工具调用不进入 `on_delta`,也没有原始 SSE 录制或逐事件对比能力。
3. 因此验收应分别称为“固定 SSE fixture parser 覆盖及本地解析单元测试”和“真实端点归一工具调用 smoke”,不能把后者描述为原始 SSE fidelity 或转录一致性证明。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,163 @@
//! 真实端点的流式工具调用验收。默认 `#[ignore]`,只在显式指定环境变量时运行:
//!
//! 仓库根目录没有 Cargo.toml,必须显式指定 workspace manifest
//!
//! ```powershell
//! $env:PLATFORM_LLM_LIVE_BASE_URL = 'https://api.minimaxi.com/anthropic'
//! $env:PLATFORM_LLM_LIVE_API_KEY = '...'
//! $env:PLATFORM_LLM_LIVE_MODEL = 'MiniMax-M3'
//! $env:PLATFORM_LLM_LIVE_API_KIND = 'anthropic' # 或 openai_chat / openai_responses
//! cargo test -p platform-llm --manifest-path server-rs/Cargo.toml --test live_stream_tool_calls -- --ignored --nocapture
//! ```
//!
//! 凭据只从进程环境变量读取,不要写进仓库内任何文件。
//!
//! 本用例是实时端点工具调用 smoke,只验证归一后的工具名、id 和完整参数 JSON;
//! 文本增量字符数仅用于打印观测,不录制或逐事件比对原始 SSE,也不承担固定 fixture 转录一致性证明。
use platform_llm::{
LlmApiKind, LlmClient, LlmConfig, LlmFunctionTool, LlmMessage, LlmProvider, LlmRunRequest,
LlmToolChoice,
};
fn env_var(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
}
fn parse_api_kind(value: &str) -> Result<LlmApiKind, String> {
let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
if normalized.is_empty() {
return Ok(LlmApiKind::OpenAiResponses);
}
match normalized.as_str() {
"anthropic" => Ok(LlmApiKind::Anthropic),
"openai_chat" => Ok(LlmApiKind::OpenAiChat),
"openai_responses" => Ok(LlmApiKind::OpenAiResponses),
value => Err(format!(
"PLATFORM_LLM_LIVE_API_KIND 无效:{value},请使用 openai_responses、openai_chat 或 anthropic"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_api_kind_defaults_only_for_empty_value() {
assert_eq!(
parse_api_kind("").expect("empty api kind should default"),
LlmApiKind::OpenAiResponses
);
assert_eq!(
parse_api_kind(" ").expect("whitespace api kind should default"),
LlmApiKind::OpenAiResponses
);
}
#[test]
fn parse_api_kind_accepts_supported_values() {
assert_eq!(
parse_api_kind("anthropic").expect("anthropic should parse"),
LlmApiKind::Anthropic
);
assert_eq!(
parse_api_kind("OPENAI-CHAT").expect("openai chat should parse"),
LlmApiKind::OpenAiChat
);
assert_eq!(
parse_api_kind("openai_responses").expect("openai responses should parse"),
LlmApiKind::OpenAiResponses
);
}
#[test]
fn parse_api_kind_rejects_unknown_non_empty_value() {
let error = parse_api_kind("anthopic").expect_err("misspelled api kind must fail");
assert!(error.contains("anthopic"));
}
}
#[tokio::test]
#[ignore = "需要真实 Provider 凭据,用 --ignored 显式运行"]
async fn live_stream_run_returns_native_tool_calls() {
let (Some(base_url), Some(api_key), Some(model)) = (
env_var("PLATFORM_LLM_LIVE_BASE_URL"),
env_var("PLATFORM_LLM_LIVE_API_KEY"),
env_var("PLATFORM_LLM_LIVE_MODEL"),
) else {
panic!("缺少 PLATFORM_LLM_LIVE_BASE_URL / _API_KEY / _MODEL");
};
let api_kind = parse_api_kind(
env_var("PLATFORM_LLM_LIVE_API_KIND")
.as_deref()
.unwrap_or_default(),
)
.unwrap_or_else(|error| panic!("{error}"));
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
api_key,
model,
120_000,
0,
1_000,
)
.expect("live config should be valid");
let client = LlmClient::new(config).expect("live client should be created");
let request = LlmRunRequest::new(vec![
LlmMessage::system("你可以使用工具。需要外部数据时必须调用工具,不要凭空回答。"),
LlmMessage::user("杭州现在天气怎么样?"),
])
.with_api_kind(api_kind)
.with_max_output_tokens(512)
.with_function_tools(vec![LlmFunctionTool::new(
"get_weather",
"查询指定城市的当前天气。",
serde_json::json!({
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}),
)])
.with_tool_choice(LlmToolChoice::Required);
let mut streamed_chars = 0usize;
let response = client
.stream_run(request, |delta| {
streamed_chars += delta.delta_text.chars().count();
})
.await
.expect("live stream_run should succeed");
println!(
"api_kind={api_kind:?} finish_reason={:?} streamed_chars={streamed_chars} text={:?}",
response.finish_reason, response.text
);
for call in &response.tool_calls {
println!(
"tool_call id={} name={} args={}",
call.id, call.name, call.arguments
);
}
assert!(
!response.tool_calls.is_empty(),
"流式必须解析出工具调用,实际 finish_reason={:?}",
response.finish_reason
);
let call = &response.tool_calls[0];
assert_eq!(call.name, "get_weather");
assert!(!call.id.trim().is_empty(), "工具调用必须带 id");
let arguments: serde_json::Value =
serde_json::from_str(&call.arguments).expect("参数必须是完整 JSON");
assert!(
arguments.get("city").is_some(),
"参数应包含 city,实际为 {arguments}"
);
}