修复 Agent 原生工具严格 Schema 兼容

This commit is contained in:
AIGameCreator App
2026-07-21 19:24:18 +08:00
parent d8a070f8f8
commit a983597f13
2 changed files with 302 additions and 43 deletions
@@ -28984,9 +28984,9 @@ fn apply_agent_runtime_autonomous_source_schema_limits_with_max(
(
"project.patchset",
vec![
"/properties/input/properties/changes/items/oneOf/0/properties/content",
"/properties/input/properties/changes/items/oneOf/1/properties/oldText",
"/properties/input/properties/changes/items/oneOf/1/properties/newText",
"/properties/input/properties/changes/items/properties/content",
"/properties/input/properties/changes/items/properties/oldText",
"/properties/input/properties/changes/items/properties/newText",
],
),
];
@@ -362,17 +362,21 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
format!("Agent 原生工具协议错误:{} reason 不能为空", call.name),
));
}
let mut input = arguments.input;
if runtime_tool.as_deref() == Some("agent.delegate") {
validate_native_agent_delegate_input(&arguments.input)?;
validate_native_agent_delegate_input(&input)?;
}
if runtime_tool.as_deref() == Some("project.patchset") {
input = normalize_native_project_patchset_input(input)?;
}
let action = if let Some(tool) = runtime_tool {
AgentRuntimeToolAction {
tool,
reason: Some(arguments.reason),
input: arguments.input,
input,
}
} else if let Some(tool) = mcp_tool {
if !arguments.input.is_object() {
if !input.is_object() {
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!("Agent 原生 MCP 工具 {} input 必须是 object", call.name),
@@ -384,7 +388,7 @@ pub(crate) fn parse_agent_runtime_native_tool_calls(
input: json!({
"server": tool.server_id,
"tool": tool.name,
"arguments": arguments.input,
"arguments": input,
}),
}
} else {
@@ -506,6 +510,153 @@ fn validate_native_agent_delegate_input(
Ok(())
}
fn normalize_native_project_patchset_input(
input: Value,
) -> Result<Value, AgentRuntimeToolPlanProtocolError> {
const CHANGE_FIELDS: [&str; 7] = [
"operation",
"path",
"content",
"expectedSha256",
"oldText",
"newText",
"expectedReplacements",
];
let object = input.as_object().ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:project.patchset input 必须是 object",
)
})?;
if object.len() != 1 || !object.contains_key("changes") {
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:project.patchset input 字段无效",
));
}
let changes = object
.get("changes")
.and_then(Value::as_array)
.ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
"Agent 原生工具协议错误:project.patchset changes 必须是数组",
)
})?;
let mut normalized = Vec::with_capacity(changes.len());
for (index, change) in changes.iter().enumerate() {
let change = change.as_object().ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}] 必须是 object",
index + 1
),
)
})?;
if change.len() != CHANGE_FIELDS.len()
|| CHANGE_FIELDS
.iter()
.any(|field| !change.contains_key(*field))
|| change
.keys()
.any(|field| !CHANGE_FIELDS.contains(&field.as_str()))
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}] 字段无效",
index + 1
),
));
}
let required_string = |field: &str| {
change.get(field).and_then(Value::as_str).ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须是字符串",
index + 1
),
)
})
};
let require_null = |fields: &[&str]| {
if let Some(field) = fields
.iter()
.find(|field| !change.get(**field).is_some_and(Value::is_null))
{
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}].{field} 必须为 null",
index + 1
),
));
}
Ok(())
};
let operation = required_string("operation")?;
let path = required_string("path")?;
normalized.push(match operation {
"create" => {
require_null(&[
"expectedSha256",
"oldText",
"newText",
"expectedReplacements",
])?;
json!({
"operation": operation,
"path": path,
"content": required_string("content")?,
})
}
"update" => {
require_null(&["content"])?;
let expected_replacements = change
.get("expectedReplacements")
.and_then(Value::as_u64)
.ok_or_else(|| {
protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}].expectedReplacements 必须是整数",
index + 1
),
)
})?;
json!({
"operation": operation,
"path": path,
"expectedSha256": required_string("expectedSha256")?,
"oldText": required_string("oldText")?,
"newText": required_string("newText")?,
"expectedReplacements": expected_replacements,
})
}
"delete" => {
require_null(&["content", "oldText", "newText", "expectedReplacements"])?;
json!({
"operation": operation,
"path": path,
"expectedSha256": required_string("expectedSha256")?,
})
}
_ => {
return Err(protocol_error(
AgentRuntimeToolPlanProtocolErrorKind::ArgumentsSchema,
format!(
"Agent 原生工具协议错误:project.patchset changes[{}].operation 无效",
index + 1
),
));
}
});
}
Ok(json!({ "changes": normalized }))
}
fn validate_native_delegate_string(
value: Option<&Value>,
field: &str,
@@ -892,7 +1043,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"properties": { "commandId": { "type": "string", "enum": ["game.static_smoke"] } }
}),
"preview.validate" => json!({
"type": "object", "required": ["viewports", "expectedText", "settleMs", "failOnConsoleError"], "additionalProperties": false,
"type": "object", "required": ["viewports", "expectedText", "settleMs", "failOnConsoleError", "playtestScenario"], "additionalProperties": false,
"properties": {
"viewports": { "type": "array", "minItems": 1, "maxItems": 2, "items": { "type": "string", "enum": ["desktop", "mobile"] } },
"expectedText": string_array_schema(16),
@@ -1023,41 +1174,18 @@ fn project_patchset_input_schema() -> Value {
"changes": {
"type": "array", "minItems": 1, "maxItems": 12,
"items": {
"oneOf": [
{
"type": "object",
"required": ["operation", "path", "content"],
"additionalProperties": false,
"properties": {
"operation": { "type": "string", "enum": ["create"] },
"path": { "type": "string", "minLength": 1 },
"content": { "type": "string" }
}
},
{
"type": "object",
"required": ["operation", "path", "expectedSha256", "oldText", "newText", "expectedReplacements"],
"additionalProperties": false,
"properties": {
"operation": { "type": "string", "enum": ["update"] },
"path": { "type": "string", "minLength": 1 },
"expectedSha256": { "type": "string", "minLength": 64, "maxLength": 64 },
"oldText": { "type": "string", "minLength": 1 },
"newText": { "type": "string" },
"expectedReplacements": { "type": "integer", "minimum": 1 }
}
},
{
"type": "object",
"required": ["operation", "path", "expectedSha256"],
"additionalProperties": false,
"properties": {
"operation": { "type": "string", "enum": ["delete"] },
"path": { "type": "string", "minLength": 1 },
"expectedSha256": { "type": "string", "minLength": 64, "maxLength": 64 }
}
}
]
"type": "object",
"required": ["operation", "path", "content", "expectedSha256", "oldText", "newText", "expectedReplacements"],
"additionalProperties": false,
"properties": {
"operation": { "type": "string", "enum": ["create", "update", "delete"] },
"path": { "type": "string", "minLength": 1 },
"content": { "type": ["string", "null"] },
"expectedSha256": { "type": ["string", "null"], "minLength": 64, "maxLength": 64 },
"oldText": { "type": ["string", "null"], "minLength": 1 },
"newText": { "type": ["string", "null"] },
"expectedReplacements": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 }
}
}
}
}
@@ -1068,6 +1196,60 @@ fn project_patchset_input_schema() -> Value {
mod tests {
use super::*;
fn empty_catalog() -> GameCreatorMcpCatalog {
GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
tools: Vec::new(),
}
}
fn collect_openai_strict_schema_issues(schema: &Value, path: &str, issues: &mut Vec<String>) {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
));
}
}
if let Some(properties) = object.get("properties").and_then(Value::as_object) {
if object.get("additionalProperties") != Some(&Value::Bool(false)) {
issues.push(format!(
"strict object must reject additional properties at {path}"
));
}
let property_names = properties.keys().cloned().collect::<BTreeSet<_>>();
let required_names = object
.get("required")
.and_then(Value::as_array)
.map(|required| {
required
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect::<BTreeSet<_>>()
});
if required_names.as_ref() != Some(&property_names) {
issues.push(format!(
"strict object must require every property at {path}: required={required_names:?}, properties={property_names:?}"
));
}
for (name, child) in properties {
collect_openai_strict_schema_issues(
child,
&format!("{path}/properties/{name}"),
issues,
);
}
}
if let Some(items) = object.get("items") {
collect_openai_strict_schema_issues(items, &format!("{path}/items"), issues);
}
}
fn valid_delegate_input(repair_of_delegation_id: Value, run_id: Value) -> Value {
json!({
"agentId": "specialist",
@@ -1125,4 +1307,81 @@ mod tests {
assert!(description.contains("repairOfDelegationId 指向原 delivery"));
assert!(description.contains("runId 必须为 null"));
}
#[test]
fn strict_native_function_schemas_match_openai_subset() {
let functions = build_agent_runtime_native_function_tools(&empty_catalog())
.expect("build native function tools");
let mut issues = Vec::new();
for function in functions.iter().filter(|function| function.strict) {
collect_openai_strict_schema_issues(&function.parameters, &function.name, &mut issues);
}
assert!(issues.is_empty(), "{}", issues.join("\n"));
}
#[test]
fn native_project_patchset_normalizes_nullable_strict_shape() {
let arguments = json!({
"reason": "原子应用三类变更",
"input": {
"changes": [
{
"operation": "create",
"path": "game/new.txt",
"content": "created",
"expectedSha256": null,
"oldText": null,
"newText": null,
"expectedReplacements": null
},
{
"operation": "update",
"path": "game/main.txt",
"content": null,
"expectedSha256": "a".repeat(64),
"oldText": "before",
"newText": "after",
"expectedReplacements": 1
},
{
"operation": "delete",
"path": "game/old.txt",
"content": null,
"expectedSha256": "b".repeat(64),
"oldText": null,
"newText": null,
"expectedReplacements": null
}
]
}
});
let parsed = parse_agent_runtime_native_tool_calls(
&[LlmToolCall {
id: "patchset-call".to_string(),
name: native_runtime_function_name("project.patchset")
.expect("native patchset function name"),
arguments: serde_json::to_string(&arguments).expect("serialize arguments"),
}],
&empty_catalog(),
)
.expect("parse strict patchset call");
assert_eq!(
parsed.plan.actions[0].input,
json!({
"changes": [
{"operation": "create", "path": "game/new.txt", "content": "created"},
{
"operation": "update",
"path": "game/main.txt",
"expectedSha256": "a".repeat(64),
"oldText": "before",
"newText": "after",
"expectedReplacements": 1
},
{"operation": "delete", "path": "game/old.txt", "expectedSha256": "b".repeat(64)}
]
})
);
}
}