补齐 Anthropic 原生工具与三协议流式工具调用
Project CI / Native shell tests (pull_request) Failing after 7m6s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Failing after 24s

Anthropic 请求发送 tools 与对象形态 tool_choice 并解析 tool_use block,解除本地对 function tools 的拦截
Chat / Responses / Anthropic 三种协议的流式工具增量按槽位聚合,收尾校验参数为完整 JSON,截断流不返回半截参数
解除 App 侧 Anthropic 降级为文本 JSON 协议的两处守卫与对应提示词分支
新增依赖真实凭据的流式工具验收用例,默认 ignore

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 13:38:41 +00:00
parent 006846e667
commit 7c61e9a532
4 changed files with 927 additions and 130 deletions
@@ -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,
@@ -139,11 +139,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
"command.start 使用 {\"program\":\"受信任 PATH 中的裸可执行名\"",
);
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let protocol_prompt = if api_kind == platform_llm::LlmApiKind::Anthropic {
"当前 Provider 不提供 function tools,请返回上述 schema 的单个完整 JSON object;不要解释、markdown 或代码围栏。"
} else {
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。"
};
let protocol_prompt = "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。";
let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id);
if autonomous_game_build {
system_prompt.push_str(
@@ -167,12 +163,9 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
])
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low);
if api_kind != platform_llm::LlmApiKind::Anthropic {
request = request
.with_function_tools(build_agent_runtime_native_function_tools(mcp_catalog)?)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
}
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
.with_function_tools(build_agent_runtime_native_function_tools(mcp_catalog)?)
.with_tool_choice(platform_llm::LlmToolChoice::Required);
request = apply_game_creator_llm_web_search(
apply_game_creator_llm_reasoning_effort(request, &llm)?,
&llm,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
//! 真实端点的流式工具调用验收。默认 `#[ignore]`,只在显式指定环境变量时运行:
//!
//! ```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 --test live_stream_tool_calls -- --ignored --nocapture
//! ```
//!
//! 单测里的 SSE 是转录的真实报文,这个用例负责证明转录没有偏差。
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) -> LlmApiKind {
match value.trim().to_ascii_lowercase().replace('-', "_").as_str() {
"anthropic" => LlmApiKind::Anthropic,
"openai_chat" => LlmApiKind::OpenAiChat,
_ => LlmApiKind::OpenAiResponses,
}
}
#[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").unwrap_or_default());
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}"
);
}