收敛 Agent 工具计划格式修复

过滤 Provider 推理内容并受限归一化非最终工具旁白
为原生工具协议增加递归重复键校验和稳定错误分类
补充 repair 统计、失败证据与零泄漏真实验收门禁
同步 Agent Runtime 技术方案和项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-18 02:04:08 +08:00
parent 0ca15a2645
commit 1b045a5f46
7 changed files with 1423 additions and 137 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use platform_llm::{LlmFunctionTool, LlmToolCall};
use serde::de::{DeserializeOwned, Error as _, MapAccess, SeqAccess, Visitor};
use serde::Deserialize;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
@@ -17,6 +19,185 @@ pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user";
const AGENT_RUNTIME_NATIVE_TOOL_PREFIX: &str = "runtime_tool_";
const AGENT_RUNTIME_NATIVE_MCP_PREFIX: &str = "mcp_tool_";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum AgentRuntimeToolPlanProtocolErrorKind {
ResponseShape,
CallIdentity,
UnknownFunction,
ArgumentsJson,
ArgumentsSchema,
BatchConstraint,
PlanSemantics,
CatalogBinding,
}
impl AgentRuntimeToolPlanProtocolErrorKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::ResponseShape => "response-shape",
Self::CallIdentity => "call-identity",
Self::UnknownFunction => "unknown-function",
Self::ArgumentsJson => "arguments-json",
Self::ArgumentsSchema => "arguments-schema",
Self::BatchConstraint => "batch-constraint",
Self::PlanSemantics => "plan-semantics",
Self::CatalogBinding => "catalog-binding",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentRuntimeToolPlanProtocolError {
kind: AgentRuntimeToolPlanProtocolErrorKind,
detail: String,
}
impl AgentRuntimeToolPlanProtocolError {
pub(crate) fn new(
kind: AgentRuntimeToolPlanProtocolErrorKind,
detail: impl Into<String>,
) -> Self {
Self {
kind,
detail: detail.into(),
}
}
pub(crate) fn kind(&self) -> AgentRuntimeToolPlanProtocolErrorKind {
self.kind
}
}
impl fmt::Display for AgentRuntimeToolPlanProtocolError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.detail)
}
}
fn protocol_error(
kind: AgentRuntimeToolPlanProtocolErrorKind,
detail: impl Into<String>,
) -> AgentRuntimeToolPlanProtocolError {
AgentRuntimeToolPlanProtocolError::new(kind, detail)
}
struct DuplicateSafeJson;
impl<'de> Deserialize<'de> for DuplicateSafeJson {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(DuplicateSafeJsonVisitor)
}
}
struct DuplicateSafeJsonVisitor;
impl<'de> Visitor<'de> for DuplicateSafeJsonVisitor {
type Value = DuplicateSafeJson;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("不包含重复 object key 的 JSON value")
}
fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_string<E>(self, _value: String) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_unit<E>(self) -> Result<Self::Value, E> {
Ok(DuplicateSafeJson)
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: serde::Deserializer<'de>,
{
DuplicateSafeJson::deserialize(deserializer)
}
fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
while sequence.next_element::<DuplicateSafeJson>()?.is_some() {}
Ok(DuplicateSafeJson)
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut keys = HashSet::new();
while let Some(key) = map.next_key::<String>()? {
if !keys.insert(key.clone()) {
return Err(A::Error::custom(format!("重复 JSON object key{key}")));
}
map.next_value::<DuplicateSafeJson>()?;
}
Ok(DuplicateSafeJson)
}
}
pub(crate) fn validate_agent_runtime_protocol_json(
json: &str,
description: &str,
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
let mut deserializer = serde_json::Deserializer::from_str(json);
DuplicateSafeJson::deserialize(&mut deserializer)
.and_then(|_| deserializer.end())
.map_err(|error| {
let kind = match error.classify() {
serde_json::error::Category::Data => {
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema
}
serde_json::error::Category::Io
| serde_json::error::Category::Syntax
| serde_json::error::Category::Eof => {
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsJson
}
};
protocol_error(kind, format!("{description}{error}"))
})
}
fn parse_native_arguments<T: DeserializeOwned>(
arguments: &str,
description: &str,
) -> Result<T, AgentRuntimeToolPlanProtocolError> {
validate_agent_runtime_protocol_json(arguments, description)?;
serde_json::from_str::<T>(arguments).map_err(|error| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("{description} schema 无效:{error}"),
)
})
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct NativeAgentRuntimeToolPlan {
pub(crate) plan: AgentRuntimeToolPlan,
@@ -108,9 +289,12 @@ pub(crate) fn build_agent_runtime_native_function_tools(
pub(crate) fn parse_agent_runtime_native_tool_calls(
calls: &[LlmToolCall],
mcp_catalog: &GameCreatorMcpCatalog,
) -> Result<NativeAgentRuntimeToolPlan, String> {
) -> Result<NativeAgentRuntimeToolPlan, AgentRuntimeToolPlanProtocolError> {
if calls.is_empty() {
return Err("Agent 原生工具协议错误:function calls 不能为空".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ResponseShape,
"Agent 原生工具协议错误:function calls 不能为空",
));
}
let mut seen_call_ids = HashSet::new();
let mut plan_update = None;
@@ -122,29 +306,40 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
for call in calls {
let call_id = call.id.trim();
if call_id.is_empty() || !seen_call_ids.insert(call_id.to_string()) {
return Err("Agent 原生工具协议错误:call id 必须非空且唯一".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::CallIdentity,
"Agent 原生工具协议错误:call id 必须非空且唯一",
));
}
call_ids.push(call_id.to_string());
function_names.push(call.name.clone());
if call.name == AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME {
if plan_update.is_some() {
return Err("Agent 原生工具协议错误:一次响应只能更新一次计划".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
"Agent 原生工具协议错误:一次响应只能更新一次计划",
));
}
plan_update = Some(
serde_json::from_str::<AgentRuntimePlanUpdate>(&call.arguments)
.map_err(|error| format!("解析原生计划更新失败:{error}"))?,
);
plan_update = Some(parse_native_arguments::<AgentRuntimePlanUpdate>(
&call.arguments,
"解析原生计划更新失败",
)?);
continue;
}
if call.name == AGENT_RUNTIME_RESPOND_FUNCTION_NAME {
if response.is_some() {
return Err("Agent 原生工具协议错误:一次响应只能提交一个最终回复".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
"Agent 原生工具协议错误:一次响应只能提交一个最终回复",
));
}
response = Some(
serde_json::from_str::<NativeResponseArguments>(&call.arguments)
.map_err(|error| format!("解析原生最终回复失败:{error}"))?
.response,
parse_native_arguments::<NativeResponseArguments>(
&call.arguments,
"解析原生最终回复失败",
)?
.response,
);
continue;
}
@@ -152,14 +347,19 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
let runtime_tool = runtime_tool_for_native_function(&call.name);
let mcp_tool = mcp_tool_for_native_function(&call.name, mcp_catalog)?;
if runtime_tool.is_none() && mcp_tool.is_none() {
return Err(format!("Agent 原生工具协议错误:未知函数 {}", call.name));
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction,
format!("Agent 原生工具协议错误:未知函数 {}", call.name),
));
}
let arguments = serde_json::from_str::<NativeActionArguments>(&call.arguments)
.map_err(|error| format!("解析原生工具 {} 参数失败:{error}", call.name))?;
let arguments = parse_native_arguments::<NativeActionArguments>(
&call.arguments,
&format!("解析原生工具 {} 参数失败", call.name),
)?;
if arguments.reason.trim().is_empty() {
return Err(format!(
"Agent 原生工具协议错误:{} reason 不能为空",
call.name
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:{} reason 不能为空", call.name),
));
}
if runtime_tool.as_deref() == Some("agent.delegate") {
@@ -173,9 +373,9 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
}
} else if let Some(tool) = mcp_tool {
if !arguments.input.is_object() {
return Err(format!(
"Agent 原生 MCP 工具 {} input 必须是 object",
call.name
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生 MCP 工具 {} input 必须是 object", call.name),
));
}
AgentRuntimeToolAction {
@@ -192,20 +392,29 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
};
actions.push(action);
if actions.len() > AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT {
return Err(format!(
"Agent 原生工具协议错误:一次最多调用 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个动作工具"
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
format!(
"Agent 原生工具协议错误:一次最多调用 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个动作工具"
),
));
}
}
if response.is_some() && !actions.is_empty() {
return Err("Agent 原生工具协议错误:最终回复不能与动作工具同时提交".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint,
"Agent 原生工具协议错误:最终回复不能与动作工具同时提交",
));
}
if response
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
return Err("Agent 原生工具协议错误:最终回复不能为空".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics,
"Agent 原生工具协议错误:最终回复不能为空",
));
}
let response = response.unwrap_or_default();
let thinking_summary = plan_update
@@ -227,7 +436,9 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
})
}
fn validate_native_agent_delegate_input(input: &Value) -> Result<(), String> {
fn validate_native_agent_delegate_input(
input: &Value,
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
const REQUIRED_FIELDS: [&str; 6] = [
"agentId",
"task",
@@ -236,13 +447,17 @@ fn validate_native_agent_delegate_input(input: &Value) -> Result<(), String> {
"repairOfDelegationId",
"runId",
];
let object = input
.as_object()
.ok_or_else(|| "Agent 原生工具协议错误:agent.delegate input 必须是 object".to_string())?;
let object = input.as_object().ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:agent.delegate input 必须是 object",
)
})?;
for field in REQUIRED_FIELDS {
if !object.contains_key(field) {
return Err(format!(
"Agent 原生工具协议错误:agent.delegate 缺少 {field}"
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:agent.delegate 缺少 {field}"),
));
}
}
@@ -250,7 +465,10 @@ fn validate_native_agent_delegate_input(input: &Value) -> Result<(), String> {
.keys()
.any(|field| !REQUIRED_FIELDS.contains(&field.as_str()))
{
return Err("Agent 原生工具协议错误:agent.delegate 包含未知字段".to_string());
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:agent.delegate 包含未知字段",
));
}
validate_native_delegate_string(object.get("agentId"), "agentId", 96, false)?;
validate_native_delegate_string(object.get("task"), "task", 2_400, false)?;
@@ -282,17 +500,21 @@ fn validate_native_delegate_string(
field: &str,
max_chars: usize,
nullable: bool,
) -> Result<(), String> {
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
if nullable && value.is_some_and(Value::is_null) {
return Ok(());
}
let value = value
.and_then(Value::as_str)
.ok_or_else(|| format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"))?;
let value = value.and_then(Value::as_str).ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"),
)
})?;
let chars = value.chars().count();
if value.trim().is_empty() || chars > max_chars {
return Err(format!(
"Agent 原生工具协议错误:agent.delegate {field} 长度无效"
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:agent.delegate {field} 长度无效"),
));
}
Ok(())
@@ -304,13 +526,17 @@ fn validate_native_delegate_string_list(
min_items: usize,
max_items: usize,
max_chars: usize,
) -> Result<(), String> {
let values = value
.and_then(Value::as_array)
.ok_or_else(|| format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"))?;
) -> Result<(), AgentRuntimeToolPlanProtocolError> {
let values = value.and_then(Value::as_array).ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:agent.delegate {field} 类型无效"),
)
})?;
if values.len() < min_items || values.len() > max_items {
return Err(format!(
"Agent 原生工具协议错误:agent.delegate {field} 数量无效"
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生工具协议错误:agent.delegate {field} 数量无效"),
));
}
for value in values {
@@ -330,14 +556,17 @@ fn runtime_tool_for_native_function(name: &str) -> Option<String> {
fn mcp_tool_for_native_function<'a>(
name: &str,
catalog: &'a GameCreatorMcpCatalog,
) -> Result<Option<&'a GameCreatorMcpCatalogTool>, String> {
) -> Result<Option<&'a GameCreatorMcpCatalogTool>, AgentRuntimeToolPlanProtocolError> {
let matches = catalog
.tools
.iter()
.filter(|tool| native_mcp_function_name(&tool.server_id, &tool.name) == name)
.collect::<Vec<_>>();
if matches.len() > 1 {
return Err(format!("MCP 原生函数 binding 冲突:{name}"));
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::CatalogBinding,
format!("MCP 原生函数 binding 冲突:{name}"),
));
}
Ok(matches.into_iter().next())
}
@@ -30807,6 +30807,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"));
assert!(initial_request.contains("arguments.input"));
assert!(initial_request.contains("禁止把 input 字段扁平到 arguments 顶层"));
assert!(initial_request.contains("必须调用 respond_to_user"));
let repair_request = receiver
.recv_timeout(Duration::from_secs(2))
.expect("first native tool plan repair request");
@@ -30844,6 +30848,7 @@ async fn background_agent_runtime_repairs_malformed_native_function_arguments()
.iter()
.zip(["call-native-malformed-1", "call-native-malformed-2"])
{
assert_eq!(record["protocolErrorKind"], "arguments-json");
assert_eq!(
record["callIdSha256"],
format!("{:x}", Sha256::digest(call_id.as_bytes()))
@@ -30865,6 +30870,147 @@ async fn background_agent_runtime_repairs_malformed_native_function_arguments()
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_audits_thinking_normalization_without_body() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "推理归一化审计").expect("project init");
let thinking = "<think>NORMALIZATION_PRIVATE_CANARY</think>";
let mut tool_response = native_agent_tool_plan_chat_response(
"call-normalized-reply",
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
serde_json::json!({"response": "推理块已安全归一化。NORMALIZATION_OK"}).to_string(),
);
tool_response["choices"][0]["message"]["content"] = serde_json::json!(thinking);
let base_url = spawn_mock_llm_raw_responses_with_capture(vec![tool_response], None);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_chat"
}}
}}
}}"#
));
let run_id = "design-thinking-normalization-audit-run";
start_game_creator_agent_background_task_at(
&root,
"design-director",
"验证推理块归一化审计不保存正文",
run_id,
)
.expect("start normalization task");
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(runtime.status, "idle");
assert_eq!(runtime.phase, "completed");
assert_eq!(
runtime.last_response.as_deref(),
Some("推理块已安全归一化。NORMALIZATION_OK")
);
let records = read_agent_db_records_for_test(&root);
let protocol = records
.iter()
.find(|record| {
record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id
})
.expect("normalized protocol audit");
assert_eq!(
protocol["normalizationKinds"],
serde_json::json!(["complete-think-block"])
);
assert_eq!(protocol["normalizationCount"], 1);
assert_eq!(protocol["normalizedTextChars"], thinking.chars().count());
assert_eq!(
protocol["normalizedTextSha256"].as_str().map(str::len),
Some(64)
);
let serialized = serde_json::to_string(protocol).expect("serialize protocol audit");
assert!(!serialized.contains("NORMALIZATION_PRIVATE_CANARY"));
assert!(!serialized.contains("<think>"));
assert!(protocol.get("normalizedText").is_none());
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_audits_planner_commentary_without_body() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "计划旁白归一化审计").expect("project init");
let commentary = "PLANNER_COMMENTARY_PRIVATE_CANARY";
let mut action_response = native_agent_tool_plan_chat_response(
"call-commentary-index",
&native_runtime_function_name("project.index").expect("index function"),
serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
);
action_response["choices"][0]["message"]["content"] = serde_json::json!(commentary);
let final_response = native_agent_tool_plan_chat_response(
"call-commentary-final",
AGENT_RUNTIME_RESPOND_FUNCTION_NAME,
serde_json::json!({"response": "计划旁白已安全归一化。COMMENTARY_OK"}).to_string(),
);
let base_url =
spawn_mock_llm_raw_responses_with_capture(vec![action_response, final_response], None);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"design-director": {{
"apiKey": "design-key",
"baseUrl": {base_url:?},
"model": "design-runtime-model",
"apiKind": "openai_chat"
}}
}}
}}"#
));
let run_id = "design-planner-commentary-audit-run";
start_game_creator_agent_background_task_at(
&root,
"design-director",
"验证非最终计划旁白不触发 Provider repair",
run_id,
)
.expect("start commentary task");
let runtime = wait_for_agent_runtime_idle(&root, "design-director");
assert_eq!(runtime.status, "idle");
assert_eq!(runtime.phase, "completed");
assert_eq!(
runtime.last_response.as_deref(),
Some("计划旁白已安全归一化。COMMENTARY_OK")
);
let records = read_agent_db_records_for_test(&root);
assert!(!records.iter().any(|record| {
record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id
}));
let protocol = records
.iter()
.find(|record| {
record["recordType"] == "agent.runtime.tool_plan.protocol"
&& record["runId"] == run_id
&& record["normalizationCount"] == 1
})
.expect("planner commentary protocol audit");
assert_eq!(
protocol["normalizationKinds"],
serde_json::json!(["planner-commentary"])
);
assert_eq!(protocol["normalizedTextChars"], commentary.chars().count());
assert_eq!(
protocol["normalizedTextSha256"].as_str().map(str::len),
Some(64)
);
let serialized = serde_json::to_string(protocol).expect("serialize protocol audit");
assert!(!serialized.contains(commentary));
assert!(protocol.get("normalizedText").is_none());
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() {
let root = unique_project_path();
@@ -30961,6 +31107,7 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() {
let repair_record = repair_records[0];
assert_eq!(repair_record["agentId"], "design-director");
assert_eq!(repair_record["attempt"], 1);
assert_eq!(repair_record["protocolErrorKind"], "arguments-json");
assert_eq!(
repair_record["maxAttempts"],
AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS
@@ -37968,20 +38115,303 @@ fn agent_native_tool_parser_rejects_action_budget_and_duplicate_control_calls()
}
#[test]
fn agent_native_tool_parser_rejects_function_calls_with_text_body() {
fn agent_native_tool_parser_normalizes_nonfinal_planner_commentary() {
let text = "先读取项目索引,再根据结果继续。";
let response = agent_tool_plan_llm_response(
"这段普通正文不能和 function call 共存",
text,
vec![platform_llm::LlmToolCall {
id: "call-with-text".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
}],
);
let parsed = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect("nonfinal native calls make planner commentary non-authoritative");
assert_eq!(parsed.plan.actions.len(), 1);
assert_eq!(parsed.normalization_kinds, vec!["planner-commentary"]);
assert_eq!(parsed.normalization_count, 1);
assert_eq!(parsed.normalized_text_chars, text.chars().count());
assert_eq!(
parsed.normalized_text_sha256.as_deref().map(str::len),
Some(64)
);
}
#[test]
fn agent_native_tool_parser_rejects_user_reply_with_text_body() {
let response = agent_tool_plan_llm_response(
"这段普通正文不能和显式用户回复共存",
vec![platform_llm::LlmToolCall {
id: "call-reply-with-text".to_string(),
name: AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
arguments: serde_json::json!({"response": "显式用户回复"}).to_string(),
}],
);
let error = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect_err("tool calls with plain text must fail");
.expect_err("user-visible reply calls with plain text must fail");
assert!(error.contains("不能同时携带普通文本正文"));
}
#[test]
fn agent_native_tool_parser_accepts_complete_thinking_blocks_without_visible_text() {
let text = "<think>内部推理不应进入协议正文</think>\n<THINK>第二段推理</THINK>";
let response = agent_tool_plan_llm_response(
text,
vec![platform_llm::LlmToolCall {
id: "call-with-thinking".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
}],
);
let parsed = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect("complete thinking blocks may accompany native calls");
assert_eq!(parsed.protocol, "native_runtime_tools");
assert_eq!(parsed.normalization_kinds, vec!["complete-think-block"]);
assert_eq!(parsed.normalization_count, 2);
assert_eq!(parsed.normalized_text_chars, text.chars().count());
assert_eq!(
parsed.normalized_text_sha256.as_deref().map(str::len),
Some(64)
);
}
#[test]
fn agent_native_tool_parser_accepts_balanced_nested_thinking_block() {
let response = agent_tool_plan_llm_response(
"<think>外层推理<think>内层推理</think></think>",
vec![platform_llm::LlmToolCall {
id: "call-with-balanced-nested-thinking".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
}],
);
let parsed = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect("balanced nested thinking is one complete hidden block");
assert_eq!(parsed.normalization_kinds, vec!["complete-think-block"]);
assert_eq!(parsed.normalization_count, 1);
}
#[test]
fn agent_native_tool_parser_rejects_incomplete_thinking_block_as_visible_text() {
let response = agent_tool_plan_llm_response(
"<think>未闭合的推理块",
vec![platform_llm::LlmToolCall {
id: "call-with-incomplete-thinking".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
}],
);
let error = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect_err("incomplete thinking blocks must remain visible and fail closed");
assert!(error.contains("不能同时携带普通文本正文"));
}
#[test]
fn agent_native_tool_parser_rejects_nested_unclosed_thinking_block() {
let response = agent_tool_plan_llm_response(
"<think>外层未闭合<think>内层内容</think>",
vec![platform_llm::LlmToolCall {
id: "call-with-nested-incomplete-thinking".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取项目索引", "input": {}}).to_string(),
}],
);
let error = parse_game_creator_agent_tool_plan_llm_response(&response)
.expect_err("nested incomplete thinking blocks must fail closed");
assert!(error.contains("不能同时携带普通文本正文"));
}
#[test]
fn agent_tool_plan_protocol_errors_expose_stable_kinds() {
let empty_catalog = GameCreatorMcpCatalog {
fingerprint: "empty-catalog".to_string(),
servers: Vec::new(),
tools: Vec::new(),
};
let parse = |response: platform_llm::LlmRunResponse| {
parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
&response,
&empty_catalog,
)
.expect_err("fixture must fail")
.kind()
};
assert_eq!(
parse(agent_tool_plan_llm_response("not-json", Vec::new())),
AgentRuntimeToolPlanProtocolErrorKind::ResponseShape
);
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: serde_json::json!({"reason": "读取", "input": {}}).to_string(),
}],
)),
AgentRuntimeToolPlanProtocolErrorKind::CallIdentity
);
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "unknown-call".to_string(),
name: "unknown_function".to_string(),
arguments: "{}".to_string(),
}],
)),
AgentRuntimeToolPlanProtocolErrorKind::UnknownFunction
);
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "bad-json".to_string(),
name: native_runtime_function_name("project.index").expect("index function"),
arguments: "{".to_string(),
}],
)),
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsJson
);
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "bad-schema".to_string(),
name: native_runtime_function_name("file.read").expect("file read function"),
arguments: serde_json::json!({"reason": "读取", "path": "README.md"}).to_string(),
}],
)),
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema
);
for (id, name, arguments) in [
(
"duplicate-action-field",
native_runtime_function_name("project.index").expect("index function"),
r#"{"reason":"first","reason":"second","input":{}}"#,
),
(
"duplicate-plan-field",
AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(),
r#"{"thinkingSummary":"first","thinkingSummary":"second","planUpdate":null,"plan":[],"actions":[],"response":""}"#,
),
(
"duplicate-nested-action-input-field",
native_runtime_function_name("file.read").expect("file read function"),
r#"{"reason":"read","input":{"path":"first","path":"second","startLine":1,"maxLines":120}}"#,
),
(
"duplicate-nested-wrapper-input-field",
AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(),
r#"{"thinkingSummary":"read","planUpdate":null,"plan":[],"actions":[{"tool":"file.read","reason":"read","input":{"path":"first","path":"second"}}],"response":""}"#,
),
] {
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: id.to_string(),
name,
arguments: arguments.to_string(),
}],
)),
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema
);
}
let action_name = native_runtime_function_name("project.index").expect("index function");
let too_many_actions = (0..4)
.map(|index| platform_llm::LlmToolCall {
id: format!("batch-{index}"),
name: action_name.clone(),
arguments: serde_json::json!({"reason": "读取", "input": {}}).to_string(),
})
.collect();
assert_eq!(
parse(agent_tool_plan_llm_response("", too_many_actions)),
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint
);
assert_eq!(
parse(agent_tool_plan_llm_response(
"",
vec![
platform_llm::LlmToolCall {
id: "reply-with-action".to_string(),
name: AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
arguments: serde_json::json!({"response": "不应与动作共存"}).to_string(),
},
platform_llm::LlmToolCall {
id: "action-with-reply".to_string(),
name: action_name,
arguments: serde_json::json!({"reason": "读取", "input": {}}).to_string(),
},
],
)),
AgentRuntimeToolPlanProtocolErrorKind::BatchConstraint
);
let plan_semantics = agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "empty-reply".to_string(),
name: AGENT_RUNTIME_RESPOND_FUNCTION_NAME.to_string(),
arguments: serde_json::json!({"response": " "}).to_string(),
}],
);
assert_eq!(
parse(plan_semantics),
AgentRuntimeToolPlanProtocolErrorKind::PlanSemantics
);
let duplicate_tool = GameCreatorMcpCatalogTool {
server_id: "duplicate-server".to_string(),
name: "duplicate-tool".to_string(),
title: None,
description: "duplicate fixture".to_string(),
input_schema: serde_json::json!({
"type": "object",
"required": [],
"additionalProperties": false,
"properties": {}
}),
output_schema: None,
read_only_hint: true,
destructive_hint: false,
open_world_hint: false,
configured_approval_mode: "auto".to_string(),
effective_approval_mode: "auto".to_string(),
fingerprint: "duplicate-tool-fingerprint".to_string(),
};
let duplicate_name = native_mcp_function_name(&duplicate_tool.server_id, &duplicate_tool.name);
let duplicate_catalog = GameCreatorMcpCatalog {
fingerprint: "duplicate-catalog".to_string(),
servers: Vec::new(),
tools: vec![duplicate_tool.clone(), duplicate_tool],
};
let catalog_error = parse_game_creator_agent_tool_plan_llm_response_with_catalog_classified(
&agent_tool_plan_llm_response(
"",
vec![platform_llm::LlmToolCall {
id: "duplicate-binding".to_string(),
name: duplicate_name,
arguments: serde_json::json!({"reason": "查询", "input": {}}).to_string(),
}],
),
&duplicate_catalog,
)
.expect_err("duplicate binding must fail");
assert_eq!(
catalog_error.kind(),
AgentRuntimeToolPlanProtocolErrorKind::CatalogBinding
);
}
#[test]
fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
let catalog = GameCreatorMcpCatalog {
@@ -4823,3 +4823,13 @@
- 完成边界:finalization 只认可同一父 run 的 durable static delivery 和 isolated group。repair delegate、合法 isolated 检查、读取、状态查询和项目验证继续允许;源码写入、patch/restore、Git commit、命令启动及平台素材生成由专业 Agent 承担。
- 验证方式:运行 `supervisor_collaboration_``provider_action_batch_``project_supervisor_mixed_` 定向 Rust 回归,随后执行编码检查和 `git diff --check`;真实 Provider V1.32 必须在最终代码 diff 上独立完成,不能复用 V1.31 报告。
- 真实验收:2026-07-17 使用 `gpt-5.5 / openai_chat / high` 完成 `supervisor-swarm-collaboration-policy-mixed-recovery` 最终代码独立 PASS。隔离 AppData 副本启用 `maxRetries=2`,正式 AppData 与 Runner endpoint 保持未修改;86 个 Provider lifecycle 全部完成,本轮未触发重试。单一父 Session/run 完成首批 2 个 static delegate + 1 个三 child isolated group、Runner pidfd 强杀恢复、1 次 repair、3 次 delivery 认领、宿主验证和唯一最终回复;重复 delivery/group/instance/result/join/claim/message/action/receipt/lifecycle、残留 sidecar、私密正文、API Key、项目路径与正式配置路径泄漏均为 0。报告同时暴露 49 次 native tool plan 中有 30 次格式修复,作为后续性能与提示合同收敛风险保留。
## 2026-07-18 Agent 原生工具计划 repair 使用稳定分类与受限归一化
- 背景:V1.32 虽然完整 PASS,但 49 次成功 native tool plan 伴随 30 次格式修复;原有审计只有错误哈希和字符数,无法判断是正文混入、arguments JSON、schema 还是批次语义导致,也无法在失败 partial report 中比较分布。
- 兼容边界:`platform-llm` 排除明确 reasoning/analysis content partAgent 移除完整、嵌套闭合的 `<think>...</think>`。只有不含 `respond_to_user` 和旧 wrapper 的 native planning 响应可把剩余正文按 `planner-commentary` 归一化,并继续以 function calls 为权威动作;最终用户回复、legacy wrapper、未闭合或错配 thinking 标签继续失败关闭。
- 协议分类:固定使用 `response-shape / call-identity / unknown-function / arguments-json / arguments-schema / batch-constraint / plan-semantics / catalog-binding`。JSON 先递归拒绝顶层和任意嵌套 input 的重复 object key,再做 schema 解析;前七类按现有上限进入格式修复,目录 binding 冲突直接失败且成功报告中必须为 0。控制流不再从中文错误字符串反推类别。
- 审计与报告:repair 只新增 `protocolErrorKind`;成功归一化只保存固定 `complete-think-block / planner-commentary` kind、数量、字符数和 SHA-256。真实 E2E 报告增加 repaired loop、second repair 和固定补零直方图,完整与 partial 证据复用同一选择器和聚合器,分类总和必须闭合且不得携带原始错误、正文、arguments、preview 或单条身份;白名单必须包含 Agent DB 固有 `schemaVersion / updatedAt`,不能把安全 envelope 误报成正文泄漏。
- Prompt:原生 function arguments 的统一外壳明确为 `reason + input`legacy text JSON schema 只属于没有 function tools 的 Provider,避免工具自己的 input schema 与旧 actions JSON 示例互相竞争。
- 诊断过程:首轮旧保守正文规则得到 35 个成功计划、23 次 `response-shape` repair,且因 E2E 白名单遗漏 Agent DB envelope 误报 58 条泄漏而 FAIL;第二次新规则尝试在 2 个计划、0 repair 时因 Provider isolated write scope 不满足 fixture 提前停止。两轮都不作为完成证据。
- 真实验收:最终代码对应的正式 `gpt-5.5 / openai_chat / high` 同 suite 独立 PASS。46/46 个成功计划全部使用 `native_runtime_tools`,格式 repair 和八类直方图均为 0Provider lifecycle 为 54/54 started/terminal,其中 53 completed、1 次瞬态失败通过新 request identity 显式重试恢复,相比 V1.32 的 86 减少 32,总耗时 `631.6s`。static + isolated 混合协作、业务 delivery repair、Provider 真并行、pidfd Runner 强杀恢复、宿主验证和唯一 Supervisor assistant 全部成立;重复、残留 sidecar、正文、Key、项目 / 正式配置路径与报告泄漏均为 0,隔离现场完整清理。
@@ -1179,6 +1179,21 @@ V1.31 证明真实 Provider 可以自主形成 static + isolated 混合协作,
成功报告共记录 178 个 task snapshot、326 个 event、556 个 Agent DB record、30 个 action execution 和 38 个 receipt;重复 delivery/group/instance/result/join/claim/message/action/receipt/Provider lifecycle 与 pending/batch/finalization/confirmation sidecar 均为 0,私密正文、Provider payload、API Key、项目路径、正式配置路径和最终报告泄漏均为 0。`turn.report=settled` 且 reconciliation Agent 为 0。49 次 native tool plan 中发生 30 次格式修复,未破坏动作幂等与最终结果,但说明真实链路仍有明显延迟和 Provider 调用成本,后续应单独收敛工具合同表达和 repair 频率。
## V1.33 原生工具计划 repair 收敛与分类
V1.32 的真实基线是 `49` 次成功 native tool plan 对应 `30` 次格式修复。V1.33 不放宽动作、完成、权限或恢复门禁,只收敛 OpenAI-compatible Provider 的工具响应兼容边界,并让每次 repair 可以按稳定类别计数。
- `platform-llm` 的 Chat Completions 与 Responses content parts 只排除明确标记为 `reasoning / reasoning_content / analysis / thinking` 的内部推理 part;普通 `text / output_text` 继续进入可见正文,独立 `reasoning_content` 不提升为正文。不能因为响应同时包含 tool calls 就笼统丢弃 content。
- Agent 原生工具解析可以移除完整、大小写不敏感且嵌套闭合的 `<think>...</think>` 块。对于不含 `respond_to_user` 和旧 `submit_agent_tool_plan` 的 native planning 响应,剩余普通文本只作为不具执行权的 `planner-commentary` 丢弃,以 function calls 作为权威动作;最终用户回复、legacy wrapper、未闭合 / 错配 thinking 标签仍按 `response-shape` 失败关闭。成功归一化的公共审计只保存固定 `complete-think-block / planner-commentary` kind、数量、原文本字符数和 SHA-256,不保存正文。
- 工具计划错误使用固定类别 `response-shape / call-identity / unknown-function / arguments-json / arguments-schema / batch-constraint / plan-semantics / catalog-binding`。JSON 先递归遍历所有 object key,再做 schema 解析,既稳定区分语法与 schema,也拒绝顶层及任意嵌套 input 的重复字段。repair prompt 仍可在当前瞬时私有请求中使用过滤后的错误细节,Agent DB 与 E2E 报告只保存类别、计数和既有哈希;`catalog-binding` 属于本地目录冲突,必须直接失败,不能重问 Provider,也不能在成功 E2E 报告中出现非零计数。
- OpenAI-compatible planning prompt 必须把原生 function schema 作为参数事实源。legacy text JSON schema 明确只供不支持 function tools 的 Provider 使用;所有动作函数参数统一为 `{"reason":"...","input":{...}}`,工具输入示例只描述 `input` 字段,禁止把 input 属性扁平到 arguments 顶层。
- E2E 证据固定输出发生过 repair 的 loop 数、第二次 repair 数和按上述固定类别补零后的直方图;分类总和必须等于 repair 总数。完整报告和失败 partial report 使用同一聚合器,禁止输出单条错误、错误正文、preview、arguments、Agent/run/loop 身份或动态类别。
- 确定性验收覆盖 standalone reasoning、reasoning content part、可见 content、完整 / 嵌套 / 未闭合 thinking block、非最终 commentary、最终回复正文冲突、重复 JSON 字段、八类错误、repair 审计零正文和报告聚合闭合。真实验收继续复用 V1.32 `supervisor-swarm-collaboration-policy-mixed-recovery`,在相同正式路由和隔离 AppData 口径下对比 `49 / 30` 基线,并同时检查总耗时、唯一 lifecycle、零重复、零泄漏和现场清理。
2026-07-18 第一轮诊断在旧保守正文规则下形成 `35` 次成功计划与 `23``response-shape` repair,比例未比 V1.32 下降;同时新 E2E 白名单遗漏 Agent DB 固有的 `schemaVersion / updatedAt`,把 58 条安全审计误判为 payload leak,因此该轮 **FAIL** 且不作为完成证据。第二次尝试在 2 次计划、0 repair 时因 Provider 给出的 isolated child write scope 不满足业务 fixture 提前停止,同样不作为完成证据。
最终代码对应的正式 `gpt-5.5 / openai_chat / high` 独立轮 **PASS**,总耗时 `631.6s`(约 10 分 32 秒)。46/46 次成功计划全部使用 `native_runtime_tools`,格式 repair 为 `0`,八类 repair 直方图全为 `0`Provider lifecycle 从 V1.32 基线的 86 降为 54started / terminal 均为 54,其中 53 completed、1 次瞬态失败通过新的 request identity 显式重试恢复,wrapper/text fallback 和协议审计 payload leak 均为 0。相同父 Session/run 完成 2 个 static delegate、1 个三 child isolated all-join、1 次业务 delivery repair、两类 Provider 真并行、pidfd Runner 强杀恢复、宿主验证和唯一 Supervisor assistant;重复 delivery/group/instance/result/join/claim/message/action/receipt/lifecycle、残留 sidecar、私密正文、API Key、项目 / 正式配置路径及报告泄漏均为 0,隔离 AppData 与 disposable 项目完整清理。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
+68 -2
View File
@@ -476,7 +476,6 @@ enum ChatCompletionsContent {
#[derive(Deserialize)]
struct ChatCompletionsContentPart {
#[serde(rename = "type")]
#[allow(dead_code)]
part_type: Option<String>,
#[serde(default)]
text: Option<String>,
@@ -515,7 +514,6 @@ struct ResponsesOutputItem {
#[derive(Deserialize)]
struct ResponsesOutputContentPart {
#[serde(rename = "type")]
#[allow(dead_code)]
part_type: Option<String>,
#[serde(default)]
text: Option<String>,
@@ -2173,6 +2171,7 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option<String>
.output
.iter()
.flat_map(|item| item.content.iter())
.filter(|part| !is_hidden_reasoning_part(part.part_type.as_deref()))
.filter_map(|part| part.text.as_deref())
.collect::<Vec<_>>()
.join("");
@@ -2251,6 +2250,7 @@ fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
ChatCompletionsContent::Parts(parts) => {
let text = parts
.iter()
.filter(|part| !is_hidden_reasoning_part(part.part_type.as_deref()))
.filter_map(|part| part.text.as_deref())
.collect::<Vec<_>>()
.join("");
@@ -2260,6 +2260,16 @@ fn extract_content_text(content: &ChatCompletionsContent) -> Option<String> {
}
}
fn is_hidden_reasoning_part(part_type: Option<&str>) -> bool {
let Some(part_type) = part_type.map(str::trim) else {
return false;
};
["reasoning", "reasoning_content", "analysis", "thinking"]
.iter()
.any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type))
}
fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec<u8>), LlmError> {
match std_str::from_utf8(bytes) {
Ok(text) => Ok((text.to_string(), Vec::new())),
@@ -2847,6 +2857,62 @@ mod tests {
);
}
#[test]
fn chat_response_excludes_standalone_reasoning_fields_from_text() {
let response = parse_chat_completions_response(
LlmProvider::OpenAiCompatible,
"fallback-model",
r#"{"id":"chat_reasoning_fields","choices":[{"message":{"reasoning_content":"内部推理","reasoning":"内部分析","content":null,"tool_calls":[{"id":"call_noop","type":"function","function":{"name":"noop","arguments":"{}"}}]},"finish_reason":"tool_calls"}]}"#,
)
.expect("tool call should keep the response valid without visible content");
assert_eq!(response.text, "");
}
#[test]
fn chat_response_filters_reasoning_parts_and_preserves_visible_parts() {
let response = parse_chat_completions_response(
LlmProvider::OpenAiCompatible,
"fallback-model",
r#"{"id":"chat_content_parts","choices":[{"message":{"content":[{"type":"reasoning","text":"内部推理"},{"type":"analysis","text":"内部分析"},{"type":"reasoning_content","text":"内部推理补充"},{"type":"thinking","text":"内部思考"},{"type":"text","text":"可见"},{"type":"output_text","text":"答案"}]},"finish_reason":"stop"}]}"#,
)
.expect("visible chat content parts should parse");
assert_eq!(response.text, "可见答案");
}
#[test]
fn chat_response_preserves_visible_content_with_tool_calls() {
let response = parse_chat_completions_response(
LlmProvider::OpenAiCompatible,
"fallback-model",
r#"{"id":"chat_visible_tool_call","choices":[{"message":{"content":[{"type":"analysis","text":"内部分析"},{"type":"text","text":"先检查项目。"}],"tool_calls":[{"id":"call_project_index","type":"function","function":{"name":"project_index","arguments":"{\"path\":\"/tmp/game\"}"}}]},"finish_reason":"tool_calls"}]}"#,
)
.expect("chat response with visible content and tool calls should parse");
assert_eq!(response.text, "先检查项目。");
assert_eq!(
response.tool_calls,
vec![LlmToolCall {
id: "call_project_index".to_string(),
name: "project_index".to_string(),
arguments: r#"{"path":"/tmp/game"}"#.to_string(),
}]
);
}
#[test]
fn responses_response_filters_reasoning_parts_and_preserves_output_text() {
let response = parse_responses_response(
LlmProvider::OpenAiCompatible,
"fallback-model",
r#"{"id":"responses_content_parts","output":[{"type":"message","content":[{"type":"analysis","text":"内部分析"},{"type":"output_text","text":"最终答案"}]}],"status":"completed"}"#,
)
.expect("visible Responses content parts should parse");
assert_eq!(response.text, "最终答案");
}
#[tokio::test]
async fn run_accepts_chat_tool_calls_without_text_content() {
let server_url = spawn_mock_server(vec![MockResponse {