Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 137 additions & 5 deletions src/google/adk/agents/llm_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,73 @@ async def _convert_tool_union_to_tools(
return []


# GenerateContentConfig fields that already have a dedicated LlmAgent argument.
# Passing them as LlmAgent kwargs should point at that argument rather than
# folding into generate_content_config (which would then fail later).
_GENERATE_CONTENT_FIELDS_OWNED_BY_AGENT: dict[str, str] = {
'system_instruction': 'instruction',
'response_schema': 'output_schema',
}

# Snake-case field names and camelCase aliases used by GenerateContentConfig.
_GENERATE_CONTENT_FIELD_NAMES: dict[str, str] = {
name: name for name in types.GenerateContentConfig.model_fields
}
_GENERATE_CONTENT_FIELD_NAMES.update({
field.alias: name
for name, field in types.GenerateContentConfig.model_fields.items()
if field.alias is not None
})


def _generate_content_field_name(key: str) -> Optional[str]:
"""Return the GenerateContentConfig field name for key, or None."""
return _GENERATE_CONTENT_FIELD_NAMES.get(key)


def _existing_generate_content_as_dict(
existing: Any,
) -> Optional[dict[str, Any]]:
"""Normalize generate_content_config input to a snake_case dict.

Returns None when the value is not a recognized config shape so the
generate_content_config field validator can report the type error.
"""
if existing is None:
return {}
if isinstance(existing, types.GenerateContentConfig):
return existing.model_dump(exclude_unset=True)
if isinstance(existing, dict):
normalized: dict[str, Any] = {}
for key, value in existing.items():
canonical = _generate_content_field_name(key) or key
normalized[canonical] = value
return normalized
return None


# TODO: drop the explicit abc.ABC base once BaseNode surfaces ABCMeta to
# static type checkers.
class LlmAgent(BaseAgent, abc.ABC):
"""LLM-based Agent."""
"""LLM-based Agent.

Generation settings from ``google.genai.types.GenerateContentConfig``
such as ``temperature``, ``top_p``, and ``max_output_tokens`` can be
passed directly as keyword arguments. They are merged into
``generate_content_config``.

Example:
```python
from google.adk.agents import LlmAgent

agent = LlmAgent(
name='grader',
model='gemini-3.5-flash',
instruction='Grade the exam.',
temperature=0.1,
)
```
"""

DEFAULT_MODEL: ClassVar[str] = 'gemini-3.5-flash'
"""System default model used when no model is set on an agent."""
Expand Down Expand Up @@ -355,11 +418,13 @@ class LlmAgent(BaseAgent, abc.ABC):
generate_content_config: Optional[types.GenerateContentConfig] = None
"""The additional content generation configurations.

NOTE: not all fields are usable, e.g. tools must be configured via `tools`,
thinking_config can be configured here or via the `planner`. If both are set, the planner's configuration takes precedence.
Generation knobs such as temperature, top_p, and max_output_tokens may
also be passed directly as LlmAgent keyword arguments; they are merged
into this config.

For example: use this config to adjust model temperature, configure safety
settings, etc.
NOTE: not all fields are usable, e.g. tools must be configured via `tools`,
thinking_config can be configured here or via the `planner`. If both are
set, the planner's configuration takes precedence.
"""

mode: Literal['chat', 'task', 'single_turn'] | None = None
Expand Down Expand Up @@ -1086,6 +1151,73 @@ def __maybe_accumulate_streaming_output(
event.actions.state_delta[self.output_key] = accumulator
return accumulator

@model_validator(mode='before')
@classmethod
def _fold_generate_content_kwargs(cls, data: Any) -> Any:
"""Fold GenerateContentConfig fields passed as LlmAgent kwargs.

Users coming from google-genai often pass temperature= (and similar
generation knobs) on LlmAgent. Merge those into generate_content_config
so construction succeeds, and point reserved fields at the LlmAgent
argument that owns them.
"""
if not isinstance(data, dict):
return data

convenience_kwargs: dict[str, Any] = {}
redirected: list[tuple[str, str]] = []
for key, value in data.items():
gcc_field = _generate_content_field_name(key)
if gcc_field is None or gcc_field in cls.model_fields:
continue
agent_field = _GENERATE_CONTENT_FIELDS_OWNED_BY_AGENT.get(gcc_field)
if agent_field is not None:
redirected.append((key, agent_field))
continue
if gcc_field in convenience_kwargs:
raise ValueError(
f'Generation setting `{gcc_field}` was passed more than once'
' (including via its GenerateContentConfig alias). Set it in'
' only one place.'
)
convenience_kwargs[gcc_field] = value

if redirected:
details = '. '.join(
f'`{src}` must be set via LlmAgent.{dest}, not via'
f' LlmAgent({src}=...)'
for src, dest in redirected
)
raise ValueError(details + '.')

if not convenience_kwargs:
return data

existing_dict = _existing_generate_content_as_dict(
data.get('generate_content_config')
)
if existing_dict is None:
return data

conflicts = sorted(
key for key in convenience_kwargs if key in existing_dict
)
if conflicts:
conflict_list = ', '.join(f'`{key}`' for key in conflicts)
raise ValueError(
f'Cannot set {conflict_list} both as an LlmAgent argument and'
' inside generate_content_config. Set each field in only one'
' place.'
)

for key in list(data.keys()):
gcc_field = _generate_content_field_name(key)
if gcc_field is not None and gcc_field in convenience_kwargs:
del data[key]
existing_dict.update(convenience_kwargs)
data['generate_content_config'] = existing_dict
return data

@model_validator(mode='before')
@classmethod
def _pre_validate_tools(cls, data: Any) -> Any:
Expand Down
51 changes: 51 additions & 0 deletions tests/unittests/agents/test_llm_agent_error_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,54 @@ def test_response_schema_error_includes_move_guidance(self):
config = types.GenerateContentConfig(response_schema={'type': 'string'})
with pytest.raises(ValueError, match=r'Move your schema'):
LlmAgent.validate_generate_content_config(config)


class TestGenerateContentKwargErrors:
"""Tests for LlmAgent generation-kwarg folding error messages."""

def test_system_instruction_kwarg_points_to_instruction(self):
"""system_instruction= should tell users to use instruction=."""
with pytest.raises(ValueError, match=r'LlmAgent.instruction'):
LlmAgent(name='test_agent', system_instruction='You are helpful.')

def test_response_schema_kwarg_points_to_output_schema(self):
"""response_schema= should tell users to use output_schema=."""
with pytest.raises(ValueError, match=r'LlmAgent.output_schema'):
LlmAgent(name='test_agent', response_schema={'type': 'string'})

def test_generation_kwarg_conflict_with_generate_content_config(self):
"""The same field set twice should name both sources."""
with pytest.raises(
ValueError,
match=(
r'both as an LlmAgent argument and inside generate_content_config'
),
):
LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(temperature=0.5),
temperature=0.1,
)

def test_camel_case_config_dict_conflict_with_snake_case_kwarg(self):
"""Aliased dict keys still conflict with the snake_case kwarg."""
with pytest.raises(ValueError, match=r'`max_output_tokens`'):
LlmAgent(
name='test_agent',
generate_content_config={'maxOutputTokens': 10},
max_output_tokens=20,
)

def test_unknown_extra_kwarg_still_forbidden(self):
"""Unrecognized kwargs remain extra_forbidden."""
with pytest.raises(ValueError, match='Extra inputs are not permitted'):
LlmAgent(name='test_agent', not_a_real_field=True)

def test_duplicate_snake_and_camel_generation_kwargs_raise(self):
"""The same generation field via name and alias is rejected."""
with pytest.raises(ValueError, match=r'max_output_tokens'):
LlmAgent.model_validate({
'name': 'test_agent',
'max_output_tokens': 10,
'maxOutputTokens': 20,
})
62 changes: 62 additions & 0 deletions tests/unittests/agents/test_llm_agent_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,68 @@ def test_validate_generate_content_config_http_options_allowed():
assert agent.generate_content_config.http_options.extra_body == extra_body


def test_temperature_kwarg_folds_into_generate_content_config():
"""LlmAgent(temperature=...) is stored on generate_content_config."""

class Grade(BaseModel):
score: int

agent = LlmAgent(
name='grader',
model='gemini-3.5-flash-lite',
instruction='Grade the exam.',
output_schema=Grade,
temperature=0.1,
)

assert agent.generate_content_config.temperature == pytest.approx(0.1)


def test_generation_kwargs_fold_together_into_generate_content_config():
"""Common generation knobs passed as kwargs land on the same config."""
agent = LlmAgent(
name='test_agent',
temperature=0.2,
top_p=0.95,
max_output_tokens=256,
)

assert agent.generate_content_config.temperature == pytest.approx(0.2)
assert agent.generate_content_config.top_p == pytest.approx(0.95)
assert agent.generate_content_config.max_output_tokens == 256


def test_generation_kwargs_merge_with_existing_generate_content_config():
"""Kwargs fill fields that generate_content_config left unset."""
agent = LlmAgent(
name='test_agent',
generate_content_config=types.GenerateContentConfig(top_p=0.9),
temperature=0.1,
)

assert agent.generate_content_config.temperature == pytest.approx(0.1)
assert agent.generate_content_config.top_p == pytest.approx(0.9)


def test_camel_case_generation_kwarg_folds_into_generate_content_config():
"""JSON-style GenerateContentConfig aliases are folded the same way."""
agent = LlmAgent.model_validate(
{'name': 'test_agent', 'maxOutputTokens': 256}
)

assert agent.generate_content_config.max_output_tokens == 256


def test_thinking_config_kwarg_folds_into_generate_content_config():
"""thinking_config can be passed directly as an LlmAgent kwarg."""
agent = LlmAgent(
name='test_agent',
thinking_config=types.ThinkingConfig(include_thoughts=True),
)

assert agent.generate_content_config.thinking_config.include_thoughts is True


def test_allow_transfer_by_default():
sub_agent = LlmAgent(name='sub_agent')
agent = LlmAgent(name='test_agent', sub_agents=[sub_agent])
Expand Down