补齐 Anthropic 原生工具与三协议流式工具调用 #112
@@ -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,
|
||||
|
||||
+21
-66
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 不广告旧 wrapper;parser 只为 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 原生工具」取代;三种协议都提供原生 tool,interaction 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 ignored;V1.26 真实行为门禁至此完成。
|
||||
@@ -1493,6 +1493,7 @@ V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是
|
||||
- 恢复验收必须在同一轮证明:同一 requestId 只闭合一次且不产生替代 requestId,proxy 的 `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` quarantine;Windows 使用相对父句柄、`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 schema;Runtime 只接受恰好一次同名 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 schema;Runtime 只接受恰好一次同名 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 各 1,Skill 正文、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-fast,Provider 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 和 protocol;protocol 只保存 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/漂移/终态清理前先闭合整本账本的实际 requestId,Runner 恢复严格扫描 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,不直接修改项目、不伪造工具 observation;wrapper 在仓库外创建带 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`。排查或切换密钥后,可在本地运行:
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user