Skip to content

Commit f21c15f

Browse files
committed
feat(tools): add support for nested Pydantic models with Field descriptions
This commit adds _resolve_pydantic_refs() to inline nested BaseModel properties and their Field descriptions, ensuring that LLMs receive complete parameter documentation even for complex nested structures. Key changes: - Add _resolve_pydantic_refs() to resolve $ref pointers from Pydantic schemas - Integrate reference resolution into _get_pydantic_schema() - Handle allOf wrappers (Pydantic v2 pattern) - Support multi-level nesting with circular reference protection - Preserve parameter-level descriptions over model docstrings - Add 8 comprehensive tests for nested models including: * Single-level nested models * Multi-level nested models (e.g., Person -> ContactInfo -> email) * List[BaseModel] support * Optional[BaseModel] support * Mixed simple and nested parameters * Circular reference handling This addresses the limitation identified by @edpowers in PR #4962 where nested Pydantic BaseModel Field descriptions were not accessible to LLMs because they remained in $defs instead of being inlined.
1 parent db56e23 commit f21c15f

2 files changed

Lines changed: 458 additions & 1 deletion

File tree

src/google/adk/tools/_automatic_function_calling_util.py

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,146 @@ def _extract_base_type_from_annotated(annotation: Any) -> Any:
9393
return annotation
9494

9595

96+
def _resolve_pydantic_refs(schema: Dict[str, Any]) -> Dict[str, Any]:
97+
"""Resolve $ref pointers in Pydantic JSON schema and inline nested objects.
98+
99+
Pydantic generates JSON schemas with $ref pointers to $defs for nested
100+
BaseModel classes. This function resolves these references and inlines
101+
nested properties so that Field descriptions from nested models are
102+
directly accessible in the schema sent to the LLM.
103+
104+
This is similar to the reference resolution in openapi_spec_parser.py but
105+
optimized for Pydantic v2 schema structure (handles allOf wrappers).
106+
107+
Args:
108+
schema: Pydantic model_json_schema() output with $defs.
109+
110+
Returns:
111+
Schema with all $ref resolved and nested properties inlined. The $defs
112+
section is removed as all definitions are now inlined.
113+
114+
Example:
115+
Input:
116+
{
117+
"properties": {
118+
"user": {"allOf": [{"$ref": "#/$defs/Person"}], "description": "User"}
119+
},
120+
"$defs": {
121+
"Person": {"properties": {"name": {"description": "Name"}}}
122+
}
123+
}
124+
125+
Output:
126+
{
127+
"properties": {
128+
"user": {
129+
"type": "object",
130+
"description": "User",
131+
"properties": {"name": {"description": "Name"}}
132+
}
133+
}
134+
}
135+
"""
136+
import copy
137+
138+
schema = copy.deepcopy(schema)
139+
defs = schema.get("$defs", {})
140+
141+
def resolve_ref(ref_string: str) -> Optional[Dict]:
142+
"""Resolve a $ref string like '#/$defs/Person'."""
143+
if not ref_string.startswith("#/$defs/"):
144+
return None
145+
def_name = ref_string.split("/")[-1]
146+
return defs.get(def_name)
147+
148+
def resolve_property(
149+
prop_schema: Dict, seen_refs: Optional[set] = None
150+
) -> Dict:
151+
"""Recursively resolve $ref in a property schema.
152+
153+
Args:
154+
prop_schema: A property schema that may contain $ref or allOf with $ref.
155+
seen_refs: Set of already-visited $ref strings to prevent circular refs.
156+
157+
Returns:
158+
Property schema with all $ref resolved and nested properties inlined.
159+
"""
160+
if seen_refs is None:
161+
seen_refs = set()
162+
163+
prop_schema = copy.deepcopy(prop_schema)
164+
165+
# Handle allOf wrapper (Pydantic v2 pattern: {"allOf": [{"$ref": "..."}]})
166+
if "allOf" in prop_schema and len(prop_schema["allOf"]) == 1:
167+
ref_item = prop_schema["allOf"][0]
168+
if "$ref" in ref_item:
169+
ref_string = ref_item["$ref"]
170+
171+
# Prevent circular references
172+
if ref_string in seen_refs:
173+
# Return schema without allOf to break the cycle
174+
return {k: v for k, v in prop_schema.items() if k != "allOf"}
175+
176+
seen_refs_copy = seen_refs.copy()
177+
seen_refs_copy.add(ref_string)
178+
179+
resolved = resolve_ref(ref_string)
180+
if resolved:
181+
resolved = copy.deepcopy(resolved)
182+
183+
# Preserve parameter-level description (takes precedence over model docstring)
184+
param_description = prop_schema.get("description")
185+
186+
# Recursively resolve nested properties within the resolved definition
187+
if "properties" in resolved:
188+
for nested_name, nested_schema in resolved["properties"].items():
189+
resolved["properties"][nested_name] = resolve_property(
190+
nested_schema, seen_refs_copy
191+
)
192+
193+
# If there was a parameter-level description, keep it
194+
# (e.g., "User info" instead of model's docstring "Person model")
195+
if param_description:
196+
resolved["description"] = param_description
197+
198+
return resolved
199+
200+
# Handle direct $ref (less common in Pydantic v2, but supported for completeness)
201+
elif "$ref" in prop_schema:
202+
ref_string = prop_schema["$ref"]
203+
if ref_string not in seen_refs:
204+
seen_refs_copy = seen_refs.copy()
205+
seen_refs_copy.add(ref_string)
206+
resolved = resolve_ref(ref_string)
207+
if resolved:
208+
return resolve_property(copy.deepcopy(resolved), seen_refs_copy)
209+
210+
# Recursively resolve nested properties (for already-inlined objects)
211+
if "properties" in prop_schema:
212+
for nested_name in list(prop_schema["properties"].keys()):
213+
prop_schema["properties"][nested_name] = resolve_property(
214+
prop_schema["properties"][nested_name], seen_refs
215+
)
216+
217+
# Handle arrays with items that might have refs
218+
if "items" in prop_schema:
219+
prop_schema["items"] = resolve_property(prop_schema["items"], seen_refs)
220+
221+
return prop_schema
222+
223+
# Resolve all top-level properties
224+
if "properties" in schema:
225+
for prop_name in list(schema["properties"].keys()):
226+
schema["properties"][prop_name] = resolve_property(
227+
schema["properties"][prop_name]
228+
)
229+
230+
# Clean up $defs since all definitions are now inlined
231+
schema.pop("$defs", None)
232+
233+
return schema
234+
235+
96236
def _get_fields_dict(func: Callable) -> Dict:
97237
"""Build a dictionary of field definitions for Pydantic model creation.
98238
@@ -214,7 +354,15 @@ def _get_pydantic_schema(func: Callable) -> Dict:
214354
context_param = find_context_parameter(func) or "tool_context"
215355
if context_param in fields_dict.keys():
216356
fields_dict.pop(context_param)
217-
return pydantic.create_model(func.__name__, **fields_dict).model_json_schema()
357+
358+
schema = pydantic.create_model(
359+
func.__name__, **fields_dict
360+
).model_json_schema()
361+
362+
# Resolve $ref for nested Pydantic models to inline Field descriptions
363+
schema = _resolve_pydantic_refs(schema)
364+
365+
return schema
218366

219367

220368
def _process_pydantic_schema(vertexai: bool, schema: Dict) -> Dict:

0 commit comments

Comments
 (0)