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 3259e8960f8 Mask tool output from every capability in Common AI agents 
(#73993)
3259e8960f8 is described below

commit 3259e8960f8e7a0a4b257fad3b6e321c2a1fffbf
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 12:39:22 2026 +0100

    Mask tool output from every capability in Common AI agents (#73993)
    
    Agents built by Common AI masked tool results only for toolsets 
AgentOperator
    could find and wrap: toolsets=, agent_params toolsets and a Toolset 
capability
    holding a toolset. Tools that arrive through any other capability (MCP,
    PrefixTools, CombinedCapability, a Toolset built per run), function tools in
    agent_params, and every tool given to LLMOperator and the other LLM 
operators
    reached the model unmasked. PydanticAIHook.create_agent now adds a
    MaskingCapability that masks around every tool call, innermost so other
    capabilities see only masked results. It also masks the retry or failure
    message pydantic-ai builds from a tool's ModelRetry or ToolFailed, and a
    ToolReturn's metadata.
    
    Masking a dataclass result now copies it and masks the copy's fields instead
    of rebuilding it with dataclasses.replace, which failed on an InitVar 
without
    a default and reset init=False fields.
    
    * Fix review findings on Common AI tool-output masking
    
    Running out of tool retries is no longer logged as the tool failing: the
    UnexpectedModelBehavior pydantic-ai raises below MaskingCapability is logged
    at debug and stripped like a retry. mask_secrets keeps a Pydantic model that
    holds no registered secret as the model, so a ToolReturn's metadata or a
    model a tool returns still has its attributes in the message history.
    
    The comment on why AgentOperator keeps toolset-level masking now names the
    real reason, toolset wrappers a capability adds below the capability hooks;
    the durable journal masks what it records itself, and its test now covers a
    leaf toolset AirflowDurability journals as well as a durable unit. The
    MaskingCapability docstring credits create_agent, shows how to add it to an
    agent built with Agent(...), and names what still sees a result before it is
    masked. The docs list output functions as not masked, and the Pydantic AI
    framework page and the capture_content option no longer contradict the
    masking create_agent now does.
    
    * Simplify Common AI tool-output masking
    
    Mask a ToolReturn and the message part inside a ToolRetryError or
    ToolFailedError through mask_secrets' own dataclass handling instead of
    listing fields by hand, which also masks a failed ToolReturnPart's
    metadata. Move the comment on why AgentOperator keeps toolset-level
    masking next to that code, and drop an operator test the hook test
    already covers.
---
 providers/common/ai/docs/agent_security.rst        |  18 +-
 providers/common/ai/docs/capabilities.rst          |   6 +-
 .../common/ai/docs/frameworks/pydantic_ai.rst      |  22 ++-
 providers/common/ai/docs/observability.rst         |  10 +-
 providers/common/ai/provider.yaml                  |   8 +-
 .../providers/common/ai/durable/fingerprint.py     |   7 +-
 .../providers/common/ai/get_provider_info.py       |   2 +-
 .../providers/common/ai/hooks/pydantic_ai.py       |  10 ++
 .../airflow/providers/common/ai/operators/agent.py |   4 +
 .../airflow/providers/common/ai/utils/masking.py   |  26 ++-
 .../providers/common/ai/utils/toolset_base.py      |  83 +++++++++-
 .../tests/unit/common/ai/hooks/test_pydantic_ai.py |  45 +++++
 .../tests/unit/common/ai/operators/test_agent.py   | 182 +++++++++++++++++++--
 .../ai/tests/unit/common/ai/utils/test_masking.py  |  51 +++++-
 .../unit/common/ai/utils/test_toolset_base.py      |  42 ++++-
 15 files changed, 459 insertions(+), 57 deletions(-)

diff --git a/providers/common/ai/docs/agent_security.rst 
b/providers/common/ai/docs/agent_security.rst
index 58a26dc17b9..9ce4a350a7f 100644
--- a/providers/common/ai/docs/agent_security.rst
+++ b/providers/common/ai/docs/agent_security.rst
@@ -80,16 +80,22 @@ No single layer is sufficient on its own. They work 
together.
      - What a tool returns, and the error text handed to the model so it can 
correct a
        call, pass through Airflow's secret masker first. A connection password 
that shows
        up in a database error or a hook's return value reaches the model, the 
model
-       provider and any trace as ``***``. ``AgentOperator`` applies this to 
the toolsets
-       you pass in ``toolsets``, in ``agent_params["toolsets"]`` and in a 
``Toolset``
-       capability, including your own. The SQL, hook, object storage, 
DataFusion, MCP,
+       provider and any trace as ``***``. Every agent this provider builds 
applies this
+       to what each tool returns or raises while it runs, however the tool 
reaches the
+       agent: ``toolsets``, ``agent_params``, or any capability, including 
your own.
+       That covers ``AgentOperator``, the other LLM operators, an agent you 
build with
+       ``PydanticAIHook.create_agent``, and one you build with ``Agent(...)`` 
and give
+       ``MaskingCapability`` (see :doc:`frameworks/pydantic_ai`). The SQL, 
hook, object storage, DataFusion, MCP,
        sandbox and managed-agent toolsets apply it wherever they run, 
including in a Pydantic AI agent
        you build yourself.
      - Masks only secrets Airflow has registered, such as connection passwords 
and
        sensitive connection extras. A credential that exists only in the data 
itself is
-       not recognized. Not masked: prompts, model output, function tools 
passed as
-       ``agent_params["tools"]``, a ``Toolset`` capability built by a 
function, a
-       framework's own tools, and MCP servers a framework connects to itself. 
In a
+       not recognized. Not masked: prompts, model output, errors raised while 
validating
+       a tool's arguments or the agent's output, what an output function 
(``ToolOutput``
+       or a function ``output_type``) returns or raises, a result a capability 
returns
+       in place of running the tool, tools the model provider runs itself 
(native ``WebSearch``,
+       ``WebFetch`` or ``ImageGeneration``, and ``MCP(native=True)``), a 
framework's own
+       tools, and MCP servers a framework connects to itself. In a
        sandbox, the model writes the commands, so it can print a secret in a 
form the
        masker does not recognize; masking there guards against accidents only.
    * - **ObjectStorageToolset: one read-only root**
diff --git a/providers/common/ai/docs/capabilities.rst 
b/providers/common/ai/docs/capabilities.rst
index 12bf47565b6..43106a7cf78 100644
--- a/providers/common/ai/docs/capabilities.rst
+++ b/providers/common/ai/docs/capabilities.rst
@@ -85,9 +85,9 @@ templated inside a ``Toolset`` capability the same way as in 
``toolsets=``:
 A ``Toolset`` capability built from a function is resolved when the run 
starts, so its
 connection IDs are not templated.
 
-Tool results from a ``Toolset`` capability are masked like those from 
``toolsets=``.
-When ``enable_tool_logging=True`` (the default), ``AgentOperator`` logs calls 
to
-these toolsets and other tools contributed by capabilities, including MCP
+Tool results from any capability whose tools run in the worker are masked like 
those
+from ``toolsets=``. When ``enable_tool_logging=True`` (the default), 
``AgentOperator``
+logs calls to these toolsets and other tools contributed by capabilities, 
including MCP
 toolsets. Output tools and provider-native tools, including native MCP, are not
 covered. See :doc:`toolsets/logging` for configuration details and the complete
 limitations. Put a toolset in the capability list when you need to order it
diff --git a/providers/common/ai/docs/frameworks/pydantic_ai.rst 
b/providers/common/ai/docs/frameworks/pydantic_ai.rst
index f88e3d30887..0ae4bd8cf98 100644
--- a/providers/common/ai/docs/frameworks/pydantic_ai.rst
+++ b/providers/common/ai/docs/frameworks/pydantic_ai.rst
@@ -42,12 +42,23 @@ connections and self-hosted endpoints as ``AgentOperator``; 
see :doc:`../model_p
 Every toolset this provider ships is a Pydantic AI toolset, so it goes into
 ``toolsets=`` as it is, next to any toolsets and tools of your own.
 
-For the OpenTelemetry spans ``AgentOperator`` emits, build the agent with the 
hook's
+For the OpenTelemetry spans and secret masking ``AgentOperator`` gets, build 
the agent
+with the hook's
 
:meth:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook.create_agent`
 instead of ``Agent(...)``. It takes the same arguments, and under ``[common.ai]
 otel_export_enabled`` it sends the agent's spans through Airflow's tracing, 
without prompt
 text unless ``capture_content`` is on; see :doc:`../observability`. Pydantic 
AI's own
-instrumentation records prompt and completion text by default.
+instrumentation records prompt and completion text by default. It also passes 
what every
+tool returns or raises through Airflow's secret masker, whichever toolset or 
capability
+supplied the tool. To get that masking on an agent you build with 
``Agent(...)``, add
+:class:`~airflow.providers.common.ai.utils.toolset_base.MaskingCapability` 
last in its
+``capabilities``:
+
+.. code-block:: python
+
+    from airflow.providers.common.ai.utils.toolset_base import 
MaskingCapability
+
+    agent = Agent(model, toolsets=[my_toolset], 
capabilities=[MaskingCapability()])
 
 What you get, and what you do not
 ---------------------------------
@@ -57,8 +68,8 @@ as they do inside ``AgentOperator``: SQL validation, 
``allowed_tables``, result
 object-storage path checks, and the secret masker on everything a tool 
returns, including
 the text of a failure the model is asked to correct. Their calls are counted 
in the
 ``common_ai.tool_calls`` metric described in :doc:`../observability`. The 
Agent Skills
-toolset is the exception: its results are masked only inside 
``AgentOperator``, and its
-calls are not counted.
+toolset is the exception: its results are masked only in an agent built with
+``create_agent`` or given ``MaskingCapability``, and its calls are not counted.
 
 ``AgentOperator`` adds things on top of the agent that a task running your own 
agent
 does not get:
@@ -66,9 +77,6 @@ does not get:
 - Durable replay of model and tool steps across task retries 
(``durable=True``).
 - Human review of the output (``enable_hitl_review``), and a pause for a 
person to
   approve marked tool calls before they run (:doc:`../tool_approval`).
-- Masking of the results of the Agent Skills toolset and of toolsets you wrote 
yourself.
-  There is no public masking wrapper, so run the agent through 
``AgentOperator`` if their
-  results can carry a secret.
 - Rendering of templated connection IDs in toolsets, such as ``SQLToolset("{{ 
... }}")``.
   In your own task, pass the connection ID itself.
 - Tool call logging, and the task's identity (``airflow.dag_id``, 
``airflow.task_id``
diff --git a/providers/common/ai/docs/observability.rst 
b/providers/common/ai/docs/observability.rst
index bd964c7dd12..b55aeb112b4 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -169,12 +169,14 @@ outputs (``gen_ai.input.messages`` / 
``gen_ai.output.messages``), set:
 
 .. warning::
 
-    With ``capture_content`` enabled, prompts, completions, and tool IO are
+    With ``capture_content`` enabled, prompts, completions, and tool arguments 
are
     exported to your tracing backend **without redaction**. Airflow's secret 
masking
     applies to logs and rendered template fields, not to OpenTelemetry span
-    attributes, so it does not scrub this content. Enable it only for debugging
-    in a trusted environment. It has no effect unless ``otel_export_enabled`` 
is
-    ``True``.
+    attributes. The one exception is what a function tool returns or raises 
while it
+    runs in an agent this provider builds, which is masked before the span 
records it.
+    An output function's result is not (see :doc:`agent_security`). Enable it 
only for
+    debugging in a trusted environment. It has no effect unless 
``otel_export_enabled``
+    is ``True``.
 
 Agents built with other frameworks
 ----------------------------------
diff --git a/providers/common/ai/provider.yaml 
b/providers/common/ai/provider.yaml
index ee1e188fc1b..fc902b7341b 100644
--- a/providers/common/ai/provider.yaml
+++ b/providers/common/ai/provider.yaml
@@ -159,9 +159,11 @@ config:
           finish reason are recorded, never message text. Turning this on 
exports
           model inputs and outputs to your tracing backend without redaction. 
Airflow's
           secret masking applies to logs and rendered template fields, not to
-          OpenTelemetry span attributes, so it does not scrub this content.
-          Enable it only for debugging in a trusted environment. Has no effect
-          unless ``otel_export_enabled`` is ``True``.
+          OpenTelemetry span attributes. The exception is what a function tool
+          returns or raises while it runs in an agent this provider builds, 
which
+          is masked before the span records it. Enable it only for debugging in
+          a trusted environment. Has no effect unless ``otel_export_enabled`` 
is
+          ``True``.
         version_added: 0.4.0
         type: boolean
         example: "False"
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py 
b/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
index a7462216d99..3082f4df64f 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/durable/fingerprint.py
@@ -156,9 +156,10 @@ def fingerprint_model_request(
         return _digest(
             {
                 "model": model_identifier,
-                # The journal records tool results with secrets masked, while 
a live run hands
-                # the model the unmasked result, so a replayed history holds 
``***`` where the
-                # live one held the secret. Masking the history here as well 
makes both match.
+                # The journal records tool results with secrets masked, and 
not everything in a
+                # live run's history is (an output function's result, for 
one), so a replayed
+                # history can hold ``***`` where the live one held the secret. 
Masking the
+                # history here as well makes both match.
                 "messages": _strip_volatile(mask_secrets(dumped)),
                 "settings": _content_settings(model_settings),
                 "params": _normalize_params(params),
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py 
b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
index 07bba3befff..3af5ce60876 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/get_provider_info.py
@@ -133,7 +133,7 @@ def get_provider_info():
                         "default": "False",
                     },
                     "capture_content": {
-                        "description": "Capture prompt, completion, and 
tool-call content on the emitted\nGenAI spans (``gen_ai.input.messages`` / 
``gen_ai.output.messages``).\n\nOff by default: only token counts, model id, 
latency, tool names, and\nfinish reason are recorded, never message text. 
Turning this on exports\nmodel inputs and outputs to your tracing backend 
without redaction. Airflow's\nsecret masking applies to logs and rendered 
template fields, not to\nOpenTelemetry span at [...]
+                        "description": "Capture prompt, completion, and 
tool-call content on the emitted\nGenAI spans (``gen_ai.input.messages`` / 
``gen_ai.output.messages``).\n\nOff by default: only token counts, model id, 
latency, tool names, and\nfinish reason are recorded, never message text. 
Turning this on exports\nmodel inputs and outputs to your tracing backend 
without redaction. Airflow's\nsecret masking applies to logs and rendered 
template fields, not to\nOpenTelemetry span at [...]
                         "version_added": "0.4.0",
                         "type": "boolean",
                         "example": "False",
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py 
b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
index f44ae25ce54..3a6a91404d8 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/pydantic_ai.py
@@ -27,6 +27,7 @@ from pydantic_ai.models.fallback import FallbackModel
 from pydantic_ai.providers import infer_provider, infer_provider_class
 
 from airflow.providers.common.ai.observability import 
genai_instrumentation_settings
+from airflow.providers.common.ai.utils.toolset_base import MaskingCapability
 from airflow.providers.common.compat.module_loading import import_string
 from airflow.providers.common.compat.sdk import BaseHook
 
@@ -611,6 +612,12 @@ class PydanticAIHook(BaseHook):
         GenAI spans through Airflow's tracing pipeline. See
         :mod:`airflow.providers.common.ai.observability`.
 
+        What each tool returns or raises while it runs passes through 
Airflow's secret
+        masker before the model sees it, whichever toolset or capability 
supplied the
+        tool: the agent gets a
+        
:class:`~airflow.providers.common.ai.utils.toolset_base.MaskingCapability` after
+        any ``capabilities`` passed in.
+
         :param output_type: The expected output type from the agent (default: 
``str``).
         :param instructions: System-level instructions for the agent.
             Required when *spec_file* is not given. When *spec_file* is given,
@@ -633,6 +640,9 @@ class PydanticAIHook(BaseHook):
         # still wins over the provider's auto-instrumentation.
         caller_instrument = agent_kwargs.pop("instrument", _UNSET)
 
+        # Last, so it is the innermost capability; see MaskingCapability.
+        agent_kwargs["capabilities"] = [*(agent_kwargs.get("capabilities") or 
()), MaskingCapability()]
+
         if spec_file is not None:
             from_file_kwargs = dict(agent_kwargs)
             model = self._get_conn_if_model_configured()
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py 
b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
index 9078c2e9f85..8bc303e6494 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/agent.py
@@ -782,6 +782,10 @@ class AgentOperator(CancellableAgentRunMixin, 
BaseOperator, HITLReviewMixin, Usa
             # pydantic-ai wraps capability hooks in list order, so merging the 
two lists
             # would pick an order the Dag author never wrote down.
             raise ValueError("Pass capabilities either as capabilities=... or 
in agent_params, not both.")
+        # create_agent adds a MaskingCapability that masks every tool call. 
The toolsets are
+        # masked here as well because toolset wrappers a capability adds 
(get_wrapper_toolset,
+        # as ToolLoggingCapability does) run below the capability hooks and 
would otherwise see
+        # the unmasked result. The durable journal masks what it records on 
its own.
         if self.toolsets:
             toolsets: list[AbstractToolset] = [ensure_masked(ts) for ts in 
self.toolsets]
             extra_kwargs["toolsets"] = toolsets
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/masking.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/masking.py
index 9fe2de9ebdc..90916a03a39 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/masking.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/masking.py
@@ -18,6 +18,7 @@
 
 from __future__ import annotations
 
+import copy
 import dataclasses
 import json
 from typing import Any, overload
@@ -46,7 +47,8 @@ def mask_secrets(value: Any) -> Any:
 
     Strings are masked wherever they sit in nested dicts, lists, tuples, sets 
and dataclasses,
     dict keys included, and the shape and types are kept. Bytes are masked as 
UTF-8 text. A
-    Pydantic model is turned into the JSON-compatible data the model would be 
shown. Images,
+    Pydantic model that holds a registered secret is turned into the 
JSON-compatible data the
+    model would be shown, with the secret masked; one that holds none is 
returned as it is. Images,
     documents and other multimodal content, and any other object, pass through 
as they are.
     Registered secrets are the ones Airflow knows about, such as connection 
passwords and
     sensitive connection extras; a credential that only appears in the data 
itself is not
@@ -78,6 +80,9 @@ def dumps_masked(value: Any, **kwargs: Any) -> str:
 def _json_default(value: object) -> Any:
     if isinstance(value, bytes):
         return value.decode("utf-8", "replace")
+    if isinstance(value, BaseModel):
+        # Only reached for a model mask_secrets kept as it was, so one holding 
no secret.
+        return to_jsonable_python(value)
     if dataclasses.is_dataclass(value) and not isinstance(value, type):
         return {field.name: getattr(value, field.name) for field in 
dataclasses.fields(value)}
     return mask_secrets(str(value))
@@ -91,7 +96,11 @@ def _mask(value: Any, seen: frozenset[int]) -> Any:
         masked = mask_secrets(text)
         return value if masked == text else masked.encode("utf-8", 
"surrogateescape")
     if isinstance(value, BaseModel):
-        return _mask(to_jsonable_python(value), seen)
+        data = to_jsonable_python(value)
+        masked = _mask(data, seen)
+        # Kept as the model when there was nothing to mask, so code that reads 
its attributes
+        # later, such as a ToolReturn's metadata in the message history, still 
can.
+        return value if masked == data else masked
     if isinstance(value, (dict, list, tuple, set, frozenset)) or 
_is_masked_dataclass(value):
         if id(value) in seen:
             return _CYCLE
@@ -105,10 +114,15 @@ def _mask(value: Any, seen: frozenset[int]) -> Any:
     if isinstance(value, (set, frozenset)):
         return type(value)(_mask(item, seen) for item in value)
     if _is_masked_dataclass(value):
-        # Rebuilt rather than turned into a dict, so a type the framework acts 
on, such as
-        # pydantic-ai's TextContent, still is one.
-        fields = [field for field in dataclasses.fields(value) if field.init]
-        return dataclasses.replace(value, **{f.name: _mask(getattr(value, 
f.name), seen) for f in fields})
+        # A copy with its fields masked, rather than a dict, so a type the 
framework acts on,
+        # such as pydantic-ai's TextContent, still is one. Not 
dataclasses.replace, which fails
+        # on an InitVar without a default and resets fields declared with 
init=False.
+        masked = copy.copy(value)
+        for field in dataclasses.fields(value):
+            if hasattr(value, field.name):
+                # object.__setattr__ so a frozen dataclass can be masked too.
+                object.__setattr__(masked, field.name, _mask(getattr(value, 
field.name), seen))
+        return masked
     return value
 
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/toolset_base.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/toolset_base.py
index 10caa772cc4..4a90d956d6c 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/toolset_base.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/toolset_base.py
@@ -19,15 +19,22 @@
 from __future__ import annotations
 
 import asyncio
-import dataclasses
 import logging
 import threading
 from abc import abstractmethod
 from dataclasses import dataclass
 from typing import TYPE_CHECKING, Any, Literal, TypeVar
 
-from pydantic_ai.exceptions import ApprovalRequired, CallDeferred, ModelRetry, 
ToolFailed
-from pydantic_ai.messages import ToolReturn
+from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
+from pydantic_ai.exceptions import (
+    ApprovalRequired,
+    CallDeferred,
+    ModelRetry,
+    ToolFailed,
+    ToolFailedError,
+    ToolRetryError,
+    UnexpectedModelBehavior,
+)
 from pydantic_ai.toolsets import DynamicToolset
 from pydantic_ai.toolsets.abstract import AbstractToolset
 from pydantic_ai.toolsets.wrapper import WrapperToolset
@@ -41,6 +48,9 @@ if TYPE_CHECKING:
     from collections.abc import Awaitable, Callable
 
     from pydantic_ai._run_context import RunContext
+    from pydantic_ai.capabilities.abstract import ValidatedToolArgs, 
WrapToolExecuteHandler
+    from pydantic_ai.messages import ToolCallPart
+    from pydantic_ai.tools import ToolDefinition
     from pydantic_ai.toolsets import ToolsetFunc
     from pydantic_ai.toolsets.abstract import ToolsetTool
 
@@ -93,6 +103,17 @@ async def _mask_call(name: str, call: Awaitable[Any], *, 
count_as: str | None =
     except (ModelRetry, ToolFailed) as e:
         log.debug("Tool %s returned an error for the model", name, exc_info=e)
         error = _strip(e)
+    except (ToolRetryError, ToolFailedError) as e:
+        # The form a ModelRetry or ToolFailed takes by the time a capability's
+        # wrap_tool_execute sees it. The model is sent the message part it 
carries, not the
+        # exception's text, so that part is what gets masked.
+        log.debug("Tool %s returned an error for the model", name, exc_info=e)
+        error = _mask_tool_error_part(e)
+    except UnexpectedModelBehavior as e:
+        # What pydantic-ai raises, below a capability's wrap_tool_execute, 
once a tool has
+        # used up its retries. The run fails with it, so it is not logged as 
the tool failing.
+        log.debug("Tool %s ran out of retries", name, exc_info=e)
+        error = _strip(e)
     except Exception as e:
         if not getattr(e, _STRIPPED, False):
             log.warning("Tool %s failed", name, exc_info=e)
@@ -103,13 +124,18 @@ async def _mask_call(name: str, call: Awaitable[Any], *, 
count_as: str | None =
     if error is not None:
         # Raised outside the except blocks, so Python does not chain the 
original back on.
         raise error
-    if isinstance(result, ToolReturn):
-        return dataclasses.replace(
-            result, return_value=mask_secrets(result.return_value), 
content=mask_secrets(result.content)
-        )
+    # A ToolReturn is masked field by field, its metadata included: not sent 
to the model,
+    # but kept in the message history and recorded in traces.
     return mask_secrets(result)
 
 
+def _mask_tool_error_part(error: ToolRetryError | ToolFailedError) -> 
ToolRetryError | ToolFailedError:
+    """Rebuild ``error`` around a masked copy of the message part it 
carries."""
+    if isinstance(error, ToolRetryError):
+        return ToolRetryError(mask_secrets(error.tool_retry))
+    return ToolFailedError(mask_secrets(error.tool_failed))
+
+
 def _strip(error: Exception) -> Exception:
     """
     Mask what ``error`` would print and drop its cause chain.
@@ -259,6 +285,49 @@ class MaskingToolset(WrapperToolset[Any]):
         return await _mask_call(name, self.wrapped.call_tool(name, tool_args, 
ctx, tool))
 
 
+@dataclass
+class MaskingCapability(AbstractCapability[Any]):
+    """
+    Mask what every tool hands the model, however the tool reached the agent.
+
+    :class:`MaskingToolset` only covers a toolset the operator can find and 
wrap. A
+    capability can supply tools the operator never sees as a toolset: ``MCP``, 
a
+    ``Toolset`` built per run, or a ``Toolset`` inside ``PrefixTools`` or a
+    ``CombinedCapability``. This masks around every tool call instead.
+
+    
:meth:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook.create_agent`
+    adds it to every agent it builds, so add it yourself only to an agent you 
build with
+    ``Agent(...)``, last in its ``capabilities``:
+
+    .. code-block:: python
+
+        agent = Agent(model, toolsets=[my_toolset], 
capabilities=[*my_capabilities, MaskingCapability()])
+
+    It declares the innermost position, and pydantic-ai breaks ties between 
capabilities
+    that declare it by list order, the last one innermost, so the agent's 
other capabilities
+    hooking tool execution see a result only after it is masked. Two kinds 
still see it
+    first: a capability passed to ``run(capabilities=...)``, which wraps 
inside the agent's
+    own, and a toolset wrapper a capability adds with ``get_wrapper_toolset``, 
which runs
+    below every capability's hooks.
+    """
+
+    def get_ordering(self) -> CapabilityOrdering:
+        """Join the innermost capabilities; list order decides which of them 
is closest to the tool."""
+        return CapabilityOrdering(position="innermost")
+
+    async def wrap_tool_execute(
+        self,
+        ctx: RunContext[Any],
+        *,
+        call: ToolCallPart,
+        tool_def: ToolDefinition,
+        args: ValidatedToolArgs,
+        handler: WrapToolExecuteHandler,
+    ) -> Any:
+        """Run the tool and mask its result, or the error it raised, before 
anything else sees it."""
+        return await _mask_call(call.tool_name, handler(args))
+
+
 def ensure_masked(toolset: AbstractToolset[Any] | ToolsetFunc[Any]) -> 
AbstractToolset[Any]:
     """
     Return ``toolset`` wrapped in :class:`MaskingToolset`, unless it already 
masks its own output.
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py 
b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
index 4b703301b87..098408e02d6 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_pydantic_ai.py
@@ -26,7 +26,9 @@ from unittest.mock import MagicMock, call, patch
 
 import pytest
 from pydantic_ai import Agent
+from pydantic_ai.capabilities import AbstractCapability
 from pydantic_ai.exceptions import FallbackExceptionGroup, ModelAPIError
+from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, 
ToolReturnPart
 from pydantic_ai.models import Model
 from pydantic_ai.models.fallback import FallbackModel
 from pydantic_ai.models.function import FunctionModel
@@ -44,6 +46,7 @@ from airflow.providers.common.ai.hooks.pydantic_ai import (
     _has_recognized_provider_prefix,
     _looks_like_unrecognized_provider_prefix,
 )
+from airflow.providers.common.ai.utils.toolset_base import MaskingCapability
 from airflow.providers.common.compat.module_loading import import_string, 
qualname
 from airflow.providers.common.compat.sdk import AirflowNotFoundException, 
BaseHook
 from airflow.providers_manager import HookInfo
@@ -1273,6 +1276,7 @@ class TestPydanticAIHookCreateAgent:
             mock_model,
             output_type=str,
             instructions="You are a helpful assistant.",
+            capabilities=[MaskingCapability()],
         )
 
     @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
@@ -1298,8 +1302,42 @@ class TestPydanticAIHookCreateAgent:
             output_type=dict,
             instructions="Be helpful.",
             retries=3,
+            capabilities=[MaskingCapability()],
         )
 
+    @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
+    @patch("airflow.providers.common.ai.hooks.pydantic_ai.Agent", 
autospec=True)
+    def test_create_agent_adds_masking_after_the_callers_capabilities(self, 
mock_agent_cls, mock_infer_model):
+        """Last, so it is innermost among capabilities that also ask to be."""
+        mock_infer_model.return_value = MagicMock(spec=Model)
+        callers = [MagicMock(spec=AbstractCapability)]
+        hook = PydanticAIHook(llm_conn_id="test_conn", 
model_id="openai:gpt-5.6-sol")
+        conn = Connection(conn_id="test_conn", conn_type="pydanticai")
+        with patch.object(hook, "get_connection", autospec=True, 
return_value=conn):
+            hook.create_agent(instructions="hi", capabilities=callers)
+
+        assert mock_agent_cls.call_args.kwargs["capabilities"] == [*callers, 
MaskingCapability()]
+        assert len(callers) == 1
+
+    @pytest.mark.enable_redact
+    def test_create_agent_masks_what_a_tool_returns(self, registered_secret):
+        """Operators other than AgentOperator pass agent_params tools straight 
to create_agent."""
+
+        def read_setting() -> str:
+            return f"api key: {registered_secret}"
+
+        def call_then_echo(messages, info):
+            returns = [p for m in messages for p in m.parts if isinstance(p, 
ToolReturnPart)]
+            if returns:
+                return 
ModelResponse(parts=[TextPart(content=str(returns[-1].content))])
+            return ModelResponse(parts=[ToolCallPart(tool_name="read_setting", 
args={}, tool_call_id="c1")])
+
+        hook = PydanticAIHook(llm_conn_id="test_conn")
+        with patch.object(hook, "get_conn", autospec=True, 
return_value=FunctionModel(call_then_echo)):
+            agent = hook.create_agent(instructions="hi", tools=[read_setting])
+
+        assert agent.run_sync("hi").output == "api key: ***"
+
     def test_create_agent_without_instructions_or_spec_file_raises(self):
         hook = PydanticAIHook(llm_conn_id="test_conn", 
model_id="openai:gpt-5.6-sol")
         with pytest.raises(ValueError, match="instructions is required"):
@@ -1321,6 +1359,7 @@ class TestPydanticAIHookCreateAgent:
             "/path/to/agent.yaml",
             model=mock_model,
             output_type=str,
+            capabilities=[MaskingCapability()],
         )
         mock_agent_cls.assert_not_called()
 
@@ -1339,6 +1378,7 @@ class TestPydanticAIHookCreateAgent:
         mock_agent_cls.from_file.assert_called_once_with(
             "/path/to/agent.yaml",
             output_type=str,
+            capabilities=[MaskingCapability()],
         )
         mock_agent_cls.assert_not_called()
 
@@ -1377,6 +1417,7 @@ class TestPydanticAIHookCreateAgent:
             spec_path,
             model=mock_model,
             output_type=str,
+            capabilities=[MaskingCapability()],
         )
 
     @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
@@ -1401,6 +1442,7 @@ class TestPydanticAIHookCreateAgent:
             model=mock_model,
             output_type=str,
             instructions="Override instructions.",
+            capabilities=[MaskingCapability()],
         )
 
     @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
@@ -1419,6 +1461,7 @@ class TestPydanticAIHookCreateAgent:
             "/path/to/agent.yaml",
             model=mock_model,
             output_type=dict,
+            capabilities=[MaskingCapability()],
         )
 
     @patch("airflow.providers.common.ai.hooks.pydantic_ai.infer_model", 
autospec=True)
@@ -1443,6 +1486,7 @@ class TestPydanticAIHookCreateAgent:
             output_type=str,
             retries=5,
             end_strategy="early",
+            capabilities=[MaskingCapability()],
         )
 
 
@@ -1493,6 +1537,7 @@ class TestPydanticAIHookCreateAgentInstrumentation:
             mock_model,
             output_type=str,
             instructions="hi",
+            capabilities=[MaskingCapability()],
         )
         assert agent.instrument is False
         mock_settings.assert_not_called()
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py 
b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
index e45611283a7..27f5cda1ba2 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_agent.py
@@ -34,18 +34,20 @@ from pydantic import BaseModel
 from pydantic_ai import Agent, DeferredToolRequests, RunContext, Tool, 
ToolOutput
 from pydantic_ai.capabilities import (
     AbstractCapability,
+    CapabilityOrdering,
     CombinedCapability,
     PrefixTools,
     Thinking,
     Toolset,
     WebSearch,
 )
-from pydantic_ai.exceptions import UsageLimitExceeded
+from pydantic_ai.exceptions import ModelRetry, ToolFailed, UsageLimitExceeded
 from pydantic_ai.messages import (
     ModelMessage,
     ModelMessagesTypeAdapter,
     ModelRequest,
     ModelResponse,
+    RetryPromptPart,
     TextPart,
     ToolCallPart,
     ToolReturnPart,
@@ -64,6 +66,7 @@ from airflow.providers.common.ai.durable import 
AirflowDurability
 from airflow.providers.common.ai.durable.base import RUN_ID_KEY, build_step_key
 from airflow.providers.common.ai.durable.journal import DurableJournal, 
DurableRun, journal_scope
 from airflow.providers.common.ai.durable.storage import DurableStorage
+from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
 from airflow.providers.common.ai.operators.agent import AgentOperator, 
HITLReviewLink
 from airflow.providers.common.ai.sandbox.base import (
     HOLDER_TAG,
@@ -3511,11 +3514,61 @@ class TestAgentOperatorDurableUsageBudgetEndToEnd:
 
 
 def _echo_tool_result(messages, info: AgentInfo) -> ModelResponse:
-    """Call ``read_setting`` once, then answer with whatever it returned."""
-    returns = [p for m in messages for p in m.parts if isinstance(p, 
ToolReturnPart)]
-    if returns:
-        return 
ModelResponse(parts=[TextPart(content=str(returns[-1].content))])
-    return ModelResponse(parts=[ToolCallPart(tool_name="read_setting", 
args={}, tool_call_id="c1")])
+    """
+    Call the first tool the agent offers once, whatever its name, then answer 
with what came back.
+
+    That is the tool's result, or the retry prompt pydantic-ai sends when the 
tool raises.
+    """
+    replies = [p for m in messages for p in m.parts if isinstance(p, 
(ToolReturnPart, RetryPromptPart))]
+    if replies:
+        return 
ModelResponse(parts=[TextPart(content=str(replies[-1].content))])
+    return ModelResponse(
+        parts=[ToolCallPart(tool_name=info.function_tools[0].name, args={}, 
tool_call_id="c1")]
+    )
+
+
[email protected]
+class _ToolsFromGetToolset(AbstractCapability):
+    """Provides tools the way ``MCP`` and other built-in capabilities do, 
through ``get_toolset``."""
+
+    toolset: FunctionToolset = 
dataclasses.field(default_factory=FunctionToolset)
+
+    def get_toolset(self):
+        return self.toolset
+
+
[email protected]
+class _RecordToolResults(AbstractCapability):
+    """A Dag author's capability that looks at every tool result, as a logging 
or guard capability would."""
+
+    seen: list = dataclasses.field(default_factory=list)
+
+    def get_ordering(self):
+        # Asks for the same spot the masking capability takes, next to the 
tool.
+        return CapabilityOrdering(position="innermost")
+
+    async def wrap_tool_execute(self, ctx, *, call, tool_def, args, handler):
+        result = await handler(args)
+        self.seen.append(result)
+        return result
+
+
+class _OwnLeafToolset(AbstractToolset):
+    """A Dag author's own toolset, which pydantic-ai's durable backend does 
not run as a durable unit."""
+
+    def __init__(self, inner: FunctionToolset):
+        self.inner = inner
+
+    @property
+    def id(self):
+        return self.inner.id
+
+    async def get_tools(self, ctx):
+        tools = await self.inner.get_tools(ctx)
+        return {name: dataclasses.replace(tool, toolset=self) for name, tool 
in tools.items()}
+
+    async def call_tool(self, name, tool_args, ctx, tool):
+        return await self.inner.call_tool(name, tool_args, ctx, 
dataclasses.replace(tool, toolset=self.inner))
 
 
 @pytest.mark.enable_redact
@@ -3523,12 +3576,17 @@ class TestAgentOperatorMasksToolOutput:
     """What any tool hands the model is masked, however the toolset reaches 
the agent."""
 
     @staticmethod
-    def _run(op: AgentOperator, storage=None) -> str:
-        hook = MagicMock(spec=["create_agent"])
-        hook.create_agent.side_effect = lambda **kw: 
Agent(FunctionModel(_echo_tool_result), **kw)
+    def _agent(op: AgentOperator) -> Agent:
+        # The real create_agent, which adds the masking capability, with a 
scripted model.
+        hook = PydanticAIHook(llm_conn_id="c")
         op.llm_hook = hook
+        with patch.object(hook, "get_conn", autospec=True, 
return_value=FunctionModel(_echo_tool_result)):
+            return op._build_agent()
+
+    def _run(self, op: AgentOperator, storage=None) -> str:
+        agent = self._agent(op)
         with journal_scope(DurableJournal(storage)) if storage is not None 
else nullcontext():
-            return op._build_agent().run_sync("hi").output
+            return agent.run_sync("hi").output
 
     @staticmethod
     def _dag_authors_toolset(secret: str) -> FunctionToolset:
@@ -3565,20 +3623,120 @@ class TestAgentOperatorMasksToolOutput:
 
         assert self._run(op) == "api key: ***"
 
-    def 
test_a_toolset_capability_is_masked_before_the_durable_journal_stores_it(self, 
registered_secret):
+    @pytest.mark.parametrize(
+        "passed_as",
+        [
+            pytest.param(lambda ts: {"toolsets": [ts]}, id="toolsets"),
+            pytest.param(lambda ts: {"capabilities": [Toolset(ts)]}, 
id="toolset-capability"),
+        ],
+    )
+    @pytest.mark.parametrize(
+        "toolset",
+        [
+            pytest.param(lambda ts: ts, id="durable-unit"),
+            pytest.param(_OwnLeafToolset, id="own-leaf"),
+        ],
+    )
+    def test_the_durable_journal_never_stores_a_registered_secret(
+        self, registered_secret, passed_as, toolset
+    ):
+        """
+        The journal masks what it records itself, whichever path journals the 
call.
+
+        It sits inside every masking layer: pydantic-ai runs a function 
toolset as a durable
+        unit, and AirflowDurability journals any other leaf toolset, both 
below the masking
+        wrapper around the toolset and the masking capability.
+        """
         storage = MemoryStorage()
         op = AgentOperator(
             task_id="t",
             prompt="hi",
             llm_conn_id="c",
             durable=True,
-            agent_params={"capabilities": 
[Toolset(self._dag_authors_toolset(registered_secret))]},
+            **passed_as(toolset(self._dag_authors_toolset(registered_secret))),
         )
 
         assert self._run(op, storage) == "api key: ***"
         recorded = [step["payload"] for step in storage.steps() if 
step["kind"] == "tool"]
         assert recorded == [{"kind": "tool_return", "result": "api key: ***"}]
 
+    @pytest.mark.parametrize(
+        "capability",
+        [
+            pytest.param(lambda ts: PrefixTools(wrapped=Toolset(ts), 
prefix="settings"), id="prefixed"),
+            pytest.param(lambda ts: CombinedCapability([Toolset(ts)]), 
id="combined"),
+            pytest.param(lambda ts: Toolset(lambda ctx: ts), 
id="toolset-built-per-run"),
+            pytest.param(lambda ts: _ToolsFromGetToolset(ts), 
id="capability-providing-tools"),
+        ],
+    )
+    def test_tools_from_any_capability(self, registered_secret, capability):
+        op = AgentOperator(
+            task_id="t",
+            prompt="hi",
+            llm_conn_id="c",
+            
capabilities=[capability(self._dag_authors_toolset(registered_secret))],
+        )
+
+        assert self._run(op) == "api key: ***"
+
+    def test_other_capabilities_only_see_the_masked_result(self, 
registered_secret):
+        seen = []
+        op = AgentOperator(
+            task_id="t",
+            prompt="hi",
+            llm_conn_id="c",
+            capabilities=[
+                _RecordToolResults(seen),
+                
_ToolsFromGetToolset(self._dag_authors_toolset(registered_secret)),
+            ],
+        )
+
+        self._run(op)
+
+        assert seen == ["api key: ***"]
+
+    @pytest.mark.parametrize(
+        ("error", "reply_is"),
+        [
+            pytest.param(ModelRetry, lambda part: isinstance(part, 
RetryPromptPart), id="model-retry"),
+            pytest.param(
+                ToolFailed,
+                lambda part: isinstance(part, ToolReturnPart) and part.outcome 
== "failed",
+                id="tool-failed",
+            ),
+        ],
+    )
+    def test_an_error_from_a_capabilitys_tool_is_masked(self, 
registered_secret, error, reply_is, caplog):
+        """
+        pydantic-ai hands a capability both as its own wrapper errors; the 
model sees the part inside.
+
+        A ModelRetry comes back to the model as a retry prompt and a 
ToolFailed as a failed tool
+        result, which does not spend the retry budget; masking must keep them 
apart. Neither is
+        the tool failing, so neither is logged as one.
+        """
+
+        def read_setting() -> str:
+            raise error(f"bad key {registered_secret}")
+
+        op = AgentOperator(
+            task_id="t",
+            prompt="hi",
+            llm_conn_id="c",
+            
capabilities=[_ToolsFromGetToolset(FunctionToolset(tools=[read_setting]))],
+        )
+
+        result = self._agent(op).run_sync("hi")
+
+        (reply,) = [
+            p
+            for m in result.all_messages()
+            for p in m.parts
+            if isinstance(p, (ToolReturnPart, RetryPromptPart))
+        ]
+        assert reply_is(reply)
+        assert reply.content == "bad key ***"
+        assert "Tool read_setting failed" not in caplog.messages
+
     def test_a_toolset_that_masks_its_own_output_is_not_wrapped_again(self):
         sql = SQLToolset("pg_default")
         op = AgentOperator(
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_masking.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_masking.py
index e6dffecaeec..5c821d5b10f 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_masking.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_masking.py
@@ -17,7 +17,7 @@
 from __future__ import annotations
 
 import json
-from dataclasses import dataclass
+from dataclasses import InitVar, dataclass, field
 
 import pytest
 from pydantic import BaseModel
@@ -55,6 +55,16 @@ class TestMaskSecrets:
 
         assert mask_secrets([Setting(value=registered_secret)]) == [{"value": 
"***"}]
 
+    def test_keeps_a_pydantic_model_that_holds_no_secret(self, 
registered_secret):
+        """Code that reads the model's attributes later, such as from the 
message history, still can."""
+
+        class Setting(BaseModel):
+            value: str
+
+        setting = Setting(value="plain")
+
+        assert mask_secrets(setting) is setting
+
     def test_leaves_other_objects_such_as_images_alone(self, 
registered_secret):
         image = BinaryContent(data=b"\x89PNG", media_type="image/png")
 
@@ -69,6 +79,37 @@ class TestMaskSecrets:
         assert mask_secrets(Credentials("svc", registered_secret)) == 
Credentials("svc", "***")
         assert mask_secrets(TextContent(content=f"key={registered_secret}")) 
== TextContent(content="key=***")
 
+    def test_masks_a_dataclass_it_could_not_construct_again(self, 
registered_secret):
+        """An InitVar has no value to pass back in, and an init=False field is 
not an argument."""
+
+        @dataclass
+        class Report:
+            rows: list
+            source: InitVar[str]
+            label: str = field(init=False, default="")
+            # Never set: a field the copy must not try to read.
+            cache: str = field(init=False)
+
+            def __post_init__(self, source: str) -> None:
+                self.label = f"from {source}"
+
+        report = Report([registered_secret], source=registered_secret)
+
+        masked = mask_secrets(report)
+
+        assert type(masked) is Report
+        assert masked.rows == ["***"]
+        assert masked.label == "from ***"
+        assert not hasattr(masked, "cache")
+        assert report.rows == [registered_secret]
+
+    def test_masks_a_slotted_dataclass(self, registered_secret):
+        @dataclass(slots=True)
+        class Row:
+            value: str
+
+        assert mask_secrets(Row(registered_secret)) == Row("***")
+
     def test_masks_bytes_as_text(self, registered_secret):
         assert mask_secrets(f"key={registered_secret}".encode()) == b"key=***"
 
@@ -115,3 +156,11 @@ class TestDumpsMasked:
         dumped = dumps_masked({"row": Row(f"pg://svc:{secret}@db"), "blob": 
f"key={secret}".encode()})
 
         assert json.loads(dumped) == {"row": {"dsn": "pg://svc:***@db"}, 
"blob": "key=***"}
+
+    def test_dumps_a_pydantic_model_that_holds_no_secret_as_its_data(self, 
registered_secret):
+        class Setting(BaseModel):
+            value: str
+
+        assert json.loads(dumps_masked({"setting": Setting(value="plain")})) 
== {
+            "setting": {"value": "plain"}
+        }
diff --git 
a/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
index df0f364ede8..fa5b339293d 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_toolset_base.py
@@ -26,7 +26,7 @@ from unittest.mock import MagicMock
 import pytest
 from pydantic_ai import Agent
 from pydantic_ai._run_context import RunContext
-from pydantic_ai.exceptions import ApprovalRequired, ModelRetry, ToolFailed
+from pydantic_ai.exceptions import ApprovalRequired, ModelRetry, ToolFailed, 
UnexpectedModelBehavior
 from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, 
ToolReturn, ToolReturnPart
 from pydantic_ai.models.function import FunctionModel
 from pydantic_ai.models.test import TestModel
@@ -34,7 +34,12 @@ from pydantic_ai.toolsets.abstract import ToolsetTool
 from pydantic_ai.toolsets.function import FunctionToolset
 from pydantic_ai.usage import RunUsage
 
-from airflow.providers.common.ai.utils.toolset_base import AirflowToolset, 
MaskingToolset, ensure_masked
+from airflow.providers.common.ai.utils.toolset_base import (
+    AirflowToolset,
+    MaskingCapability,
+    MaskingToolset,
+    ensure_masked,
+)
 
 
 class _ScriptedToolset(AirflowToolset):
@@ -146,11 +151,15 @@ class TestMasking:
     def test_masks_a_tool_return_and_keeps_it_one(self, registered_secret):
         result = _call(
             _ScriptedToolset(
-                ToolReturn(return_value=f"key={registered_secret}", 
content=f"for {registered_secret}")
+                ToolReturn(
+                    return_value=f"key={registered_secret}",
+                    content=f"for {registered_secret}",
+                    metadata={"raw": registered_secret},
+                )
             )
         )
 
-        assert result == ToolReturn(return_value="key=***", content="for ***")
+        assert result == ToolReturn(return_value="key=***", content="for ***", 
metadata={"raw": "***"})
 
     def test_an_approval_request_keeps_its_type_and_is_not_logged_as_a_failure(
         self, registered_secret, caplog
@@ -228,6 +237,31 @@ class TestMaskingToolset:
         assert asyncio.run(call()) == "api key: ***"
 
 
[email protected]_redact
+class TestMaskingCapability:
+    def test_running_out_of_tool_retries_is_not_logged_as_a_tool_failure(self, 
registered_secret, caplog):
+        """pydantic-ai raises UnexpectedModelBehavior below the capability 
once the retries are used up."""
+
+        def read_setting() -> str:
+            raise ModelRetry(f"bad key {registered_secret}")
+
+        def keep_calling(messages, info) -> ModelResponse:
+            return ModelResponse(
+                parts=[ToolCallPart(tool_name="read_setting", args={}, 
tool_call_id=f"c{len(messages)}")]
+            )
+
+        agent = Agent(
+            FunctionModel(keep_calling),
+            toolsets=[FunctionToolset([read_setting], max_retries=1)],
+            capabilities=[MaskingCapability()],
+        )
+
+        with pytest.raises(UnexpectedModelBehavior, match="exceeded max 
retries"):
+            agent.run_sync("hi")
+
+        assert "Tool read_setting failed" not in caplog.messages
+
+
 class _ApiToolset(FunctionToolset):
     """A Dag author's toolset whose one tool returns a connection string."""
 

Reply via email to