From 9c94f113aa0d024c0b385a85adbd03d79bcbafa0 Mon Sep 17 00:00:00 2001 From: Aarav Mittal <137450929+a2105z@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:54:16 -0500 Subject: [PATCH] fix(agents): accept generation kwargs on LlmAgent Users coming from google-genai pass temperature= (and similar knobs) on LlmAgent, which failed with extra_forbidden and no pointer to generate_content_config. Fold GenerateContentConfig fields into that config, and point reserved fields at the LlmAgent argument that owns them. Fixes #6836 --- src/google/adk/agents/llm_agent.py | 142 +++++++++++++++++- .../agents/test_llm_agent_error_messages.py | 51 +++++++ .../unittests/agents/test_llm_agent_fields.py | 62 ++++++++ 3 files changed, 250 insertions(+), 5 deletions(-) diff --git a/src/google/adk/agents/llm_agent.py b/src/google/adk/agents/llm_agent.py index 35c529474e..9ae9b91536 100644 --- a/src/google/adk/agents/llm_agent.py +++ b/src/google/adk/agents/llm_agent.py @@ -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.""" @@ -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 @@ -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: diff --git a/tests/unittests/agents/test_llm_agent_error_messages.py b/tests/unittests/agents/test_llm_agent_error_messages.py index eb5ed07f3e..929e0d60e7 100644 --- a/tests/unittests/agents/test_llm_agent_error_messages.py +++ b/tests/unittests/agents/test_llm_agent_error_messages.py @@ -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, + }) diff --git a/tests/unittests/agents/test_llm_agent_fields.py b/tests/unittests/agents/test_llm_agent_fields.py index bc87106149..ba89e9c7e9 100644 --- a/tests/unittests/agents/test_llm_agent_fields.py +++ b/tests/unittests/agents/test_llm_agent_fields.py @@ -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])