mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
Co-authored-by: Mason Daugherty <github@mdrxy.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Unit tests for langchain.agents.structured_output module."""
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage
|
|
from pydantic import BaseModel
|
|
|
|
from langchain.agents.structured_output import (
|
|
OutputToolBinding,
|
|
ProviderStrategy,
|
|
ProviderStrategyBinding,
|
|
ToolStrategy,
|
|
_SchemaSpec,
|
|
)
|
|
|
|
|
|
class _TestModel(BaseModel):
|
|
"""A test model for structured output."""
|
|
|
|
name: str
|
|
age: int
|
|
email: str = "default@example.com"
|
|
|
|
|
|
class CustomModel(BaseModel):
|
|
"""Custom model with a custom docstring."""
|
|
|
|
value: float
|
|
description: str
|
|
|
|
|
|
class EmptyDocModel(BaseModel):
|
|
# No custom docstring, should have no description in tool
|
|
data: str
|
|
|
|
|
|
class TestToolStrategy:
|
|
"""Test ToolStrategy dataclass."""
|
|
|
|
def test_basic_creation(self) -> None:
|
|
"""Test basic ToolStrategy creation."""
|
|
strategy = ToolStrategy(schema=_TestModel)
|
|
assert strategy.schema == _TestModel
|
|
assert strategy.tool_message_content is None
|
|
assert len(strategy.schema_specs) == 1
|
|
assert strategy.schema_specs[0].schema == _TestModel
|
|
|
|
def test_multiple_schemas(self) -> None:
|
|
"""Test ToolStrategy with multiple schemas."""
|
|
strategy = ToolStrategy(schema=_TestModel | CustomModel)
|
|
assert len(strategy.schema_specs) == 2
|
|
assert strategy.schema_specs[0].schema == _TestModel
|
|
assert strategy.schema_specs[1].schema == CustomModel
|
|
|
|
def test_schema_with_tool_message_content(self) -> None:
|
|
"""Test ToolStrategy with tool message content."""
|
|
strategy = ToolStrategy(schema=_TestModel, tool_message_content="custom message")
|
|
assert strategy.schema == _TestModel
|
|
assert strategy.tool_message_content == "custom message"
|
|
assert len(strategy.schema_specs) == 1
|
|
assert strategy.schema_specs[0].schema == _TestModel
|
|
|
|
|
|
class TestProviderStrategy:
|
|
"""Test ProviderStrategy dataclass."""
|
|
|
|
def test_basic_creation(self) -> None:
|
|
"""Test basic ProviderStrategy creation."""
|
|
strategy = ProviderStrategy(schema=_TestModel)
|
|
assert strategy.schema == _TestModel
|
|
assert strategy.schema_spec.schema == _TestModel
|
|
assert strategy.schema_spec.strict is None
|
|
|
|
def test_strict(self) -> None:
|
|
"""Test ProviderStrategy creation with strict=True."""
|
|
strategy = ProviderStrategy(schema=_TestModel, strict=True)
|
|
assert strategy.schema == _TestModel
|
|
assert strategy.schema_spec.schema == _TestModel
|
|
assert strategy.schema_spec.strict is True
|
|
|
|
def test_to_model_kwargs(self) -> None:
|
|
strategy_default = ProviderStrategy(schema=_TestModel)
|
|
assert strategy_default.to_model_kwargs() == {
|
|
"response_format": {
|
|
"json_schema": {
|
|
"name": "_TestModel",
|
|
"schema": {
|
|
"description": "A test model for structured output.",
|
|
"properties": {
|
|
"age": {"title": "Age", "type": "integer"},
|
|
"email": {
|
|
"default": "default@example.com",
|
|
"title": "Email",
|
|
"type": "string",
|
|
},
|
|
"name": {"title": "Name", "type": "string"},
|
|
},
|
|
"required": ["name", "age"],
|
|
"title": "_TestModel",
|
|
"type": "object",
|
|
},
|
|
},
|
|
"type": "json_schema",
|
|
}
|
|
}
|
|
|
|
def test_to_model_kwargs_strict(self) -> None:
|
|
strategy_default = ProviderStrategy(schema=_TestModel, strict=True)
|
|
assert strategy_default.to_model_kwargs() == {
|
|
"response_format": {
|
|
"json_schema": {
|
|
"name": "_TestModel",
|
|
"schema": {
|
|
"description": "A test model for structured output.",
|
|
"properties": {
|
|
"age": {"title": "Age", "type": "integer"},
|
|
"email": {
|
|
"default": "default@example.com",
|
|
"title": "Email",
|
|
"type": "string",
|
|
},
|
|
"name": {"title": "Name", "type": "string"},
|
|
},
|
|
"required": ["name", "age"],
|
|
"title": "_TestModel",
|
|
"type": "object",
|
|
},
|
|
"strict": True,
|
|
},
|
|
"type": "json_schema",
|
|
}
|
|
}
|
|
|
|
|
|
class TestOutputToolBinding:
|
|
"""Test OutputToolBinding dataclass and its methods."""
|
|
|
|
def test_from_schema_spec_basic(self) -> None:
|
|
"""Test basic OutputToolBinding creation from SchemaSpec."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
assert tool_binding.schema == _TestModel
|
|
assert tool_binding.schema_kind == "pydantic"
|
|
assert tool_binding.tool is not None
|
|
assert tool_binding.tool.name == "_TestModel"
|
|
|
|
def test_from_schema_spec_with_custom_name(self) -> None:
|
|
"""Test OutputToolBinding creation with custom name."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel, name="custom_tool_name")
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
assert tool_binding.tool.name == "custom_tool_name"
|
|
|
|
def test_from_schema_spec_with_custom_description(self) -> None:
|
|
"""Test OutputToolBinding creation with custom description."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel, description="Custom tool description")
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
assert tool_binding.tool.description == "Custom tool description"
|
|
|
|
def test_from_schema_spec_with_model_docstring(self) -> None:
|
|
"""Test OutputToolBinding creation using model docstring as description."""
|
|
schema_spec = _SchemaSpec(schema=CustomModel)
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
assert tool_binding.tool.description == "Custom model with a custom docstring."
|
|
|
|
def test_from_schema_spec_empty_docstring(self) -> None:
|
|
"""Test OutputToolBinding creation with model that has default docstring."""
|
|
|
|
# Create a model with the same docstring as BaseModel
|
|
class DefaultDocModel(BaseModel):
|
|
# This should have the same docstring as BaseModel
|
|
pass
|
|
|
|
schema_spec = _SchemaSpec(schema=DefaultDocModel)
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
# Should use empty description when model has default BaseModel docstring
|
|
assert not tool_binding.tool.description
|
|
|
|
def test_parse_payload_pydantic_success(self) -> None:
|
|
"""Test successful parsing for Pydantic model."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
tool_args = {"name": "John", "age": 30}
|
|
result = tool_binding.parse(tool_args)
|
|
|
|
assert isinstance(result, _TestModel)
|
|
assert result.name == "John"
|
|
assert result.age == 30
|
|
assert result.email == "default@example.com" # default value
|
|
|
|
def test_parse_payload_pydantic_validation_error(self) -> None:
|
|
"""Test parsing failure for invalid Pydantic data."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = OutputToolBinding.from_schema_spec(schema_spec)
|
|
|
|
# Missing required field 'name'
|
|
tool_args = {"age": 30}
|
|
|
|
with pytest.raises(ValueError, match="Failed to parse data to _TestModel"):
|
|
tool_binding.parse(tool_args)
|
|
|
|
|
|
class TestProviderStrategyBinding:
|
|
"""Test ProviderStrategyBinding dataclass and its methods."""
|
|
|
|
def test_from_schema_spec_basic(self) -> None:
|
|
"""Test basic ProviderStrategyBinding creation from SchemaSpec."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = ProviderStrategyBinding.from_schema_spec(schema_spec)
|
|
|
|
assert tool_binding.schema == _TestModel
|
|
assert tool_binding.schema_kind == "pydantic"
|
|
|
|
def test_parse_payload_pydantic_success(self) -> None:
|
|
"""Test successful parsing for Pydantic model."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = ProviderStrategyBinding.from_schema_spec(schema_spec)
|
|
|
|
message = AIMessage(content='{"name": "John", "age": 30}')
|
|
result = tool_binding.parse(message)
|
|
|
|
assert isinstance(result, _TestModel)
|
|
assert result.name == "John"
|
|
assert result.age == 30
|
|
assert result.email == "default@example.com" # default value
|
|
|
|
def test_parse_payload_pydantic_validation_error(self) -> None:
|
|
"""Test parsing failure for invalid Pydantic data."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = ProviderStrategyBinding.from_schema_spec(schema_spec)
|
|
|
|
# Missing required field 'name'
|
|
message = AIMessage(content='{"age": 30}')
|
|
|
|
with pytest.raises(ValueError, match="Failed to parse data to _TestModel"):
|
|
tool_binding.parse(message)
|
|
|
|
def test_parse_payload_pydantic_json_error(self) -> None:
|
|
"""Test parsing failure for invalid JSON data."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = ProviderStrategyBinding.from_schema_spec(schema_spec)
|
|
|
|
message = AIMessage(content="invalid json")
|
|
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="Native structured output expected valid JSON for _TestModel, but parsing failed",
|
|
):
|
|
tool_binding.parse(message)
|
|
|
|
def test_parse_content_list(self) -> None:
|
|
"""Test successful parsing for Pydantic model with content as list."""
|
|
schema_spec = _SchemaSpec(schema=_TestModel)
|
|
tool_binding = ProviderStrategyBinding.from_schema_spec(schema_spec)
|
|
|
|
message = AIMessage(
|
|
content=['{"name":', {"content": ' "John",'}, {"type": "text", "text": ' "age": 30}'}]
|
|
)
|
|
result = tool_binding.parse(message)
|
|
|
|
assert isinstance(result, _TestModel)
|
|
assert result.name == "John"
|
|
assert result.age == 30
|
|
assert result.email == "default@example.com" # default value
|
|
|
|
|
|
class TestEdgeCases:
|
|
"""Test edge cases and error conditions."""
|
|
|
|
def test_single_schema(self) -> None:
|
|
"""Test ToolStrategy with a single schema creates one schema spec."""
|
|
strategy = ToolStrategy(EmptyDocModel)
|
|
assert len(strategy.schema_specs) == 1
|
|
|
|
def test_empty_docstring_model(self) -> None:
|
|
"""Test that models without explicit docstrings have empty tool descriptions."""
|
|
binding = OutputToolBinding.from_schema_spec(_SchemaSpec(EmptyDocModel))
|
|
assert binding.tool.name == "EmptyDocModel"
|
|
assert not binding.tool.description
|