修复 Agent 原生工具严格 Schema 兼容
This commit is contained in:
@@ -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)}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user