Skip to content

Commit 06d2015

Browse files
OhYeeclaude
andcommitted
fix(tool, toolset): 兼容 additionalProperties schema 解析
- 将 additional_properties 字段类型从 bool 扩展为 Union[bool, ToolSchema] - 递归解析 schema-valued additionalProperties,序列化时保留为对象 - 修正 from_any_openapi_schema 入口条件:if not schema → if schema is None, 使空 dict {} 作为合法 JSON Schema(any)正确解析,而非降级为 string - 将嵌套三元表达式重构为 if/elif/else 提升可读性 - 添加 OpenAPI、MCP tool schema 及空 schema 的回归测试 Change-Id: I1e14f80b169adbb7693f0ee668ed6462e2a4803c Co-developed-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7ee7010 commit 06d2015

4 files changed

Lines changed: 203 additions & 17 deletions

File tree

‎agentrun/tool/model.py‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from enum import Enum
8-
from typing import Any, Dict, List, Optional
8+
from typing import Any, Dict, List, Optional, Union
99

1010
from agentrun.utils.model import BaseModel
1111

@@ -194,8 +194,8 @@ class ToolSchema(BaseModel):
194194
required: Optional[List[str]] = None
195195
"""必填字段 / Required fields"""
196196

197-
additional_properties: Optional[bool] = None
198-
"""是否允许额外属性 / Whether additional properties are allowed"""
197+
additional_properties: Optional[Union[bool, "ToolSchema"]] = None
198+
"""额外属性约束 / Additional properties constraint"""
199199

200200
items: Optional["ToolSchema"] = None
201201
"""数组元素类型 / Array item type"""
@@ -252,7 +252,7 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
252252
递归解析所有嵌套结构,保留完整的 schema 信息。
253253
Recursively parses all nested structures, preserving complete schema information.
254254
"""
255-
if not schema or not isinstance(schema, dict):
255+
if schema is None or not isinstance(schema, dict):
256256
return cls(type="string")
257257

258258
from pydash import get as pydash_get
@@ -291,13 +291,21 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
291291
else None
292292
)
293293

294+
additional_properties_raw = pydash_get(schema, "additionalProperties")
295+
if isinstance(additional_properties_raw, dict):
296+
additional_properties = cls.from_any_openapi_schema(
297+
additional_properties_raw
298+
)
299+
else:
300+
additional_properties = additional_properties_raw
301+
294302
return cls(
295303
type=pydash_get(schema, "type"),
296304
description=pydash_get(schema, "description"),
297305
title=pydash_get(schema, "title"),
298306
properties=properties,
299307
required=pydash_get(schema, "required"),
300-
additional_properties=pydash_get(schema, "additionalProperties"),
308+
additional_properties=additional_properties,
301309
items=items,
302310
min_items=pydash_get(schema, "minItems"),
303311
max_items=pydash_get(schema, "maxItems"),
@@ -334,7 +342,11 @@ def to_json_schema(self) -> Dict[str, Any]:
334342
if self.required:
335343
result["required"] = self.required
336344
if self.additional_properties is not None:
337-
result["additionalProperties"] = self.additional_properties
345+
result["additionalProperties"] = (
346+
self.additional_properties.to_json_schema()
347+
if isinstance(self.additional_properties, ToolSchema)
348+
else self.additional_properties
349+
)
338350

339351
if self.items:
340352
result["items"] = self.items.to_json_schema()

‎agentrun/toolset/model.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from enum import Enum
8-
from typing import Any, Dict, List, Optional
8+
from typing import Any, Dict, List, Optional, Union
99

1010
from agentrun.utils.model import BaseModel, Field, PageableInput
1111

@@ -103,7 +103,7 @@ class ToolSchema(BaseModel):
103103
# 对象类型字段
104104
properties: Optional[Dict[str, "ToolSchema"]] = None
105105
required: Optional[List[str]] = None
106-
additional_properties: Optional[bool] = None
106+
additional_properties: Optional[Union[bool, "ToolSchema"]] = None
107107

108108
# 数组类型字段
109109
items: Optional["ToolSchema"] = None
@@ -137,7 +137,7 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
137137
138138
递归解析所有嵌套结构,保留完整的 schema 信息。
139139
"""
140-
if not schema or not isinstance(schema, dict):
140+
if schema is None or not isinstance(schema, dict):
141141
return cls(type="string")
142142

143143
from pydash import get as pg
@@ -179,6 +179,14 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
179179
else None
180180
)
181181

182+
additional_properties_raw = pg(schema, "additionalProperties")
183+
if isinstance(additional_properties_raw, dict):
184+
additional_properties = cls.from_any_openapi_schema(
185+
additional_properties_raw
186+
)
187+
else:
188+
additional_properties = additional_properties_raw
189+
182190
return cls(
183191
# 基本字段
184192
type=pg(schema, "type"),
@@ -187,7 +195,7 @@ def from_any_openapi_schema(cls, schema: Any) -> "ToolSchema":
187195
# 对象类型
188196
properties=properties,
189197
required=pg(schema, "required"),
190-
additional_properties=pg(schema, "additionalProperties"),
198+
additional_properties=additional_properties,
191199
# 数组类型
192200
items=items,
193201
min_items=pg(schema, "minItems"),
@@ -231,7 +239,11 @@ def to_json_schema(self) -> Dict[str, Any]:
231239
if self.required:
232240
result["required"] = self.required
233241
if self.additional_properties is not None:
234-
result["additionalProperties"] = self.additional_properties
242+
result["additionalProperties"] = (
243+
self.additional_properties.to_json_schema()
244+
if isinstance(self.additional_properties, ToolSchema)
245+
else self.additional_properties
246+
)
235247

236248
# 数组类型
237249
if self.items:

‎tests/unittests/tool/test_model.py‎

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,9 @@ def test_from_any_openapi_schema_empty(self):
265265
schema = ToolSchema.from_any_openapi_schema(None)
266266
assert schema.type == "string"
267267

268-
# Empty dict creates a ToolSchema with None type (pydash_get returns None for missing keys)
268+
# Empty dict is a valid JSON Schema meaning "any" — should not collapse to "string"
269269
schema = ToolSchema.from_any_openapi_schema({})
270-
# Actually returns "string" due to the check at the beginning of the method
271-
assert schema.type == "string"
270+
assert schema.type is None
272271

273272
def test_from_any_openapi_schema_non_dict(self):
274273
"""测试从非 dict 输入创建"""
@@ -335,6 +334,72 @@ def test_from_any_openapi_schema_allof(self):
335334
assert schema.all_of[0].type == "object"
336335
assert schema.all_of[1].type == "object"
337336

337+
def test_from_any_openapi_schema_additional_properties_schema(self):
338+
"""测试 additionalProperties 为 schema 对象时的解析"""
339+
openapi_schema = {
340+
"type": "object",
341+
"properties": {
342+
"filters": {
343+
"type": "object",
344+
"additionalProperties": {
345+
"anyOf": [
346+
{"type": "string"},
347+
{"type": "integer"},
348+
]
349+
},
350+
}
351+
},
352+
}
353+
354+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
355+
356+
assert schema.properties is not None
357+
assert "filters" in schema.properties
358+
filters_schema = schema.properties["filters"]
359+
assert filters_schema.additional_properties is not None
360+
assert filters_schema.additional_properties.any_of is not None
361+
assert len(filters_schema.additional_properties.any_of) == 2
362+
assert filters_schema.additional_properties.any_of[0].type == "string"
363+
assert filters_schema.additional_properties.any_of[1].type == "integer"
364+
365+
json_schema = schema.to_json_schema()
366+
assert (
367+
json_schema["properties"]["filters"]["additionalProperties"][
368+
"anyOf"
369+
][0]["type"]
370+
== "string"
371+
)
372+
assert (
373+
json_schema["properties"]["filters"]["additionalProperties"][
374+
"anyOf"
375+
][1]["type"]
376+
== "integer"
377+
)
378+
379+
def test_from_any_openapi_schema_empty_additional_properties_schema(self):
380+
"""测试 additionalProperties 为空 schema 时保留原语义"""
381+
openapi_schema = {
382+
"type": "object",
383+
"properties": {
384+
"metadata": {
385+
"type": "object",
386+
"additionalProperties": {},
387+
}
388+
},
389+
}
390+
391+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
392+
393+
assert schema.properties is not None
394+
metadata_schema = schema.properties["metadata"]
395+
assert metadata_schema.additional_properties is not None
396+
assert metadata_schema.additional_properties.type is None
397+
398+
json_schema = schema.to_json_schema()
399+
assert (
400+
json_schema["properties"]["metadata"]["additionalProperties"] == {}
401+
)
402+
338403
def test_to_json_schema_simple(self):
339404
"""测试转换为 JSON Schema - 简单情况"""
340405
schema = ToolSchema(
@@ -659,3 +724,38 @@ def model_dump(self):
659724
assert "param1" in info.parameters.properties
660725
assert "param2" in info.parameters.properties
661726
assert info.parameters.required == ["param1"]
727+
728+
def test_from_mcp_tool_with_schema_additional_properties(self):
729+
"""测试 MCP tool schema 中 additionalProperties 为对象时的解析"""
730+
mcp_tool = {
731+
"name": "get_news_by_date",
732+
"description": "A tool with MCP schema-style date_range",
733+
"inputSchema": {
734+
"type": "object",
735+
"properties": {
736+
"date_range": {
737+
"anyOf": [
738+
{"type": "string"},
739+
{
740+
"type": "object",
741+
"additionalProperties": {"type": "string"},
742+
},
743+
{"type": "null"},
744+
]
745+
}
746+
},
747+
},
748+
}
749+
750+
info = ToolInfo.from_mcp_tool(mcp_tool)
751+
752+
assert info.name == "get_news_by_date"
753+
assert info.parameters is not None
754+
assert info.parameters.properties is not None
755+
date_range_schema = info.parameters.properties["date_range"]
756+
assert date_range_schema.any_of is not None
757+
assert date_range_schema.any_of[1].type == "object"
758+
assert date_range_schema.any_of[1].additional_properties is not None
759+
assert (
760+
date_range_schema.any_of[1].additional_properties.type == "string"
761+
)

‎tests/unittests/toolset/test_model.py‎

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,10 +419,9 @@ def test_from_any_openapi_schema_none(self):
419419
assert schema.type == "string"
420420

421421
def test_from_any_openapi_schema_empty_dict(self):
422-
"""测试从空字典创建"""
422+
"""测试从空字典创建 — 空 dict 是合法的 JSON Schema,表示 'any'"""
423423
schema = ToolSchema.from_any_openapi_schema({})
424-
# 空字典被视为 falsy,所以返回默认的 string 类型
425-
assert schema.type == "string"
424+
assert schema.type is None
426425

427426
def test_from_any_openapi_schema_non_dict(self):
428427
"""测试从非字典 schema 创建"""
@@ -448,6 +447,69 @@ def test_from_any_openapi_schema_with_properties(self):
448447
assert schema.required == ["name"]
449448
assert schema.additional_properties is False
450449

450+
def test_from_any_openapi_schema_with_schema_additional_properties(self):
451+
"""测试 additionalProperties 为 schema 对象时的解析"""
452+
openapi_schema = {
453+
"type": "object",
454+
"properties": {
455+
"filters": {
456+
"type": "object",
457+
"additionalProperties": {
458+
"anyOf": [
459+
{"type": "string"},
460+
{"type": "integer"},
461+
]
462+
},
463+
}
464+
},
465+
}
466+
467+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
468+
469+
assert schema.properties is not None
470+
filters_schema = schema.properties["filters"]
471+
assert filters_schema.additional_properties is not None
472+
assert filters_schema.additional_properties.any_of is not None
473+
assert len(filters_schema.additional_properties.any_of) == 2
474+
475+
json_schema = schema.to_json_schema()
476+
assert (
477+
json_schema["properties"]["filters"]["additionalProperties"][
478+
"anyOf"
479+
][0]["type"]
480+
== "string"
481+
)
482+
assert (
483+
json_schema["properties"]["filters"]["additionalProperties"][
484+
"anyOf"
485+
][1]["type"]
486+
== "integer"
487+
)
488+
489+
def test_from_any_openapi_schema_with_empty_additional_properties(self):
490+
"""测试 additionalProperties 为空 schema 时保留原语义"""
491+
openapi_schema = {
492+
"type": "object",
493+
"properties": {
494+
"metadata": {
495+
"type": "object",
496+
"additionalProperties": {},
497+
}
498+
},
499+
}
500+
501+
schema = ToolSchema.from_any_openapi_schema(openapi_schema)
502+
503+
assert schema.properties is not None
504+
metadata_schema = schema.properties["metadata"]
505+
assert metadata_schema.additional_properties is not None
506+
assert metadata_schema.additional_properties.type is None
507+
508+
json_schema = schema.to_json_schema()
509+
assert (
510+
json_schema["properties"]["metadata"]["additionalProperties"] == {}
511+
)
512+
451513
def test_from_any_openapi_schema_with_items(self):
452514
"""测试从带 items 的数组 schema 创建"""
453515
openapi_schema = {

0 commit comments

Comments
 (0)