diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 72d261ec4c5..5cacfc32565 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -62,17 +62,36 @@ class ToolDescription(TypedDict): def _rm_titles(kv: dict, prev_key: str = "") -> dict: + """Recursively removes "title" fields from a JSON schema dictionary. + + Remove "title" fields from the input JSON schema dictionary, + except when a "title" appears within a property definition under "properties". + + Args: + kv (dict): The input JSON schema as a dictionary. + prev_key (str): The key from the parent dictionary, used to identify context. + + Returns: + dict: A new dictionary with appropriate "title" fields removed. + """ new_kv = {} + for k, v in kv.items(): if k == "title": - if isinstance(v, dict) and prev_key == "properties" and "title" in v: + # If the value is a nested dict and part of a property under "properties", + # preserve the title but continue recursion + if isinstance(v, dict) and prev_key == "properties": new_kv[k] = _rm_titles(v, k) else: + # Otherwise, remove this "title" key continue elif isinstance(v, dict): + # Recurse into nested dictionaries new_kv[k] = _rm_titles(v, k) else: + # Leave non-dict values untouched new_kv[k] = v + return new_kv diff --git a/libs/core/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index d83c386fd1a..18f11bdc26e 100644 --- a/libs/core/tests/unit_tests/test_tools.py +++ b/libs/core/tests/unit_tests/test_tools.py @@ -60,7 +60,10 @@ from langchain_core.tools.base import ( _is_message_content_type, get_all_basemodel_annotations, ) -from langchain_core.utils.function_calling import convert_to_openai_function +from langchain_core.utils.function_calling import ( + convert_to_openai_function, + convert_to_openai_tool, +) from langchain_core.utils.pydantic import ( PYDANTIC_MAJOR_VERSION, _create_subset_model, @@ -2560,3 +2563,44 @@ def test_tool_decorator_description() -> None: ] == "description" ) + + +def test_title_property_preserved() -> None: + """Test that the title property is preserved when generating schema. + + https://github.com/langchain-ai/langchain/issues/30456 + """ + from typing import Any + + from langchain_core.tools import tool + + schema_to_be_extracted = { + "type": "object", + "required": [], + "properties": { + "title": {"type": "string", "description": "item title"}, + "due_date": {"type": "string", "description": "item due date"}, + }, + "description": "foo", + } + + @tool(args_schema=schema_to_be_extracted) + def extract_data(extracted_data: dict[str, Any]) -> dict[str, Any]: + """Some documentation.""" + return extracted_data + + assert convert_to_openai_tool(extract_data) == { + "function": { + "description": "Some documentation.", + "name": "extract_data", + "parameters": { + "properties": { + "due_date": {"description": "item due date", "type": "string"}, + "title": {"description": "item title", "type": "string"}, + }, + "required": [], + "type": "object", + }, + }, + "type": "function", + } diff --git a/libs/core/tests/unit_tests/utils/test_rm_titles.py b/libs/core/tests/unit_tests/utils/test_rm_titles.py index 731510dd873..32d186fa6e3 100644 --- a/libs/core/tests/unit_tests/utils/test_rm_titles.py +++ b/libs/core/tests/unit_tests/utils/test_rm_titles.py @@ -190,10 +190,44 @@ schema4 = { "required": ["properties"], } +schema5 = { + "description": "A list of data.", + "items": { + "description": "foo", + "properties": { + "title": {"type": "string", "description": "item title"}, + "due_date": {"type": "string", "description": "item due date"}, + }, + "required": [], + "type": "object", + }, + "type": "array", +} + +output5 = { + "description": "A list of data.", + "items": { + "description": "foo", + "properties": { + "title": {"type": "string", "description": "item title"}, + "due_date": {"type": "string", "description": "item due date"}, + }, + "required": [], + "type": "object", + }, + "type": "array", +} + @pytest.mark.parametrize( "schema, output", - [(schema1, output1), (schema2, output2), (schema3, output3), (schema4, output4)], + [ + (schema1, output1), + (schema2, output2), + (schema3, output3), + (schema4, output4), + (schema5, output5), + ], ) def test_rm_titles(schema: dict, output: dict) -> None: assert _rm_titles(schema) == output