Skip to content

Commit 0acca6b

Browse files
authored
core[patch]: Fix handling of title when tool schema is specified manually via JSONSchema (#30479)
Fix issue: #30456
1 parent c5e42a4 commit 0acca6b

File tree

3 files changed

+100
-3
lines changed

3 files changed

+100
-3
lines changed

libs/core/langchain_core/utils/function_calling.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,36 @@ class ToolDescription(TypedDict):
6262

6363

6464
def _rm_titles(kv: dict, prev_key: str = "") -> dict:
65+
"""Recursively removes "title" fields from a JSON schema dictionary.
66+
67+
Remove "title" fields from the input JSON schema dictionary,
68+
except when a "title" appears within a property definition under "properties".
69+
70+
Args:
71+
kv (dict): The input JSON schema as a dictionary.
72+
prev_key (str): The key from the parent dictionary, used to identify context.
73+
74+
Returns:
75+
dict: A new dictionary with appropriate "title" fields removed.
76+
"""
6577
new_kv = {}
78+
6679
for k, v in kv.items():
6780
if k == "title":
68-
if isinstance(v, dict) and prev_key == "properties" and "title" in v:
81+
# If the value is a nested dict and part of a property under "properties",
82+
# preserve the title but continue recursion
83+
if isinstance(v, dict) and prev_key == "properties":
6984
new_kv[k] = _rm_titles(v, k)
7085
else:
86+
# Otherwise, remove this "title" key
7187
continue
7288
elif isinstance(v, dict):
89+
# Recurse into nested dictionaries
7390
new_kv[k] = _rm_titles(v, k)
7491
else:
92+
# Leave non-dict values untouched
7593
new_kv[k] = v
94+
7695
return new_kv
7796

7897

libs/core/tests/unit_tests/test_tools.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,10 @@
6060
_is_message_content_type,
6161
get_all_basemodel_annotations,
6262
)
63-
from langchain_core.utils.function_calling import convert_to_openai_function
63+
from langchain_core.utils.function_calling import (
64+
convert_to_openai_function,
65+
convert_to_openai_tool,
66+
)
6467
from langchain_core.utils.pydantic import (
6568
PYDANTIC_MAJOR_VERSION,
6669
_create_subset_model,
@@ -2560,3 +2563,44 @@ def foo_args_jsons_schema_with_description(x: int) -> str:
25602563
]
25612564
== "description"
25622565
)
2566+
2567+
2568+
def test_title_property_preserved() -> None:
2569+
"""Test that the title property is preserved when generating schema.
2570+
2571+
https://github.com/langchain-ai/langchain/issues/30456
2572+
"""
2573+
from typing import Any
2574+
2575+
from langchain_core.tools import tool
2576+
2577+
schema_to_be_extracted = {
2578+
"type": "object",
2579+
"required": [],
2580+
"properties": {
2581+
"title": {"type": "string", "description": "item title"},
2582+
"due_date": {"type": "string", "description": "item due date"},
2583+
},
2584+
"description": "foo",
2585+
}
2586+
2587+
@tool(args_schema=schema_to_be_extracted)
2588+
def extract_data(extracted_data: dict[str, Any]) -> dict[str, Any]:
2589+
"""Some documentation."""
2590+
return extracted_data
2591+
2592+
assert convert_to_openai_tool(extract_data) == {
2593+
"function": {
2594+
"description": "Some documentation.",
2595+
"name": "extract_data",
2596+
"parameters": {
2597+
"properties": {
2598+
"due_date": {"description": "item due date", "type": "string"},
2599+
"title": {"description": "item title", "type": "string"},
2600+
},
2601+
"required": [],
2602+
"type": "object",
2603+
},
2604+
},
2605+
"type": "function",
2606+
}

libs/core/tests/unit_tests/utils/test_rm_titles.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,44 @@
190190
"required": ["properties"],
191191
}
192192

193+
schema5 = {
194+
"description": "A list of data.",
195+
"items": {
196+
"description": "foo",
197+
"properties": {
198+
"title": {"type": "string", "description": "item title"},
199+
"due_date": {"type": "string", "description": "item due date"},
200+
},
201+
"required": [],
202+
"type": "object",
203+
},
204+
"type": "array",
205+
}
206+
207+
output5 = {
208+
"description": "A list of data.",
209+
"items": {
210+
"description": "foo",
211+
"properties": {
212+
"title": {"type": "string", "description": "item title"},
213+
"due_date": {"type": "string", "description": "item due date"},
214+
},
215+
"required": [],
216+
"type": "object",
217+
},
218+
"type": "array",
219+
}
220+
193221

194222
@pytest.mark.parametrize(
195223
"schema, output",
196-
[(schema1, output1), (schema2, output2), (schema3, output3), (schema4, output4)],
224+
[
225+
(schema1, output1),
226+
(schema2, output2),
227+
(schema3, output3),
228+
(schema4, output4),
229+
(schema5, output5),
230+
],
197231
)
198232
def test_rm_titles(schema: dict, output: dict) -> None:
199233
assert _rm_titles(schema) == output

0 commit comments

Comments
 (0)