Skip to content

feat(tools): support per-parameter descriptions via Annotated[T, Field(description=...)]#4962

Open
ecanlar wants to merge 5 commits intogoogle:mainfrom
ecanlar:feat/support-annotated-field-description
Open

feat(tools): support per-parameter descriptions via Annotated[T, Field(description=...)]#4962
ecanlar wants to merge 5 commits intogoogle:mainfrom
ecanlar:feat/support-annotated-field-description

Conversation

@ecanlar
Copy link
Copy Markdown

@ecanlar ecanlar commented Mar 23, 2026

Summary

This PR implements Option A from issue #4552, adding support for per-parameter descriptions in FunctionTool using the Annotated type hint with pydantic.Field(description=...).

  • Extracts parameter descriptions from Annotated[T, Field(description=...)] syntax
  • Unwraps base types from Annotated[T, ...] for proper model creation
  • Includes comprehensive unit tests for the new functionality

Motivation

Per-parameter descriptions enable contextual prompting at the tool level without polluting agent system prompts. This allows developers to:

  • Specify expected parameter formats (e.g., PROJECT-123)
  • Tell the model where to source values (e.g., "use the result of tool X")
  • Prevent hallucination ("do NOT invent this value, ask the user")
  • Mark parameters as conditional

Example Usage

from typing import Annotated
from pydantic import Field

async def create_task(
    repository: Annotated[str, Field(
        description=(
            "Full GitLab repository URL. "
            "MUST be obtained from get_repository_info. "
            "Format: https://gitlab.com/group/project"
        )
    )],
    base_branch: Annotated[str, Field(
        description=(
            "Base branch for development (e.g. 'main', 'develop'). "
            "MUST be obtained from get_repository_info. "
            "Do NOT default to 'main' without calling that tool first."
        )
    )],
) -> dict:
    '''Create a new task in the repository.'''
    ...

Changes

Modified Files

  • src/google/adk/tools/_automatic_function_calling_util.py:
    • Added _extract_field_info_from_annotated() helper function
    • Added _extract_base_type_from_annotated() helper function
    • Updated _get_fields_dict() to extract and use parameter descriptions

New Files

  • tests/unittests/tools/test_annotated_parameter_descriptions.py: Comprehensive tests for the new functionality

Testing

All new and existing tests pass:

  • 16 new tests for annotated parameter descriptions
  • 28 existing function tool tests continue to pass

Closes #4552

…d(description=...)]

This change implements Option A from issue google#4552, adding support for
per-parameter descriptions in FunctionTool using the Annotated type hint
with pydantic.Field(description=...).

Changes:
- Add _extract_field_info_from_annotated() to extract FieldInfo from Annotated
- Add _extract_base_type_from_annotated() to unwrap base types from Annotated
- Update _get_fields_dict() to use descriptions from Annotated[T, Field(...)]
- Add comprehensive tests for the new functionality

This enables developers to provide contextual guidance for LLM parameter
selection without embedding all information in the tool docstring:

  from typing import Annotated
  from pydantic import Field

  async def create_task(
      repository: Annotated[str, Field(
          description='Repository URL. MUST be obtained from get_repository_info.'
      )],
  ) -> dict:
      ...

Closes google#4552
@rohityan rohityan self-assigned this Mar 23, 2026
@edpowers
Copy link
Copy Markdown

edpowers commented Mar 23, 2026

Does this handle nested pydantic objects? I noticed the original PR didn't. If it helps, I can add code for that.

@ecanlar
Copy link
Copy Markdown
Author

ecanlar commented Mar 24, 2026

Hi @edpowers!

You're absolutely right - I just tested the implementation and confirmed that nested Pydantic objects are NOT handled correctly.

The issue is that Pydantic generates JSON schemas with $ref pointers to $defs for nested BaseModel classes, so the Field descriptions from nested models end up in a separate $defs section rather than inline. This means the LLM/API might not see those descriptions when generating function calls.

Thank you for catching this!

…ptions

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 google#4962 where
nested Pydantic BaseModel Field descriptions were not accessible to LLMs
because they remained in $defs instead of being inlined.
@ecanlar
Copy link
Copy Markdown
Author

ecanlar commented Mar 24, 2026

Hi @edpowers! 👋

You're absolutely right - I just verified the implementation and nested Pydantic objects were NOT handled correctly in the original PR. Thanks for catching this!

The Problem

Pydantic v2 generates JSON schemas with $ref pointers to $defs for nested BaseModel classes:

{
  "properties": {
    "user": {
      "allOf": [{"$ref": "#/$defs/Person"}],
      "description": "User info"
    }
  },
  "$defs": {
    "Person": {
      "properties": {
        "name": {"description": "Person's full name"},
        "age": {"description": "Person's age in years"}
      }
    }
  }
}

The LLM/API would not see the nested Field descriptions ("Person's full name", "Person's age in years") because they're in a separate $defs section, not inline with the properties.

The Solution

I've just pushed a commit that adds full support for nested Pydantic models!

What I implemented:

  1. _resolve_pydantic_refs() function - Resolves all $ref pointers and inlines nested properties

    • Handles Pydantic v2's allOf wrapper pattern
    • Recursively resolves multi-level nesting (e.g., PersonContactInfoemail)
    • Prevents infinite loops from circular references
    • Preserves parameter-level descriptions over model docstrings
  2. Integration - Modified _get_pydantic_schema() to automatically resolve refs

  3. Comprehensive tests - Added 8 new tests covering:

    • ✅ Single-level nested models
    • ✅ Multi-level nested models (doubly-nested)
    • List[BaseModel] support
    • Optional[BaseModel] support
    • ✅ Mixed simple + nested parameters
    • ✅ Circular reference handling
    • ✅ Full integration with build_function_declaration()

Example - Now this works correctly:

class ContactInfo(BaseModel):
    email: str = Field(description="Email in format user@domain.com")
    phone: str = Field(description="Phone with country code")

class Person(BaseModel):
    name: str = Field(description="Person's full name")
    contact: ContactInfo = Field(description="Contact information")

def create_user(
    person: Annotated[Person, Field(description="User personal information")],
) -> dict:
    """Create a new user."""
    pass

The generated FunctionDeclaration will now have all nested descriptions inlined:

  • person.name → "Person's full name" ✅
  • person.contact.email → "Email in format user@domain.com" ✅
  • person.contact.phone → "Phone with country code" ✅

Inspiration

The implementation is based on ADK's existing _resolve_references() function in openapi_spec_parser.py, which already handles similar reference resolution for OpenAPI tools. I adapted it for Pydantic's specific schema structure.


Let me know if you'd like me to add any additional test cases or edge cases!

@rohityan
Copy link
Copy Markdown
Collaborator

rohityan commented Apr 1, 2026

Hi @ecanlar , Thank you for your contribution! We appreciate you taking the time to submit this pull request.
Can you please fix the failing tests before we can proceed with the review

@rohityan rohityan added tools [Component] This issue is related to tools request clarification [Status] The maintainer need clarification or more information from the author labels Apr 1, 2026
- Add type parameters to Dict and set in _resolve_pydantic_refs inner
  functions to fix mypy [type-arg] errors
- Fix no-any-return in resolve_ref by explicitly typing the variable
- Preserve Field(description=...) for direct $ref (not just allOf) in
  _resolve_pydantic_refs so nested model descriptions propagate correctly
- Re-apply per-parameter descriptions in _get_pydantic_schema after
  schema generation, since Pydantic may replace them with model docstrings
- Unwrap Annotated[T, Field(...)] in from_function_with_options so
  Annotated BaseModel parameters are parsed correctly
- Propagate field_info.description to sub-schemas when parsing nested
  BaseModel fields in _parse_schema_from_parameter
@ecanlar ecanlar force-pushed the feat/support-annotated-field-description branch from 64bb584 to a4600a7 Compare April 7, 2026 06:53
@ecanlar
Copy link
Copy Markdown
Author

ecanlar commented Apr 7, 2026

HI @rohityan, now tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

request clarification [Status] The maintainer need clarification or more information from the author tools [Component] This issue is related to tools

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support per-parameter descriptions in FunctionTool via Annotated[T, Field(description=...)]

3 participants