补齐 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()
|
.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 {
|
let role_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
|
||||||
game_creator_project_supervisor_chat_system_prompt()
|
game_creator_project_supervisor_chat_system_prompt()
|
||||||
} else {
|
} else {
|
||||||
game_creator_role_agent_chat_system_prompt()
|
game_creator_role_agent_chat_system_prompt()
|
||||||
};
|
};
|
||||||
let protocol = if native_tools {
|
let protocol = "普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。";
|
||||||
"普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。"
|
|
||||||
} else {
|
|
||||||
"你必须只输出一个 JSON 对象,不要代码块或额外文字。允许的结构为:{\"action\":\"reply\",\"reply\":\"自然语言回复\"}、{\"action\":\"execute\"}、{\"action\":\"resume\"}、{\"action\":\"project_location\"}。不要根据单个关键词决定 action,要理解整句的否定、假设、范围和上下文。"
|
|
||||||
};
|
|
||||||
format!(
|
format!(
|
||||||
"{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。"
|
"{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。"
|
||||||
)
|
)
|
||||||
@@ -114,7 +110,7 @@ fn build_agent_interaction_request_for_session(
|
|||||||
agent_id: &str,
|
agent_id: &str,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, bool), String> {
|
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
|
||||||
let prompt = prompt.trim();
|
let prompt = prompt.trim();
|
||||||
if prompt.is_empty() {
|
if prompt.is_empty() {
|
||||||
return Err("交互内容不能为空".to_string());
|
return Err("交互内容不能为空".to_string());
|
||||||
@@ -125,7 +121,6 @@ fn build_agent_interaction_request_for_session(
|
|||||||
let (llm, config_path, context) =
|
let (llm, config_path, context) =
|
||||||
build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
|
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 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() {
|
let user_prompt = if context.trim().is_empty() {
|
||||||
format!("用户这轮输入:\n{prompt}")
|
format!("用户这轮输入:\n{prompt}")
|
||||||
} else {
|
} else {
|
||||||
@@ -133,18 +128,15 @@ fn build_agent_interaction_request_for_session(
|
|||||||
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
|
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut request = LlmRunRequest::new(vec![
|
let request = LlmRunRequest::new(vec![
|
||||||
LlmMessage::system(agent_interaction_system_prompt(agent_id, native_tools)),
|
LlmMessage::system(agent_interaction_system_prompt(agent_id)),
|
||||||
LlmMessage::user(user_prompt),
|
LlmMessage::user(user_prompt),
|
||||||
])
|
])
|
||||||
.with_api_kind(api_kind)
|
.with_api_kind(api_kind)
|
||||||
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS);
|
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS)
|
||||||
if native_tools {
|
.with_function_tools(agent_interaction_function_tools())
|
||||||
request = request
|
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
|
||||||
.with_function_tools(agent_interaction_function_tools())
|
Ok((llm, config_path, request))
|
||||||
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
|
|
||||||
}
|
|
||||||
Ok((llm, config_path, request, native_tools))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at<F>(
|
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
|
where
|
||||||
F: FnMut(&platform_llm::LlmStreamDelta),
|
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)?;
|
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 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();
|
let fallback_request = request.clone();
|
||||||
match client.stream_run(request, |delta| on_delta(delta)).await {
|
match client.stream_run(request, |delta| on_delta(delta)).await {
|
||||||
Ok(response) => response,
|
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 中的裸可执行名\"",
|
"command.start 使用 {\"program\":\"受信任 PATH 中的裸可执行名\"",
|
||||||
);
|
);
|
||||||
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
|
||||||
let protocol_prompt = if api_kind == platform_llm::LlmApiKind::Anthropic {
|
let protocol_prompt = "必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 update_agent_plan,需要行动时调用对应动作工具,已有观察足够时调用 respond_to_user。只有步骤或状态真实变化时才单独调用 update_agent_plan;当前 in_progress 步骤已具备执行条件时必须在同一响应调用对应动作工具,不能只改计划解释。不要调用未广告的旧 submit_agent_tool_plan,也不要把计划或动作放在普通文本中。";
|
||||||
"当前 Provider 不提供 function tools,请返回上述 schema 的单个完整 JSON object;不要解释、markdown 或代码围栏。"
|
|
||||||
} else {
|
|
||||||
"必须直接调用当前请求提供的原生函数:需要更新持久计划时调用 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);
|
let mut system_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent(agent_id);
|
||||||
if autonomous_game_build {
|
if autonomous_game_build {
|
||||||
system_prompt.push_str(
|
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_api_kind(api_kind)
|
||||||
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
||||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low);
|
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
||||||
if api_kind != platform_llm::LlmApiKind::Anthropic {
|
.with_function_tools(build_agent_runtime_native_function_tools(mcp_catalog)?)
|
||||||
request = request
|
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||||
.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(
|
request = apply_game_creator_llm_web_search(
|
||||||
apply_game_creator_llm_reasoning_effort(request, &llm)?,
|
apply_game_creator_llm_reasoning_effort(request, &llm)?,
|
||||||
&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