补齐 Anthropic 原生工具与三协议流式工具调用
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:
@@ -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,
|
||||
|
||||
+4
-11
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user