diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst index b64062ec8cd..5dd5ccf20ec 100644 --- a/doc/source/admin/galaxy_options.rst +++ b/doc/source/admin/galaxy_options.rst @@ -5688,8 +5688,10 @@ ``retries`` sets the pydantic-ai retry budget (tool calls and output validation); it defaults to 3. Raise it if a model intermittently fails to produce conforming output ("Exceeded - maximum output retries"); custom_tool's producer defaults to 0 - since it runs its own reflection loop. + maximum output retries"). custom_tool's producer keeps a budget of + 0 because it runs its own reflection loop; a shared ``default`` + block does not change that -- set ``custom_tool.retries`` + explicitly to override it. :Default: ``None`` :Type: any diff --git a/lib/galaxy/agents/base.py b/lib/galaxy/agents/base.py index fa38b184fcb..d69d02b6996 100644 --- a/lib/galaxy/agents/base.py +++ b/lib/galaxy/agents/base.py @@ -29,6 +29,7 @@ from typing import ( import yaml +from galaxy.exceptions import ConfigurationError from galaxy.managers.context import ProvidesUserContext from galaxy.model import User from galaxy.schema.agents import ( @@ -817,6 +818,19 @@ class BaseGalaxyAgent(ABC): return self.deps.config.ai_api_base_url return default + def _get_agent_specific_config(self, key: str, default: Any = None) -> Any: + """Read a value only from this agent's own ``inference_services`` block. + + Unlike :meth:`_get_agent_config`, this skips the shared ``default`` block so a + caller-pinned builtin is overridden only by an explicit per-agent entry. + """ + inference_config = getattr(self.deps.config, "inference_services", {}) + if isinstance(inference_config, dict): + agent_specific = inference_config.get(self.agent_type, {}) + if isinstance(agent_specific, dict) and key in agent_specific: + return agent_specific[key] + return default + def _get_model_name(self) -> str: return self._get_agent_config("model", "gpt-4o-mini") @@ -864,10 +878,26 @@ class BaseGalaxyAgent(ABC): def _get_retries(self, default: Optional[int] = None) -> int: """Retry budget for the agent's pydantic-ai ``Agent(retries=...)``. - ``default`` lets a caller override the builtin (e.g. custom_tool's producer - keeps 0 so its own reflection loop owns the retry). + With no ``default``, the budget resolves per-agent > ``default`` block > + builtin (:attr:`DEFAULT_AGENT_RETRIES`). A caller-pinned ``default`` (e.g. + custom_tool's producer keeps 0 so its own reflection loop owns the retry) is + a correctness requirement, not a tunable: only an explicit per-agent + ``retries`` overrides it -- a shared ``default`` block must not silently + re-enable pydantic-ai retries there. """ - return int(self._get_agent_config("retries", self.DEFAULT_AGENT_RETRIES if default is None else default)) + if default is None: + raw = self._get_agent_config("retries", self.DEFAULT_AGENT_RETRIES) + else: + raw = self._get_agent_specific_config("retries", default) + try: + retries = int(raw) + except (TypeError, ValueError): + retries = None + if retries is None or retries < 0: + raise ConfigurationError( + f"inference_services 'retries' for agent '{self.agent_type}' must be a non-negative integer, got {raw!r}" + ) + return retries async def _call_agent_from_tool( self, diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample index 5e470624224..ab22fbd2ec4 100644 --- a/lib/galaxy/config/sample/galaxy.yml.sample +++ b/lib/galaxy/config/sample/galaxy.yml.sample @@ -3084,8 +3084,10 @@ galaxy: # ``retries`` sets the pydantic-ai retry budget (tool calls and output # validation); it defaults to 3. Raise it if a model intermittently # fails to produce conforming output ("Exceeded maximum output - # retries"); custom_tool's producer defaults to 0 since it runs its - # own reflection loop. + # retries"). custom_tool's producer keeps a budget of 0 because it + # runs its own reflection loop; a shared ``default`` block does not + # change that -- set ``custom_tool.retries`` explicitly to override + # it. #inference_services: null # YAML file with capability hints for agent inference models. Maps diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml index 9f05a1b0b32..9758ae5afda 100644 --- a/lib/galaxy/config/schemas/config_schema.yml +++ b/lib/galaxy/config/schemas/config_schema.yml @@ -4211,8 +4211,9 @@ mapping: Per-agent or default-block ``retries`` sets the pydantic-ai retry budget (tool calls and output validation); it defaults to 3. Raise it if a model intermittently fails to produce conforming output ("Exceeded maximum output - retries"); custom_tool's producer defaults to 0 since it runs its own - reflection loop. + retries"). custom_tool's producer keeps a budget of 0 because it runs its + own reflection loop; a shared ``default`` block does not change that -- set + ``custom_tool.retries`` explicitly to override it. agent_model_capabilities_file: type: str diff --git a/test/unit/app/test_agents.py b/test/unit/app/test_agents.py index 427fb6d8a51..8d918632e89 100644 --- a/test/unit/app/test_agents.py +++ b/test/unit/app/test_agents.py @@ -84,6 +84,7 @@ from galaxy.agents.page_assistant import ( FullReplacementEdit, SectionPatchEdit, ) +from galaxy.exceptions import ConfigurationError from galaxy.schema.agents import ConfidenceLevel from galaxy.tool_util_models import UserToolSource from galaxy.util.unittest_utils import pytestmark_live_llm @@ -191,6 +192,37 @@ class TestAgentUnitMocked: producer = CustomToolAgent(self.deps) assert producer.agent._max_output_retries == 0 + def test_producer_retries_default_ignores_default_block(self): + # custom_tool's producer pins retries=0 so its own reflection loop owns + # the retry. A shared `default` block must NOT silently re-enable + # pydantic-ai retries there -- only an explicit per-agent entry may. + producer = CustomToolAgent(self.deps) + + self.mock_config.inference_services = {"default": {"retries": 5}} + assert producer._get_retries(default=0) == 0 + # The normal (critic) lookup still honors the default block. + assert producer._get_retries() == 5 + + # An explicit custom_tool entry still overrides the pinned builtin. + self.mock_config.inference_services = {"custom_tool": {"retries": 7}} + assert producer._get_retries(default=0) == 7 + + def test_invalid_retries_config_raises_configuration_error(self): + # Non-numeric, blank, or negative `retries` is operator misconfiguration; + # it must surface as a clear ConfigurationError rather than a bare + # TypeError/ValueError (which the manager mistakes for an unknown-agent + # fallback) or a silently-broken negative budget that fails every request. + router = QueryRouterAgent(self.deps) + + for bad in ("three", None, -1): + self.mock_config.inference_services = {"default": {"retries": bad}} + with pytest.raises(ConfigurationError, match="retries"): + router._get_retries() + + # 0 is valid (custom_tool's producer relies on it) and must not raise. + self.mock_config.inference_services = {"default": {"retries": 0}} + assert router._get_retries() == 0 + @pytest.mark.asyncio async def test_router_falls_back_on_output_retry_exhaustion(self): # When the model never produces a valid structured output, pydantic-ai