This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new ef495f35a30 Add `max_retries` to `AgentSkillsToolset` (#74381)
ef495f35a30 is described below

commit ef495f35a30fbc62d9b71bfbb5468d0dd9092a76
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 7 14:12:42 2026 +0100

    Add `max_retries` to `AgentSkillsToolset` (#74381)
    
    pydantic-ai-skills gives its tools a budget of one correction, and the
    toolset-level value takes precedence over the agent's retries, so a model
    that named two missing skill resources in a row failed the run however the
    agent was configured. AgentSkillsToolset now takes max_retries and passes it
    through, the same parameter the other toolsets in this provider expose. It 
is
    only forwarded when set, so the default budget is unchanged.
    
    
    Every other toolset in this provider treats max_retries=None as the agent's
    tool retry budget. AgentSkillsToolset now does too: it sets each tool's 
budget
    in get_tools, resolved the same way as AirflowToolset._get_tool_max_retries,
    instead of forwarding the value into pydantic-ai-skills, whose toolset fixes
    its own budget at one correction. With the agent's default retries of one,
    the default behaviour is unchanged.
---
 providers/common/ai/docs/toolsets/index.rst        |  2 +
 providers/common/ai/docs/toolsets/skills.rst       | 13 ++++--
 .../common/ai/example_dags/example_agent_skills.py |  2 +
 .../airflow/providers/common/ai/toolsets/skills.py | 15 ++++++-
 .../tests/unit/common/ai/toolsets/test_skills.py   | 47 +++++++++++++++++++++-
 5 files changed, 74 insertions(+), 5 deletions(-)

diff --git a/providers/common/ai/docs/toolsets/index.rst 
b/providers/common/ai/docs/toolsets/index.rst
index 3d61337e8a3..eb9c0525448 100644
--- a/providers/common/ai/docs/toolsets/index.rst
+++ b/providers/common/ai/docs/toolsets/index.rst
@@ -239,6 +239,8 @@ the call. What counts differs by toolset:
 - ``ObjectStorageToolset`` counts invalid arguments only. A path that does not 
exist or
   cannot be read goes back to the model as a failed result without using the 
budget;
   bound repeated failed reads with ``usage_limits``.
+- ``AgentSkillsToolset`` counts a failed call to a skills tool, such as a 
resource name
+  that does not exist.
 
 These toolsets allow as many corrections as the agent's tool retry budget, 
pydantic-ai's
 ``retries`` (one by default), the same way pydantic-ai's own toolsets do. Pass
diff --git a/providers/common/ai/docs/toolsets/skills.rst 
b/providers/common/ai/docs/toolsets/skills.rst
index bb1cdcc6bf4..1e0b1ed6dd7 100644
--- a/providers/common/ai/docs/toolsets/skills.rst
+++ b/providers/common/ai/docs/toolsets/skills.rst
@@ -109,14 +109,18 @@ reading an excluded file got:
 
     Resource 'warehouse.env' not found in skill 'sql-reporting'. Available 
resources: ['reference.md']. Use the exact name from load_skill output.
 
-The skills tools allow the model one correction each, and the agent's 
``retries``
-does not change that. A second refused read in a row failed the run with
-``UnexpectedModelBehavior``, even with ``retries`` set to ``{"tools": 3}``:
+Without ``max_retries``, each skills tool allows as many corrections as the 
agent's
+``retries``, one by default, and a successful call to that tool resets the 
count. With
+the default, a second refused read in a row failed the run with
+``UnexpectedModelBehavior``:
 
 .. code-block:: text
 
     Tool 'read_skill_resource' exceeded max retries count of 1. Consider 
raising the retry limit, or see the docs on tool retries: 
https://pydantic.dev/docs/ai/tools-toolsets/tools-advanced/#tool-retries
 
+With the example's ``max_retries=3``, the same two refused reads went back to 
the model,
+which then read ``reference.md`` and finished the run.
+
 ``exclude_resources`` hides files from the resource tools only. A skill's 
scripts can
 still read them, which is why the example excludes ``run_skill_script`` as 
well.
 
@@ -137,6 +141,9 @@ Parameters
   it does not stop a skill's ``run_skill_script`` from reading them off disk, 
so
   pair it with ``exclude_tools={"run_skill_script"}`` when the files are
   genuinely sensitive.
+- ``max_retries``: How many times the model may correct failed calls to one 
skills tool
+  before the run fails; a successful call to that tool resets the count. 
Default
+  ``None``, the agent's ``retries``. See :ref:`toolset-retry-budget`.
 
 Using Agent Skills with other frameworks
 ----------------------------------------
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py
index f922b40993d..5618c758e7a 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py
@@ -130,6 +130,8 @@ def example_agent_skills_restricted():
                 exclude_tools={"run_skill_script"},
                 # Keep matching files out of the resources the model can list 
and read.
                 exclude_resources=["*.env", "secrets/*"],
+                # Allow up to 3 corrections per skills tool; a successful call 
resets the count.
+                max_retries=3,
             )
         ],
     )
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/skills.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/skills.py
index 8dc1fb68ba9..65d508098e3 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/skills.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/skills.py
@@ -30,9 +30,11 @@ removed when the toolset context exits.
 
 from __future__ import annotations
 
+import dataclasses
 from typing import TYPE_CHECKING, Any
 
 from airflow.providers.common.ai.skills import SkillSource, _materialize_skills
+from airflow.providers.common.ai.utils.toolset_base import validate_max_retries
 
 try:
     from pydantic_ai.toolsets.abstract import AbstractToolset
@@ -72,6 +74,10 @@ class AgentSkillsToolset(AbstractToolset):
         discovery only -- it does not stop a skill's ``run_skill_script`` from
         reading them off disk, so pair it with 
``exclude_tools={"run_skill_script"}``
         when the files are genuinely sensitive. Requires 
``pydantic-ai-skills>=1.2.0``.
+    :param max_retries: How many times the model may correct failed calls to 
one skills tool,
+        such as a resource name that does not exist, before the run fails; a 
successful call
+        to that tool resets the count. ``None`` (the default) uses the agent's 
tool retry
+        budget, its ``retries``, as the provider's other toolsets do.
 
     Requires the ``skills`` extra: ``pip install 
"apache-airflow-providers-common-ai[skills]"``.
     """
@@ -82,10 +88,12 @@ class AgentSkillsToolset(AbstractToolset):
         *,
         exclude_tools: set[str] | None = None,
         exclude_resources: list[str] | None = None,
+        max_retries: int | None = None,
     ) -> None:
         self._sources = list(sources)
         self._exclude_tools = exclude_tools
         self._exclude_resources = exclude_resources
+        self._max_retries = validate_max_retries(max_retries)
         self._inner: Any = None
         self._cleanup: Callable[[], None] | None = None
 
@@ -101,6 +109,7 @@ class AgentSkillsToolset(AbstractToolset):
             self._sources,
             exclude_tools=self._exclude_tools,
             exclude_resources=self._exclude_resources,
+            max_retries=self._max_retries,
         )
 
     async def __aenter__(self) -> AgentSkillsToolset:
@@ -150,7 +159,11 @@ class AgentSkillsToolset(AbstractToolset):
         return self._inner
 
     async def get_tools(self, ctx: RunContext) -> dict[str, ToolsetTool]:
-        return await self._require_inner().get_tools(ctx)
+        # pydantic-ai-skills fixes its tools' budget at one correction, 
whatever the agent's
+        # retries say; resolve it the way the provider's other toolsets do 
instead.
+        max_retries = ctx.max_retries if self._max_retries is None else 
self._max_retries
+        tools = await self._require_inner().get_tools(ctx)
+        return {name: dataclasses.replace(tool, max_retries=max_retries) for 
name, tool in tools.items()}
 
     async def call_tool(
         self, name: str, tool_args: dict[str, Any], ctx: RunContext, tool: 
ToolsetTool
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_skills.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_skills.py
index a0e768da563..7801697948d 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_skills.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_skills.py
@@ -27,6 +27,10 @@ import sys
 from unittest.mock import MagicMock, patch
 
 import pytest
+from pydantic_ai import Agent
+from pydantic_ai.exceptions import UnexpectedModelBehavior
+from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart
+from pydantic_ai.models.function import FunctionModel
 
 from airflow.providers.common.ai.skills import GitSkills
 from airflow.providers.common.ai.toolsets.skills import AgentSkillsToolset
@@ -145,17 +149,58 @@ class TestLifecycle:
 
         assert "exclude_tools" not in captured
         assert "exclude_resources" not in captured
+        assert "max_retries" not in captured
+
+    def test_negative_max_retries_is_rejected(self):
+        with pytest.raises(ValueError, match="max_retries must not be 
negative"):
+            AgentSkillsToolset(sources=["/x"], max_retries=-1)
 
     def test_for_run_propagates_optional_kwargs(self):
         # for_run hands each run its own instance; dropping a kwarg here would
         # silently expose excluded files in concurrent runs.
         toolset = AgentSkillsToolset(
-            sources=["/x"], exclude_tools={"run_skill_script"}, 
exclude_resources=["*.env"]
+            sources=["/x"], exclude_tools={"run_skill_script"}, 
exclude_resources=["*.env"], max_retries=3
         )
         per_run = asyncio.run(toolset.for_run(MagicMock()))  # noqa: spec  
(for_run ignores ctx)
         assert per_run is not toolset
         assert per_run._exclude_tools == {"run_skill_script"}
         assert per_run._exclude_resources == ["*.env"]
+        assert per_run._max_retries == 3
+
+    @pytest.mark.parametrize(
+        ("max_retries", "agent_retries", "fails"),
+        [
+            pytest.param(None, 1, True, 
id="agent_default_allows_one_correction"),
+            pytest.param(None, 3, False, id="follows_agent_retries"),
+            pytest.param(2, 1, False, id="own_budget_wins"),
+        ],
+    )
+    def test_max_retries_bounds_corrections_in_a_real_run(self, tmp_path, 
max_retries, agent_retries, fails):
+        """Two unknown resource names in a row need a budget of at least two 
corrections."""
+        _write_skill(tmp_path)
+        calls = iter(
+            [
+                {"skill_name": "demo-skill", "resource_name": "missing-1.md"},
+                {"skill_name": "demo-skill", "resource_name": "missing-2.md"},
+            ]
+        )
+
+        def model(messages, info):
+            if (args := next(calls, None)) is None:
+                return ModelResponse(parts=[TextPart("done")])
+            return ModelResponse(parts=[ToolCallPart("read_skill_resource", 
args)])
+
+        agent = Agent(
+            FunctionModel(model),
+            toolsets=[AgentSkillsToolset(sources=[str(tmp_path)], 
max_retries=max_retries)],
+            retries={"tools": agent_retries},
+        )
+
+        if fails:
+            with pytest.raises(UnexpectedModelBehavior, match="exceeded max 
retries count of 1"):
+                agent.run_sync("go")
+        else:
+            assert agent.run_sync("go").output == "done"
 
 
 class TestCleanup:

Reply via email to