-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Description
[Feature Request] Support per-parameter descriptions in FunctionTool via Annotated[T, Field(description=...)]
Summary
FunctionTool (automatic function calling) currently ignores per-parameter semantic descriptions. All parameter descriptions are hardcoded to None in _get_fields_dict, regardless of whether the developer provided them via Annotated + pydantic.Field or via the Args: section of the docstring. This prevents developers from giving the LLM the contextual guidance it needs to correctly populate each argument.
Current behaviour
In google/adk/tools/_automatic_function_calling_util.py, the helper _get_fields_dict explicitly drops parameter descriptions:
# google/adk/tools/_automatic_function_calling_util.py (v1.14.1 – v1.25.1)
def _get_fields_dict(func: Callable) -> Dict:
param_signature = dict(inspect.signature(func).parameters)
fields_dict = {
name: (
# 1. We infer the argument type here …
(param.annotation if param.annotation != inspect.Parameter.empty else Any),
pydantic.Field(
# 2. We do not support default values for now.
default=( … ),
# 3. Do not support parameter description for now.
description=None, # <-- always None
),
)
for name, param in param_signature.items()
…
}
return fields_dictAs a consequence, the FunctionDeclaration sent to the model contains parameter schemas with no description field, even when the developer explicitly annotates their function like this:
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:
...Attempting this today raises a ValueError:
ValueError: Failed to parse the parameter repository:
typing.Annotated[str, FieldInfo(annotation=NoneType, required=True,
description='Full GitLab repository URL …')] of function create_task
for automatic function calling.
Expected behaviour
-
Annotated[T, Field(description="...")]— when a parameter is annotated with a pydanticFieldcarrying adescription, that description should be forwarded into the generatedSchema.descriptionfor that parameter in theFunctionDeclaration. -
Args:docstring section (alternative / complementary) — the per-parameter descriptions already written in Google-style docstrings (Args:\n param: description) should be parsed and also forwarded asSchema.description.
Either approach (or both) would close this gap. The first is more explicit and IDE-friendly; the second requires no additional imports and works with existing codebases.
Why this matters
Per-parameter descriptions are the primary mechanism by which developers can do contextual prompting at the tool level — without polluting the agent system prompt. Concrete use cases:
| What the developer wants to express | Currently possible? |
|---|---|
Specify the expected format of a parameter (e.g. PROJECT-123) |
No |
| Tell the model where to source a value (e.g. "use the result of tool X") | No |
| Prevent hallucination ("do NOT invent this value, ask the user") | No |
| Mark a parameter as conditional ("only include if explicitly provided") | No |
Without this, the only workaround is to embed all such guidance in the top-level tool docstring (which becomes the FunctionDeclaration.description). This conflates tool-level intent with parameter-level constraints, produces a verbose and hard-to-maintain description blob, and — most importantly — is less effective because the model cannot associate a constraint with a specific parameter.
Proposed implementation
The change is localised to _get_fields_dict in _automatic_function_calling_util.py.
Option A — read FieldInfo from Annotated metadata
import inspect
from typing import Annotated, get_args, get_origin
import pydantic
from pydantic import fields as pydantic_fields
from pydantic.fields import FieldInfo
def _extract_field_description(annotation) -> str | None:
"""Return the pydantic Field description from Annotated[T, Field(...)] if present."""
if get_origin(annotation) is Annotated:
for metadata in get_args(annotation)[1:]:
if isinstance(metadata, FieldInfo) and metadata.description:
return metadata.description
return None
def _get_fields_dict(func: Callable) -> Dict:
param_signature = dict(inspect.signature(func).parameters)
fields_dict = {
name: (
(param.annotation if param.annotation != inspect.Parameter.empty else Any),
pydantic.Field(
default=(
param.default
if param.default != inspect.Parameter.empty
else pydantic_fields.PydanticUndefined
),
description=_extract_field_description(param.annotation), # <-- NEW
),
)
for name, param in param_signature.items()
if param.kind in (
inspect.Parameter.POSITIONAL_OR_KEYWORD,
inspect.Parameter.KEYWORD_ONLY,
inspect.Parameter.POSITIONAL_ONLY,
)
}
return fields_dictAnnotated[str, Field(description="...")] would need to be unwrapped to its base type (str) before being passed as the annotation to pydantic's create_model, to avoid the current ValueError.
Option B — parse Args: from the docstring
import inspect
import re
def _parse_docstring_param_descriptions(func: Callable) -> dict[str, str]:
"""Extract per-parameter descriptions from a Google-style docstring Args section."""
doc = inspect.getdoc(func) or ""
descriptions: dict[str, str] = {}
in_args = False
current_param: str | None = None
current_lines: list[str] = []
for line in doc.splitlines():
if re.match(r"^\s*Args\s*:", line):
in_args = True
continue
if in_args and re.match(r"^\s*\w+\s*:", line) and not line.startswith(" " * 8):
# New top-level section (Returns, Raises, …) — stop
if current_param:
descriptions[current_param] = " ".join(current_lines).strip()
break
if in_args:
m = re.match(r"^\s{4}(\w+)\s*:(.*)", line)
if m:
if current_param:
descriptions[current_param] = " ".join(current_lines).strip()
current_param = m.group(1)
current_lines = [m.group(2).strip()]
elif current_param and line.strip():
current_lines.append(line.strip())
if current_param and current_lines:
descriptions[current_param] = " ".join(current_lines).strip()
return descriptionsVersion range
Confirmed present (unchanged) from v1.0.0 through v1.25.1 (latest at time of writing).
- File:
google/adk/tools/_automatic_function_calling_util.py - Function:
_get_fields_dict - Line (v1.25.1): comment
# 3. Do not support parameter description for now.+description=None
Alternatives considered
- Embed all guidance in the top-level docstring — the current workaround. Functional but unstructured; the model cannot associate a constraint with a specific parameter.
- Manually build a
FunctionDeclarationand wrap it in aBaseToolsubclass — possible but defeats the purpose of automatic function calling and requires significant boilerplate per tool.
Additional context
This feature is standard in other tool-calling frameworks:
- OpenAI function calling — the JSON schema for each parameter accepts a
descriptionfield natively. - LangChain
@tool— usesAnnotated[T, Field(description="...")]to populate parameter descriptions in the generated schema. - Anthropic tool use — the tool input schema is a JSON Schema object where each property can carry a
description.
Supporting this in ADK would bring it to parity with the ecosystem and significantly improve the developer experience for production-grade agents.