Skip to content

Also include return (type) json schema for tools (similar how tool parameters are handled) #3122

@LysanderKie

Description

@LysanderKie

Description

Feature Request: Include Tool Return Type Schema in Tool Definitions (passed into LLM call as part of tools json)

Background
We use pydantic-ai as an agent framework in a complex business domain. It's critical for us that in addition to the tool parameters schema and description, the schema and description of tool return types are also included in the JSON passed to the LLM as part of tool definitions.

Current Problem: The LLM doesn't know which properties/fields are available in a tool's return value without actually executing the tool call. This leads to:

  • Unnecessary tool calls just to discover what data is available
  • The LLM being unable to plan multi-step operations effectively
  • Confusion about which tool provides which data points

Use Cases

  1. Rich DTOs as Tool Returns
    All of our tools return DTOs (Pydantic models) instead of plain strings or primitives. These models contain detailed information about domain entities like invoices, projects, and contracts.

  2. Sequential (Chained) Tool Calls
    This is foundational for the sequential tool call improvements discussed in Code Mode: Tool Injection for mcp-run-python: Enable Python code to call back to agent tools with request/response flow #2037. If the LLM knows what data a tool returns, it can:

    • Plan a sequence of tool calls where one tool's output feeds into another
    • Determine upfront if a requested data point is available
    • Chain operations more intelligently (e.g., "get invoice details, then get the contract details for that invoice")
  3. Field Descriptions in Complex Domains
    In complex business domains, field names alone are often insufficient for the LLM to understand what data represents. For example:

    • audited_amount vs approved_amount vs paid_amount on an invoice
    • date_received vs date_posted vs date_due
    • Domain-specific terminology that needs context

    By including field descriptions from Pydantic models in the return schema, the LLM can:

    • Understand the semantic meaning of each field
    • Provide more accurate answers about what data is available
    • Better explain differences between similar-sounding fields to users
  4. Better User Experience
    With return schemas available, the LLM can:

    • Politely decline requests for data that isn't available in any tool return
    • Suggest alternative data points that are available
    • Be more precise about what information it can provide

Proposed Solution
Similar to how parameters work today, each tool definition would include a returns field containing the JSON schema of the return type.

How this could be implemented

  1. Auto-generate from type annotations: Since tools must return JSON-serializable data, we can extract the return type from function signatures and generate the schema
  2. Docstring parsing: Parse return type descriptions from docstrings (similar to current parameter handling)
  3. Opt-in flag: To avoid breaking changes and manage context length, introduce an opt-in flag (e.g., include_return_schema=True) in ToolDefinition or during tool preparation

I would be curious if you already thought about this and have a preferred way of how this should be implemented in mind.

Example: Current vs. Proposed

Current tool definition:

{
    "type": "function",
    "function": {
        "name": "get_contract_details",
        "description": "Get details about a specific contract by ID.",
        "parameters": {
            "additionalProperties": false,
            "properties": {
                "contract_id": {
                    "type": "integer",
                    "description": "ID of the contract"
                }
            },
            "required": ["contract_id"],
            "type": "object",
        },
        "strict": true
    }
}

Proposed with return schema:

{
    "type": "function",
    "function": {
        "name": "get_contract_details",
        "description": "Get details about a specific contract by ID.",
        "parameters": {
            "additionalProperties": false,
            "properties": {
                "contract_id": {
                    "type": "integer"
                }
            },
            "required": ["contract_id"],
            "type": "object"
        },
        "strict": true,
        "returns": {
            // Either with 'properties' or using #/$defs/... which is used for parameters
            "type": "object",
            "description": "Details about a contract within a project",
            "properties": {
                "id": {
                    "type": "integer",
                    "description": "Unique contract identifier"
                },
                "project_id": {
                    "type": "integer",
                    "description": "ID of the parent project"
                },
                "contractor_name": {
                    "type": "string",
                    "description": "Name of the contractor company"
                },
                "amount": {
                    "type": "number",
                    "description": "Total contract amount"
                },
                "signed_date": {
                    "type": "string",
                    "format": "date",
                    "description": "Date the contract was signed"
                }
            },
            "required": ["id", "project_id", "contractor_name", "amount", "signed_date"]
        }
    }
}

Minimal Example

Here's a minimal example demonstrating the need, especially for complex domain models with field descriptions:

from datetime import date
from decimal import Decimal
import pydantic
from pydantic_ai import Agent, RunContext

class InvoiceDetails(pydantic.BaseModel):
    """Details about an invoice for a contract"""
    id: int = pydantic.Field(description="Unique invoice identifier")
    contract_id: int = pydantic.Field(description="ID of the associated contract")
    invoice_number: str = pydantic.Field(description="External invoice number")
    
    # Multiple amount fields - descriptions are crucial for LLM to understand differences
    initial_amount: Decimal = pydantic.Field(description="Initial invoice amount as submitted by contractor")
    audited_amount: Decimal = pydantic.Field(description="Amount verified after checking the invoice")
    approved_amount: Decimal = pydantic.Field(description="Amount released for payment after approval")
    paid_amount: Decimal = pydantic.Field(description="Amount actually paid out")
    
    # Multiple date fields - descriptions clarify the workflow
    date_received: date = pydantic.Field(description="Date invoice was received")
    date_posted: date = pydantic.Field(description="Date invoice was posted in accounting")
    status: str = pydantic.Field(description="Invoice status (new, approved, paid)")

def get_invoice_details(ctx: RunContext, invoice_id: int) -> InvoiceDetails:
    """Get details about a specific invoice by ID."""
    return InvoiceDetails(
        id=invoice_id,
        contract_id=1,
        invoice_number="INV-2024-001",
        initial_amount=Decimal("50000.00"),
        audited_amount=Decimal("48000.00"),
        approved_amount=Decimal("48000.00"),
        paid_amount=Decimal("48000.00"),
        date_received=date(2024, 3, 15),
        date_posted=date(2024, 3, 20),
        status="approved",
    )

agent = Agent("openai:gpt-4o", tools=[get_invoice_details])

# Without return schema: 
# - LLM doesn't know what fields InvoiceDetails contains
# - Can't distinguish between initial_amount, audited_amount, approved_amount, paid_amount
# - User asks "what's the invoice amount?" - which one do they mean?

# With return schema + field descriptions:
# - LLM knows exactly what fields are available
# - Understands the difference between amounts (initial vs audited vs approved vs paid)
# - Can ask clarifying questions or provide the most relevant amount based on context

Related Issues

Open Questions

  1. Opt-in vs. opt-out: Should this be enabled by default or require explicit configuration?
  2. Context length concerns: Should there be automatic truncation/summarization for very large return schemas?
  3. Docstring parsing: How should we handle return type descriptions from docstrings?

I'm happy to contribute to implementing this feature if there's interest! Let me know if you have preferences on the approach.


Note: This feature would significantly improve pydantic-ai's capabilities for complex, multi-step agentic workflows in production environments, especially in domains where field descriptions are essential for LLM comprehension.

References

No response

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions