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 dd607da5ab3 Add Anthropic tool runner adapter for `common.ai` toolsets 
(#73962)
dd607da5ab3 is described below

commit dd607da5ab32b0cfda55f8c508788fba1a4a0924
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 11:56:56 2026 +0100

    Add Anthropic tool runner adapter for `common.ai` toolsets (#73962)
    
    Agents written on the Anthropic SDK's client.beta.messages.tool_runner can 
now
    use common.ai's connection-backed toolsets (SQLToolset, HookToolset,
    ObjectStorageToolset, sandboxes) through AirflowTools and AsyncAirflowTools,
    with the same guarantees as the Strands and ADK adapters: masked results,
    correctable failures returned as is_error results within the retry limit,
    and any other failure failing the task before the next model request.
    
    run_coroutine_sync now copies the caller's context into its worker thread,
    and the anthropic extra's floor moves to 1.1.0, the first release whose tool
    runner only runs a turn's tool calls when the turn ends in tool_use.
    
    * Tighten the Anthropic tool runner guide
    
    * Narrow the message block type in the Anthropic adapter tests
    
    * Mention toolset max_retries in the Anthropic tool runner guide
    
    The toolsets now take max_retries (#73957), so the guide no longer says they
    allow exactly one correction, and the retry budget docs list the Anthropic
    tool runner among the bridges that have no agent budget. The frameworks page
    also names the Anthropic SDK among the frameworks a team can keep.
    
    * Fail the Anthropic tool runner adapter's run at max_iterations mid-task
    
    A runner that reaches max_iterations while the model is still calling tools
    ends on that tool_use turn, which holds no answer. tools.run now raises
    ToolRunnerMaxIterationsError instead of returning it, so the task fails
    rather than succeeding with an empty result.
---
 providers/common/ai/docs/frameworks/anthropic.rst  | 212 ++++++++++++
 providers/common/ai/docs/frameworks/index.rst      |  23 +-
 providers/common/ai/docs/index.rst                 |   2 +-
 providers/common/ai/docs/observability.rst         |   3 +-
 providers/common/ai/docs/sandbox/index.rst         |  10 +-
 providers/common/ai/docs/stability.rst             |   3 +
 providers/common/ai/docs/toolsets/index.rst        |   6 +-
 .../common/ai/docs/toolsets/object_storage.rst     |   3 +-
 providers/common/ai/pyproject.toml                 |   8 +-
 .../example_dags/example_anthropic_tool_runner.py  |  82 +++++
 .../src/airflow/providers/common/ai/exceptions.py  |  10 +
 .../airflow/providers/common/ai/tools/__init__.py  |   4 +-
 .../airflow/providers/common/ai/tools/anthropic.py | 288 ++++++++++++++++
 .../providers/common/ai/utils/coroutines.py        |  13 +-
 .../providers/common/ai/utils/tool_metrics.py      |   2 +-
 .../ai/tests/unit/common/ai/test_exceptions.py     |   2 +
 .../tests/unit/common/ai/tools/test_anthropic.py   | 364 +++++++++++++++++++++
 .../tests/unit/common/ai/utils/test_coroutines.py  |  14 +
 .../observability/metrics/metrics_template.yaml    |   4 +-
 uv.lock                                            |   4 +-
 20 files changed, 1025 insertions(+), 32 deletions(-)

diff --git a/providers/common/ai/docs/frameworks/anthropic.rst 
b/providers/common/ai/docs/frameworks/anthropic.rst
new file mode 100644
index 00000000000..214ee136a8d
--- /dev/null
+++ b/providers/common/ai/docs/frameworks/anthropic.rst
@@ -0,0 +1,212 @@
+ .. Licensed to the Apache Software Foundation (ASF) under one
+    or more contributor license agreements.  See the NOTICE file
+    distributed with this work for additional information
+    regarding copyright ownership.  The ASF licenses this file
+    to you under the Apache License, Version 2.0 (the
+    "License"); you may not use this file except in compliance
+    with the License.  You may obtain a copy of the License at
+
+ ..   http://www.apache.org/licenses/LICENSE-2.0
+
+ .. Unless required by applicable law or agreed to in writing,
+    software distributed under the License is distributed on an
+    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+    KIND, either express or implied.  See the License for the
+    specific language governing permissions and limitations
+    under the License.
+
+.. _howto/frameworks:anthropic:
+
+Anthropic SDK tool runner
+=========================
+
+If your agent is a loop on the Anthropic Python SDK's
+``client.beta.messages.tool_runner``, you can run it in an Airflow task as it 
is and
+give it the toolsets this provider ships.
+:class:`~airflow.providers.common.ai.tools.anthropic.AirflowTools` turns
+``SQLToolset``, ``HookToolset`` and most other toolsets into tools the runner 
accepts.
+If you have no loop yet, ``AgentOperator`` runs one for you, with durable 
replay and
+human review; see :doc:`index`.
+
+.. note::
+
+    ``AirflowTools`` is experimental: it may change in a minor release of this 
provider.
+    The framework-neutral tool interface it builds on is stable.
+    See :ref:`howto/stability`.
+
+Run a tool runner in a task
+---------------------------
+
+This task answers a question about a database. The client's API key, and a 
gateway URL
+if you route through one, come from an Airflow connection:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
+    :language: python
+    :start-after: [START example_anthropic_tool_runner]
+    :end-before: [END example_anthropic_tool_runner]
+
+Against a SQLite database with one table, ``orders``, the model called two 
tools. Here
+are the task log's tool-call lines, timestamps trimmed, and the value the task 
returned:
+
+.. code-block:: text
+
+    [info] ::group::Tool call: list_tables
+    [info] Tool list_tables returned in 0.00s
+    [info] ::group::Tool call: query
+    [info] Tool query returned in 0.67s
+    [info] Done. Returned value was: The database contains **one table**:
+
+    | Table | Row count |
+    |--------|-----------|
+    | `orders` | 4 |
+
+Only ``AirflowTools`` and ``tools.run(runner)`` come from Airflow. The system 
prompt,
+model parameters and ``max_iterations`` are the runner's own, and so are tools 
you
+write with the SDK's ``@beta_tool`` decorator; pass those alongside Airflow's, 
as
+``tools=[*tools.tools, my_tool]``. ``tools.run`` fails the task only for 
Airflow's tools:
+the runner still hands an exception from one of your own tools to the model. A 
run that
+reaches ``max_iterations`` while the model is still calling tools has no 
answer, so
+``tools.run`` raises
+:class:`~airflow.providers.common.ai.exceptions.ToolRunnerMaxIterationsError` 
and the
+task fails rather than returning an empty result.
+
+To reach Claude on Amazon Bedrock, Claude Platform on AWS, Google Vertex AI or 
Microsoft
+Foundry, build the
+client with ``AnthropicHook(conn_id=...).get_conn()`` from the Anthropic 
provider
+instead. It reads the platform, region and credentials from an Anthropic 
connection,
+and every client it returns has the same ``tool_runner``.
+
+What each tool does
+-------------------
+
+``AirflowTools`` accepts any toolset that implements
+:class:`~airflow.providers.common.ai.tools.ToolProvider`, and individual
+:class:`~airflow.providers.common.ai.tools.AirflowTool` objects. 
``MCPToolset`` is the
+exception; give the runner the MCP server through the SDK instead. Each tool:
+
+- Keeps the toolset tool's name, description and argument schema.
+- Runs through the toolset, so connection resolution, SQL validation,
+  ``allowed_tables``, ``allowed_methods`` and result limits behave as they do 
in
+  :class:`~airflow.providers.common.ai.operators.agent.AgentOperator`.
+- Passes its result through Airflow's secret masker before the model sees it. A
+  connection password that turns up in a query result reaches the model as 
``***``.
+- Writes a ``Tool call: <name>`` group to the task log. Calls to a toolset are 
also
+  counted in the ``common_ai.tool_calls`` metric with ``framework=anthropic`` 
(see
+  :doc:`../observability`).
+
+When a tool fails
+-----------------
+
+A failure the model can correct, such as a query naming a table outside
+``allowed_tables``, comes back to it as a ``tool_result`` with ``is_error`` 
set. The
+model received this block when it was told to count the rows of a ``sales`` 
table that
+the toolset did not allow:
+
+.. code-block:: json
+
+    {
+      "type": "tool_result",
+      "tool_use_id": "toolu_01PNeqBojrc5S7FnNAeP1brL",
+      "content": "The query tool failed: Query references tables that are not 
in the allowed tables list: sales. Use list_tables to see the allowed 
tables.\nUse the list_tables and get_schema tools to inspect the database, then 
fix the query and try again.",
+      "is_error": true
+    }
+
+It then listed the tables, found ``orders``, and answered from it. 
``SQLToolset``,
+``HookToolset`` and ``ObjectStorageToolset`` allow one correction unless you 
pass the
+toolset ``max_retries``: a tool that fails in two model turns in a row ends 
the run with
+``ToolCallError: query kept failing after 1 correction(s): ...``. Each turn 
counts once,
+however many of its calls failed. ``SQLToolset`` treats every database error as
+correctable, so a query rejected because the connection's credentials are 
wrong ends the
+run on its second failure.
+
+Any other failure, such as a hook raising, ends the run. ``tools.run`` raises
+:class:`~airflow.providers.common.ai.tools.ToolCallError` before the next model
+request, the task fails, and Airflow's own retry takes over. Here is the error 
from a
+run where a tool's call to a billing API raised; the masker has already 
replaced the
+token in the message:
+
+.. code-block:: text
+
+    airflow.providers.common.ai.tools.ToolCallError: fetch_invoice failed: 
RuntimeError: billing API returned 503 for token ***
+
+Drive the runner with ``tools.run``
+-----------------------------------
+
+The tool runner catches every exception a tool raises and sends it to the 
model as an
+error result. If you finish the loop with the runner's own ``until_done()``, 
or iterate
+it yourself, a hook that raises becomes one more thing for the model to read, 
and the
+task can succeed with an answer written around the failure:
+
+.. code-block:: python
+
+    message = runner.until_done()  # a failing hook becomes text the model 
reads
+    message = tools.run(runner)  # a failing hook fails the task
+
+Airflow's tools log a warning when a failure goes to the model this way.
+
+``tools.run`` runs each turn's tool calls itself, which is also how the retry 
limit
+knows where one model turn ends. When a call fails in a way that ends the run, 
the
+turn's later calls are not run; the calls before it already have.
+
+``tools.run`` takes the runner that ``client.beta.messages.tool_runner`` 
returns. It
+does not support the streaming runner you get with ``stream=True``: a type 
checker
+rejects the call, and at run time it fails with an ``AttributeError`` after 
the first
+model request, before any tool runs.
+
+Async clients
+-------------
+
+For an ``AsyncAnthropic`` client, use
+:class:`~airflow.providers.common.ai.tools.anthropic.AsyncAirflowTools` and 
await
+``run``, inside an ``async def`` task or any other coroutine:
+
+.. code-block:: python
+
+    from anthropic import AsyncAnthropic
+
+    from airflow.providers.common.ai.tools.anthropic import AsyncAirflowTools
+    from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+    client = AsyncAnthropic()
+    tools = AsyncAirflowTools(SQLToolset(db_conn_id="warehouse"))
+    runner = client.beta.messages.tool_runner(
+        model="claude-opus-5-5",
+        max_tokens=16000,
+        tools=tools.tools,
+        messages=[{"role": "user", "content": "Which tables exist?"}],
+    )
+    message = await tools.run(runner)
+
+Differences from ``AgentOperator``
+----------------------------------
+
+The loop is the SDK's, so what ``AgentOperator`` builds on top of Pydantic AI 
does not
+apply:
+
+- A task retry runs the loop again from the start. Tools that change data must 
be safe
+  to repeat, because there is no step-level replay as with
+  ``AgentOperator(durable=True)``.
+- There is no human review step like 
``AgentOperator(enable_hitl_review=True)``.
+- A toolset's connection ID is used as written. It is not rendered as a 
template, so
+  pass the value rather than a Jinja expression.
+
+Installation
+------------
+
+Install this provider with its ``anthropic`` extra:
+
+.. code-block:: bash
+
+    pip install "apache-airflow-providers-common-ai[anthropic]"
+
+The ``anthropic`` extra installs the SDK. Add 
``apache-airflow-providers-anthropic`` if
+you build the client with ``AnthropicHook``. ``tool_runner`` is a beta helper 
of the
+SDK. The adapter needs ``anthropic`` 1.1.0 or later, which the extra requires,
+and is tested with 1.8.0.
+
+.. seealso::
+
+    - :doc:`../toolsets/index`, for what each toolset exposes and its limits.
+    - :doc:`index`, for the framework-neutral tool interface ``AirflowTools`` 
is built on.
+    - `Tool Runner 
<https://platform.claude.com/docs/en/agents-and-tools/tool-use/tool-runner>`__,
+      in Anthropic's documentation, for the runner itself.
diff --git a/providers/common/ai/docs/frameworks/index.rst 
b/providers/common/ai/docs/frameworks/index.rst
index 12b5b254074..62daee3fe01 100644
--- a/providers/common/ai/docs/frameworks/index.rst
+++ b/providers/common/ai/docs/frameworks/index.rst
@@ -50,8 +50,8 @@ AI because it does not tie a Dag to either:
 
 Pydantic AI is the recommendation, not a requirement. For a new agent, start 
with
 ``AgentOperator``, which gets the most from this provider. If your team has 
standardized
-on Strands, ADK or LangChain, keep that framework: the table below shows what 
this
-provider gives an agent written in each one.
+on Strands, ADK, LangChain or the Anthropic SDK, keep that framework: the 
table below
+shows what this provider gives an agent written in each one.
 
 Supported frameworks
 --------------------
@@ -91,6 +91,12 @@ is Airflow's and which part stays yours.
        ADK agent the same tools, with the same masking. Guide: :doc:`adk`.
      - You
      - ``google-adk``
+   * - Anthropic SDK tool runner
+     - :class:`~airflow.providers.common.ai.tools.anthropic.AirflowTools` 
hands the same
+       tools to ``client.beta.messages.tool_runner`` and makes a tool failure 
the model
+       cannot fix fail the task. Guide: :doc:`anthropic`.
+     - You
+     - ``[anthropic]`` extra
    * - LangChain
      - :class:`~airflow.providers.common.ai.hooks.langchain.LangChainHook` 
returns chat
        and embedding models configured from a connection, and
@@ -107,9 +113,9 @@ is Airflow's and which part stays yours.
      - No agent
      - ``[llamaindex]`` extra
 
-The Strands and ADK integrations and the tracing helper are experimental: they 
can change
-or be removed in a minor release of this provider. The framework-neutral tool 
interface
-under them is stable. See :ref:`howto/stability`.
+The Strands, ADK and Anthropic integrations and the tracing helper are 
experimental: they
+can change or be removed in a minor release of this provider. The 
framework-neutral tool
+interface under them is stable. See :ref:`howto/stability`.
 
 Tested versions
 ---------------
@@ -145,6 +151,8 @@ Start from the agent you already have:
   ``AirflowTools`` plugin. See :doc:`strands`.
 - **An existing ADK agent**: keep it, and add Airflow's toolsets with the ADK
   ``AirflowTools`` toolset. See :doc:`adk`.
+- **A loop written on the Anthropic SDK**: keep it, and pass the tools of
+  ``AirflowTools`` to its tool runner. See :doc:`anthropic`.
 - **An existing LangChain agent**: build its model with ``LangChainHook`` and 
add
   Airflow's toolsets with ``airflow_toolset_to_langchain_tools``.
 - **No agent yet, or no preference**: use ``AgentOperator`` or 
``@task.agent``. That route
@@ -162,7 +170,7 @@ Airflow also depends on, such as OpenTelemetry or a vendor 
SDK.
 
 To give such a framework Airflow's toolsets rather than hand-written tools, 
build on
 the framework-neutral interface in :mod:`airflow.providers.common.ai.tools`, 
which is
-what the Strands and ADK integrations are written against:
+what the Strands, ADK and Anthropic integrations are written against:
 
 - :class:`~airflow.providers.common.ai.tools.AirflowTool` is one operation: a 
name, a
   description, a JSON Schema for its arguments and an async function. Call it 
through
@@ -182,7 +190,7 @@ what the Strands and ADK integrations are written against:
 An adapter maps these onto the framework's own tool type and error status, and 
makes
 sure a ``ToolCallError`` ends the run rather than reaching the model, as the 
Strands
 plugin does. :func:`~airflow.providers.common.ai.tools.collect_tools` turns 
the toolsets and
-tools an adapter is given into one list, as the Strands and ADK adapters do.
+tools an adapter is given into one list, as the Strands, ADK and Anthropic 
adapters do.
 
 Blocking hook calls, such as a SQL query, run one at a time in the task's 
process, so an
 agent that calls two database tools at once gets its answers one after the 
other.
@@ -199,3 +207,4 @@ on; see :doc:`../observability`.
     Pydantic AI <pydantic_ai>
     Strands Agents <strands>
     Google ADK <adk>
+    Anthropic SDK <anthropic>
diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index f8b384d9bbf..47ec2a7e043 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -225,7 +225,7 @@ Install them when installing from PyPI. For example:
 ===============  
=======================================================================================================================================
 Extra            Dependencies
 ===============  
=======================================================================================================================================
-``anthropic``    ``pydantic-ai-slim[anthropic]>=2.36.0``, ``anthropic>=1.0.0``
+``anthropic``    ``pydantic-ai-slim[anthropic]>=2.36.0``, ``anthropic>=1.1.0``
 ``bedrock``      ``pydantic-ai-slim[bedrock]>=2.36.0``
 ``google``       ``pydantic-ai-slim[google]>=2.36.0``
 ``openai``       ``pydantic-ai-slim[openai]>=2.36.0``, ``openai>=2.47.0``
diff --git a/providers/common/ai/docs/observability.rst 
b/providers/common/ai/docs/observability.rst
index 48f4c24a56e..bd964c7dd12 100644
--- a/providers/common/ai/docs/observability.rst
+++ b/providers/common/ai/docs/observability.rst
@@ -246,7 +246,8 @@ carries three tags:
      - The toolset class, such as ``SQLToolset`` or ``ObjectStorageToolset``.
    * - ``framework``
      - ``pydantic_ai`` for ``AgentOperator`` and your own Pydantic AI agents;
-       ``strands``, ``adk`` or ``langchain`` for a call through that 
framework's adapter;
+       ``strands``, ``adk``, ``langchain`` or ``anthropic`` for a call through 
that
+       framework's adapter;
        ``none`` for a call to a toolset's ``airflow_tools()`` made without an 
adapter.
    * - ``outcome``
      - ``executed`` when the call returned, ``failed`` when it raised 
(including a
diff --git a/providers/common/ai/docs/sandbox/index.rst 
b/providers/common/ai/docs/sandbox/index.rst
index 9e58c25f0c3..ce093fe5735 100644
--- a/providers/common/ai/docs/sandbox/index.rst
+++ b/providers/common/ai/docs/sandbox/index.rst
@@ -496,11 +496,11 @@ for the other routes that build their own tools; see 
:ref:`toolset-call-barriers
 With another agent framework
 ----------------------------
 
-A Strands or Google ADK agent can use the same sandbox through ``AirflowTools``
-(see :doc:`../frameworks/index`). Outside a Pydantic AI run nothing ends the 
run for the
-toolset, so the task owns the sandbox's life: open the toolset with ``with`` 
(or
-``async with``) around the agent, and the sandbox it provisions is destroyed 
when the
-block ends, however the agent finishes:
+A Strands or Google ADK agent, or a loop on the Anthropic SDK's tool runner, 
can use the
+same sandbox through ``AirflowTools`` (see :doc:`../frameworks/index`). 
Outside a
+Pydantic AI run nothing ends the run for the toolset, so the task owns the 
sandbox's
+life: open the toolset with ``with`` (or ``async with``) around the agent, and 
the
+sandbox it provisions is destroyed when the block ends, however the agent 
finishes:
 
 .. code-block:: python
 
diff --git a/providers/common/ai/docs/stability.rst 
b/providers/common/ai/docs/stability.rst
index 7877e20b709..5b2e2a61f63 100644
--- a/providers/common/ai/docs/stability.rst
+++ b/providers/common/ai/docs/stability.rst
@@ -257,6 +257,9 @@ Everything this provider ships that is not in the table 
above is experimental.
        (:doc:`frameworks/index`)
      - Written against Strands 1.56 and ADK 2.9.1, which change often. Strands 
marks its
        own checkpointing experimental.
+   * - The Anthropic tool runner adapter (:doc:`frameworks/anthropic`)
+     - Built on ``tool_runner``, a beta helper of the Anthropic SDK that can 
change between
+       SDK releases.
    * - The ``common_ai.tool_calls`` metric and
        
:func:`~airflow.providers.common.ai.tools.tracing.agent_framework_tracing`
        (:doc:`observability`)
diff --git a/providers/common/ai/docs/toolsets/index.rst 
b/providers/common/ai/docs/toolsets/index.rst
index be80e9313c0..c69bfffddfb 100644
--- a/providers/common/ai/docs/toolsets/index.rst
+++ b/providers/common/ai/docs/toolsets/index.rst
@@ -294,9 +294,9 @@ used to allow exactly one correction whatever ``retries`` 
said, so an agent that
 ``retries`` now applies it to them too: ``retries=0`` fails the run on the 
first bad
 query, and a large integer ``retries`` meant for output validation also lets a 
failing
 database be queried that many times. Pass ``max_retries=1`` to a toolset to 
keep the old
-behaviour. Outside a pydantic-ai agent (the LangChain, Strands and Google ADK 
bridges)
-there is no agent budget, so each tool gets one correction unless its toolset 
sets
-``max_retries``.
+behaviour. Outside a pydantic-ai agent (the LangChain, Strands, Google ADK and 
Anthropic
+tool runner bridges) there is no agent budget, so each tool gets one 
correction unless its
+toolset sets ``max_retries``.
 
 Layering
 --------
diff --git a/providers/common/ai/docs/toolsets/object_storage.rst 
b/providers/common/ai/docs/toolsets/object_storage.rst
index 8a2a0b251a6..6f6c8489d19 100644
--- a/providers/common/ai/docs/toolsets/object_storage.rst
+++ b/providers/common/ai/docs/toolsets/object_storage.rst
@@ -157,6 +157,7 @@ Using it with other agent frameworks
 
 ``ObjectStorageToolset`` implements
 :class:`~airflow.providers.common.ai.tools.ToolProvider`, so the same three 
tools work in
-a Strands or Google ADK agent through ``AirflowTools``; see 
:doc:`../frameworks/index`.
+a Strands or Google ADK agent, or the Anthropic SDK's tool runner, through
+``AirflowTools``; see :doc:`../frameworks/index`.
 Outside ``AgentOperator``, ``path`` and ``conn_id`` are used as given: they 
are not
 rendered as templates.
diff --git a/providers/common/ai/pyproject.toml 
b/providers/common/ai/pyproject.toml
index 471fe221384..0b93658996e 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -83,12 +83,14 @@ dependencies = [
 # Any change in the dependencies is preserved when the file is regenerated
 [project.optional-dependencies]
 # @task.llm_batch talks to the provider batch APIs through the vendor SDKs 
directly, since
-# pydantic-ai has no batch abstraction. anthropic 1.0.0 is the first stable 
major. openai
+# pydantic-ai has no batch abstraction. anthropic 1.1.0 is the first release 
whose tool
+# runner runs a turn's tool calls only when the turn ends in tool_use, which 
the tool
+# runner adapter in airflow.providers.common.ai.tools.anthropic relies on. 
openai
 # 2.47.0 is the first release that accepts the httpx2 client 
pydantic-ai-slim>=2.33 hands it
 # (2.45 and 2.46 raise TypeError on it); pydantic-ai-slim[openai]>=2.33.0 
itself asks for
 # openai>=3, so this floor only matters where that requirement is overridden, 
as in this
 # repository's lock.
-"anthropic" = ["pydantic-ai-slim[anthropic]>=2.36.0", "anthropic>=1.0.0"]
+"anthropic" = ["pydantic-ai-slim[anthropic]>=2.36.0", "anthropic>=1.1.0"]
 "bedrock" = ["pydantic-ai-slim[bedrock]>=2.36.0"]
 "google" = ["pydantic-ai-slim[google]>=2.36.0"]
 "openai" = ["pydantic-ai-slim[openai]>=2.36.0", "openai>=2.47.0"]
@@ -184,7 +186,7 @@ dev = [
     # @task.llm_batch's OpenAI/Anthropic adapters call these SDKs directly. 
The dev group
     # needs them so the adapter tests, which build real SDK models, run 
instead of skipping.
     "openai>=2.47.0",
-    "anthropic>=1.0.0",
+    "anthropic>=1.1.0",
     "boat-sdk>=1.9.3",
     "islo>=0.3.19",
     "opensandbox>=1.1.0",
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
new file mode 100644
index 00000000000..cebaeab96ef
--- /dev/null
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
@@ -0,0 +1,82 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Answer a question about a database with the Anthropic SDK's tool runner, in an 
Airflow task.
+
+The agent loop is the Anthropic SDK's own 
``client.beta.messages.tool_runner``. Airflow
+supplies the API key, from a connection, and the tools:
+:class:`~airflow.providers.common.ai.tools.anthropic.AirflowTools` turns a 
``SQLToolset``
+into runner tools, with read-only SQL validation, bounded results and 
Airflow's secret
+masker applied to every result.
+
+Before running:
+
+1. Install this provider's ``anthropic`` extra:
+   ``pip install "apache-airflow-providers-common-ai[anthropic]"``.
+2. Create a connection (``LLM_CONN_ID``, default ``anthropic_default``) whose 
password is
+   an Anthropic API key. Set its host only to route through a gateway.
+3. Create a database connection (``DB_CONN_ID``, default ``sql_default``) 
whose hook is a
+   ``DbApiHook`` (e.g. SQLite, Postgres, MySQL).
+"""
+
+from __future__ import annotations
+
+import os
+
+from airflow.providers.common.compat.sdk import BaseHook, dag, task
+
+LLM_CONN_ID = os.environ.get("LLM_CONN_ID", "anthropic_default")
+LLM_MODEL = os.environ.get("LLM_MODEL", "claude-opus-5-5")
+DB_CONN_ID = os.environ.get("DB_CONN_ID", "sql_default")
+
+DEFAULT_QUESTION = "Which tables exist, and how many rows does each contain?"
+
+
+# [START example_anthropic_tool_runner]
+@dag(tags=["example"])
+def example_anthropic_tool_runner():
+    """Answer a question about a database with the Anthropic SDK's tool 
runner."""
+
+    @task
+    def ask_the_warehouse(question: str = DEFAULT_QUESTION) -> str:
+        from anthropic import Anthropic
+
+        from airflow.providers.common.ai.tools.anthropic import AirflowTools
+        from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+        llm = BaseHook.get_connection(LLM_CONN_ID)
+        client = Anthropic(api_key=llm.password, base_url=llm.host or None)
+        tools = AirflowTools(SQLToolset(db_conn_id=DB_CONN_ID))
+        runner = client.beta.messages.tool_runner(
+            model=LLM_MODEL,
+            max_tokens=16000,
+            system="You are a SQL analyst. Explore the schema before you 
query, and only read.",
+            tools=tools.tools,
+            messages=[{"role": "user", "content": question}],
+            # The runner has no limit of its own on model requests.
+            max_iterations=10,
+        )
+        message = tools.run(runner)
+        return "".join(block.text for block in message.content if block.type 
== "text")
+
+    ask_the_warehouse()
+
+
+# [END example_anthropic_tool_runner]
+
+
+example_anthropic_tool_runner()
diff --git a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py 
b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
index a6ecd9a1b5b..701917722ae 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/exceptions.py
@@ -23,6 +23,16 @@ class HITLMaxIterationsError(AirflowException):
     """Raised when the HITL review loop exhausts max iterations without 
approval or rejection."""
 
 
+class ToolRunnerMaxIterationsError(RuntimeError):
+    """
+    Raised when an Anthropic tool runner reaches ``max_iterations`` while the 
model is still calling tools.
+
+    The runner's last message asks for tools and holds no answer. Airflow's 
default retry
+    behaviour retries it like any other exception, and a fresh attempt may 
finish within
+    the limit.
+    """
+
+
 class ToolApprovalError(RuntimeError):
     """
     Raised when a run paused for tool approval cannot resume safely.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
index 8f82ec26f1e..2ecea9b3fe1 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/__init__.py
@@ -20,8 +20,8 @@ Framework-neutral tools backed by Airflow connections.
 An :class:`AirflowTool` is one operation an agent can call: a name, a
 description, a JSON Schema for its arguments, and an async function. Nothing in
 it belongs to a particular agent framework, so the same tool can be handed to
-Strands Agents, Google ADK or LangChain through a small adapter, while the 
agent
-itself stays native to that framework.
+Strands Agents, Google ADK, LangChain or the Anthropic SDK's tool runner 
through a
+small adapter, while the agent itself stays native to that framework.
 
 The toolsets this provider ships, such as
 :class:`~airflow.providers.common.ai.toolsets.sql.SQLToolset` and
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py 
b/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py
new file mode 100644
index 00000000000..a436441bb6a
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/tools/anthropic.py
@@ -0,0 +1,288 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Give Airflow tools to an agent loop run by the Anthropic SDK's tool runner.
+
+.. note:: Experimental; see :ref:`howto/stability`.
+"""
+
+from __future__ import annotations
+
+import copy
+import logging
+from collections.abc import Callable, Coroutine, Iterator
+from contextlib import AbstractContextManager, contextmanager
+from contextvars import ContextVar
+from typing import TYPE_CHECKING, Any, TypeVar
+
+try:
+    from anthropic.lib.tools import (
+        BetaAsyncFunctionTool,
+        BetaAsyncToolRunner,
+        BetaFunctionTool,
+        BetaToolRunner,
+        ToolError,
+        beta_async_tool,
+        beta_tool,
+    )
+except ImportError as e:
+    from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
+
+    raise AirflowOptionalProviderFeatureException(e)
+
+from airflow.providers.common.ai.exceptions import ToolRunnerMaxIterationsError
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
collect_tools
+from airflow.providers.common.ai.tools._from_toolset import tool_call_scope
+from airflow.providers.common.ai.utils.coroutines import run_coroutine_sync
+from airflow.providers.common.ai.utils.tool_definition import serialize_for_llm
+from airflow.providers.common.ai.utils.tool_metrics import calling_framework
+
+if TYPE_CHECKING:
+    from anthropic.types.beta import BetaMessage
+    from anthropic.types.beta.parsed_beta_message import ParsedBetaMessage
+
+    from airflow.providers.common.ai.tools import ToolProvider
+
+log = logging.getLogger(__name__)
+
+__all__ = ["AirflowTools", "AsyncAirflowTools"]
+
+# The ``output_format`` a runner parses its final message into.
+_OutputT = TypeVar("_OutputT")
+
+# The failures of the run in progress. ``run`` sets it, the tools append to it.
+_failures: ContextVar[list[ToolCallError] | None] = 
ContextVar("common_ai_anthropic_failures", default=None)
+
+
+class AirflowTools:
+    """
+    Airflow's tools for the tool runner of a synchronous ``Anthropic`` client.
+
+    Pass toolsets that implement
+    :class:`~airflow.providers.common.ai.tools.ToolProvider`, such as 
``SQLToolset``
+    and ``HookToolset``, or individual
+    :class:`~airflow.providers.common.ai.tools.AirflowTool` objects. Give 
:attr:`tools`
+    to ``client.beta.messages.tool_runner`` and drive the runner with 
:meth:`run`:
+
+    .. code-block:: python
+
+        from anthropic import Anthropic
+
+        from airflow.providers.common.ai.tools.anthropic import AirflowTools
+        from airflow.providers.common.ai.toolsets import SQLToolset
+
+        client = Anthropic()
+        tools = AirflowTools(SQLToolset("warehouse", 
allowed_tables=["orders"]))
+        runner = client.beta.messages.tool_runner(
+            model="claude-opus-5-5",
+            max_tokens=16000,
+            tools=tools.tools,
+            messages=[...],
+        )
+        message = tools.run(runner)
+
+    Each tool keeps the source tool's name, description and argument schema, 
and every
+    result passes through Airflow's secret masker first. A failure the model 
can
+    correct, such as a query naming a missing column, reaches it as a 
``tool_result``
+    with ``is_error: true``, counted against the tool's retry limit once per 
model
+    turn. A tool that is still failing once the limit is used up, or a failure 
the
+    model cannot fix, such as a hook raising, makes :meth:`run` raise
+    :class:`~airflow.providers.common.ai.tools.ToolCallError` before the next 
model
+    request, so the task fails and Airflow retries it. The runner on its own 
hands
+    every exception back to the model, so drive it with :meth:`run` rather than
+    ``until_done()``. A runner that reaches its ``max_iterations`` while the 
model is still
+    calling tools makes :meth:`run` raise
+    
:class:`~airflow.providers.common.ai.exceptions.ToolRunnerMaxIterationsError`, 
since
+    its last message holds no answer. A runner created with ``stream=True`` is 
not
+    supported.
+
+    For an ``AsyncAnthropic`` client, use :class:`AsyncAirflowTools`.
+
+    :param sources: Toolsets and tools to add, in order.
+    """
+
+    def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
+        self.tools: list[BetaFunctionTool[Callable[..., str]]] = [
+            _sync_tool(tool) for tool in collect_tools(sources)
+        ]
+
+    def run(self, runner: BetaToolRunner[_OutputT]) -> 
ParsedBetaMessage[_OutputT]:
+        """
+        Drive ``runner`` to its final message.
+
+        :raises ToolCallError: when a tool failed in a way the model cannot 
fix.
+        :raises ToolRunnerMaxIterationsError: when the runner reached 
``max_iterations``
+            while the model was still calling tools.
+        """
+        with _run() as turn:
+            for message in runner:
+                with turn(message.id):
+                    if _runs_tools(message):
+                        runner.generate_tool_call_response()
+        return _answer(runner.until_done())
+
+
+class AsyncAirflowTools:
+    """
+    Airflow's tools for the tool runner of an ``AsyncAnthropic`` client.
+
+    The asynchronous counterpart of :class:`AirflowTools`, with the same 
behaviour:
+
+    .. code-block:: python
+
+        tools = AsyncAirflowTools(SQLToolset("warehouse", 
allowed_tables=["orders"]))
+        runner = AsyncAnthropic().beta.messages.tool_runner(
+            model="claude-opus-5-5",
+            max_tokens=16000,
+            tools=tools.tools,
+            messages=[...],
+        )
+        message = await tools.run(runner)
+
+    :param sources: Toolsets and tools to add, in order.
+    """
+
+    def __init__(self, *sources: ToolProvider | AirflowTool) -> None:
+        self.tools: list[BetaAsyncFunctionTool[Callable[..., Coroutine[Any, 
Any, str]]]] = [
+            _async_tool(tool) for tool in collect_tools(sources)
+        ]
+
+    async def run(self, runner: BetaAsyncToolRunner[_OutputT]) -> 
ParsedBetaMessage[_OutputT]:
+        """
+        Drive ``runner`` to its final message.
+
+        :raises ToolCallError: when a tool failed in a way the model cannot 
fix.
+        :raises ToolRunnerMaxIterationsError: when the runner reached 
``max_iterations``
+            while the model was still calling tools.
+        """
+        with _run() as turn:
+            async for message in runner:
+                with turn(message.id):
+                    if _runs_tools(message):
+                        await runner.generate_tool_call_response()
+        return _answer(await runner.until_done())
+
+
+def _runs_tools(message: BetaMessage) -> bool:
+    """
+    Whether the runner will run this turn's tool calls, which it does only for 
``tool_use``.
+
+    A turn cut off at ``max_tokens`` can hold a tool call with incomplete 
arguments, and a
+    ``pause_turn`` or ``compaction`` turn is sent back to the server 
unchanged; the runner
+    runs no tool calls for either, so neither may this.
+    """
+    return message.stop_reason == "tool_use"
+
+
+def _answer(message: ParsedBetaMessage[_OutputT]) -> 
ParsedBetaMessage[_OutputT]:
+    """
+    Return the runner's final message, unless the runner stopped mid-task.
+
+    The runner ends after a ``tool_use`` turn only when it reaches 
``max_iterations``, and
+    that turn holds tool calls rather than an answer.
+    """
+    if _runs_tools(message):
+        raise ToolRunnerMaxIterationsError(
+            f"The tool runner reached max_iterations while the model was still 
calling tools "
+            f"(last turn {message.id}). Raise max_iterations, or give the 
model a narrower task."
+        )
+    return message
+
+
+@contextmanager
+def _run() -> Iterator[Callable[[str], AbstractContextManager[None]]]:
+    """
+    Scope one run of the runner, and yield the context that runs one model 
turn's tool calls.
+
+    The runner caches the tool results it builds and sends them with the next 
model
+    request, so building them in the turn's context lets the tools count 
retries per turn
+    and lets a failure end the run before that request.
+    """
+    run = object()
+    failures: list[ToolCallError] = []
+    token = _failures.set(failures)
+
+    def raise_first_failure() -> None:
+        if failures:
+            raise failures[0]
+
+    @contextmanager
+    def turn(message_id: str) -> Iterator[None]:
+        # Checked on the way in too: should the runner ever run tools outside 
a turn, their
+        # failure ends the run at the next turn rather than once the runner 
stops.
+        raise_first_failure()
+        with tool_call_scope(run=run, turn=message_id):
+            yield
+        raise_first_failure()
+
+    try:
+        yield turn
+        raise_first_failure()
+    finally:
+        _failures.reset(token)
+
+
+async def _call(tool: AirflowTool, arguments: dict[str, Any]) -> str:
+    failures = _failures.get()
+    if failures:
+        # The run ends once this turn's calls are done, so start no more of 
them.
+        raise ToolError("Not run: an earlier tool call in this turn failed.")
+    try:
+        with calling_framework("anthropic"):
+            result = await tool.call(arguments)
+    except ToolCallError as e:
+        if failures is None:
+            log.warning(
+                "The tool runner was not driven by AirflowTools.run, so it 
hands this failure to "
+                "the model instead of failing the task: %s",
+                e,
+            )
+            raise
+        failures.append(e)
+        # ``run`` raises the original once the turn is done. The runner logs 
any other
+        # exception with its traceback, so this keeps the task log to one 
account of it.
+        raise ToolError(str(e)) from None
+    # Masks what a plain JSON dump would render with str(), such as a 
dataclass holding a secret.
+    text = serialize_for_llm(result.content)
+    if result.is_error:
+        raise ToolError(text)
+    return text
+
+
+def _sync_tool(tool: AirflowTool) -> BetaFunctionTool[Callable[..., str]]:
+    def call_airflow_tool(**arguments: Any) -> str:
+        return run_coroutine_sync(_call(tool, arguments))
+
+    # The source schema can be a toolset's module-level constant, so hand over 
a copy.
+    return beta_tool(
+        call_airflow_tool,
+        name=tool.name,
+        description=tool.description,
+        input_schema=copy.deepcopy(tool.parameters),
+    )
+
+
+def _async_tool(tool: AirflowTool) -> BetaAsyncFunctionTool[Callable[..., 
Coroutine[Any, Any, str]]]:
+    async def call_airflow_tool(**arguments: Any) -> str:
+        return await _call(tool, arguments)
+
+    return beta_async_tool(
+        call_airflow_tool,
+        name=tool.name,
+        description=tool.description,
+        input_schema=copy.deepcopy(tool.parameters),
+    )
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/coroutines.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/coroutines.py
index 7ab97199be1..66b1cc27b55 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/coroutines.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/coroutines.py
@@ -20,6 +20,7 @@ from __future__ import annotations
 
 import asyncio
 import concurrent.futures
+import contextvars
 from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
@@ -32,11 +33,15 @@ def run_coroutine_sync(coroutine: Coroutine[Any, Any, Any]) 
-> Any:
 
     When no event loop is running in this thread the coroutine is driven with
     :func:`asyncio.run`. When one is (an async caller), it runs on a worker 
thread
-    with its own loop, so the caller's loop is never nested.
+    with its own loop, so the caller's loop is never nested. Either way it 
sees the
+    caller's context variables.
     """
     try:
         asyncio.get_running_loop()
     except RuntimeError:
-        return asyncio.run(coroutine)
-    with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
-        return pool.submit(asyncio.run, coroutine).result()
+        pass
+    else:
+        with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
+            return pool.submit(contextvars.copy_context().run, asyncio.run, 
coroutine).result()
+    # Outside the except block, so what the coroutine raises is not chained to 
the RuntimeError.
+    return asyncio.run(coroutine)
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/utils/tool_metrics.py 
b/providers/common/ai/src/airflow/providers/common/ai/utils/tool_metrics.py
index e2caf09afc5..8f1c663cc37 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/tool_metrics.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/tool_metrics.py
@@ -28,7 +28,7 @@ if TYPE_CHECKING:
     from collections.abc import Iterator
 
 #: The agent framework a tool call came through, as the ``framework`` tag 
reports it.
-Framework = Literal["pydantic_ai", "strands", "adk", "langchain", "none"]
+Framework = Literal["pydantic_ai", "strands", "adk", "langchain", "anthropic", 
"none"]
 
 # Set by each framework adapter around the calls it makes.
 _framework: ContextVar[Framework | None] = 
ContextVar("common_ai_tool_framework", default=None)
diff --git a/providers/common/ai/tests/unit/common/ai/test_exceptions.py 
b/providers/common/ai/tests/unit/common/ai/test_exceptions.py
index b7b95d4cbec..c6dfa6d06b2 100644
--- a/providers/common/ai/tests/unit/common/ai/test_exceptions.py
+++ b/providers/common/ai/tests/unit/common/ai/test_exceptions.py
@@ -25,6 +25,7 @@ from airflow.providers.common.ai.exceptions import (
     LLMFileAnalysisMultimodalRequiredError,
     LLMFileAnalysisUnsupportedFormatError,
     ManagedAgentInvocationError,
+    ToolRunnerMaxIterationsError,
 )
 from airflow.providers.common.compat.sdk import AirflowException
 
@@ -35,6 +36,7 @@ EXCEPTION_CASES = [
     (LLMFileAnalysisLimitExceededError, LLMFileAnalysisError),
     (LLMFileAnalysisMultimodalRequiredError, 
LLMFileAnalysisUnsupportedFormatError),
     (ManagedAgentInvocationError, RuntimeError),
+    (ToolRunnerMaxIterationsError, RuntimeError),
 ]
 
 
diff --git a/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py 
b/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py
new file mode 100644
index 00000000000..b8a2d736f6d
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/tools/test_anthropic.py
@@ -0,0 +1,364 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import copy
+import json
+from typing import TYPE_CHECKING, Any
+from unittest.mock import MagicMock, patch
+from uuid import uuid4
+
+import pytest
+
+pytest.importorskip("anthropic")
+
+import httpx2
+from anthropic import Anthropic, AsyncAnthropic, DefaultAsyncHttpxClient, 
DefaultHttpxClient
+from anthropic.types.beta import BetaTextBlock
+from pydantic_ai.exceptions import ModelRetry
+
+from airflow.providers.common.ai.exceptions import ToolRunnerMaxIterationsError
+from airflow.providers.common.ai.tools import AirflowTool, ToolCallError, 
ToolResult
+from airflow.providers.common.ai.tools.anthropic import AirflowTools, 
AsyncAirflowTools
+from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+from unit.common.ai.tools.test__from_toolset import _scripted
+from unit.common.ai.toolsets.test_sql import _make_mock_db_hook
+
+if TYPE_CHECKING:
+    from anthropic.lib.tools import BetaToolRunner
+    from anthropic.types.beta.parsed_beta_message import ParsedBetaMessage
+
+
+class _ScriptedModel:
+    """
+    Stands in for the Messages API: asks for ``tool_name`` until it has 
``calls`` results,
+    then answers with the last result it got. Keeps every request body it 
receives.
+
+    With a ``stop_reason`` other than ``tool_use``, the first turn asks for 
the tool but ends
+    with that reason, and any later turn answers without asking again. 
``per_turn`` is how
+    many calls each asking turn makes at once.
+    """
+
+    def __init__(
+        self,
+        tool_name: str,
+        tool_input: dict[str, Any],
+        *,
+        calls: int = 1,
+        stop_reason: str = "tool_use",
+        per_turn: int = 1,
+    ) -> None:
+        self.tool_name = tool_name
+        self.tool_input = tool_input
+        self.calls = calls
+        self.stop_reason = stop_reason
+        self.per_turn = per_turn
+        self.requests: list[dict[str, Any]] = []
+        # Unique across instances, since the retry limit tells turns apart by 
message id.
+        self._prefix = uuid4().hex[:8]
+
+    def results(self) -> list[dict[str, Any]]:
+        """Every ``tool_result`` block sent to the model, in order."""
+        messages = self.requests[-1]["messages"] if self.requests else []
+        return [
+            block
+            for message in messages
+            if message["role"] == "user" and isinstance(message["content"], 
list)
+            for block in message["content"]
+            if block.get("type") == "tool_result"
+        ]
+
+    def __call__(self, request: httpx2.Request) -> httpx2.Response:
+        self.requests.append(json.loads(request.content))
+        turn = len(self.requests)
+        results = self.results()
+        if len(results) < self.calls and (turn == 1 or self.stop_reason == 
"tool_use"):
+            content = [
+                {
+                    "type": "tool_use",
+                    "id": f"toolu_{self._prefix}_{turn}_{i}",
+                    "name": self.tool_name,
+                    "input": self.tool_input,
+                }
+                for i in range(self.per_turn)
+            ]
+            stop_reason = self.stop_reason
+        else:
+            content = [{"type": "text", "text": json.dumps(results[-1] if 
results else None)}]
+            stop_reason = "end_turn"
+        return httpx2.Response(
+            200,
+            json={
+                "id": f"msg_{self._prefix}_{turn}",
+                "type": "message",
+                "role": "assistant",
+                "model": "claude-test",
+                "content": content,
+                "stop_reason": stop_reason,
+                "stop_sequence": None,
+                "usage": {"input_tokens": 1, "output_tokens": 1},
+            },
+        )
+
+
+def _sync_runner(model: _ScriptedModel, tools: AirflowTools, **runner_options: 
Any) -> BetaToolRunner:
+    client = Anthropic(api_key="test", 
http_client=DefaultHttpxClient(transport=httpx2.MockTransport(model)))
+    return client.beta.messages.tool_runner(
+        model="claude-test",
+        max_tokens=100,
+        tools=tools.tools,
+        messages=[{"role": "user", "content": "go"}],
+        **runner_options,
+    )
+
+
+def _drive_sync(model: _ScriptedModel, tools: AirflowTools, **runner_options: 
Any) -> ParsedBetaMessage:
+    return tools.run(_sync_runner(model, tools, **runner_options))
+
+
+def _drive_async(model: _ScriptedModel, tools: AsyncAirflowTools, 
**runner_options: Any) -> ParsedBetaMessage:
+    async def run() -> ParsedBetaMessage:
+        client = AsyncAnthropic(
+            api_key="test", 
http_client=DefaultAsyncHttpxClient(transport=httpx2.MockTransport(model))
+        )
+        runner = client.beta.messages.tool_runner(
+            model="claude-test",
+            max_tokens=100,
+            tools=tools.tools,
+            messages=[{"role": "user", "content": "go"}],
+            **runner_options,
+        )
+        return await tools.run(runner)
+
+    return asyncio.run(run())
+
+
+def _answer(message: ParsedBetaMessage) -> Any:
+    """The tool result the scripted model echoed back as its answer."""
+    block = message.content[0]
+    assert isinstance(block, BetaTextBlock)
+    return json.loads(block.text)
+
+
[email protected](
+    params=[
+        pytest.param((AirflowTools, _drive_sync), id="sync"),
+        pytest.param((AsyncAirflowTools, _drive_async), id="async"),
+    ]
+)
+def flavour(request):
+    """The adapter class and the function that drives a runner with it."""
+    return request.param
+
+
[email protected]
+def run_agent(flavour):
+    tools_class, drive = flavour
+
+    def run(model: _ScriptedModel, *sources, **runner_options: Any) -> 
ParsedBetaMessage:
+        return drive(model, tools_class(*sources), **runner_options)
+
+    return run
+
+
+def _tool(result: ToolResult | Exception, ran: list[dict[str, Any]] | None = 
None) -> AirflowTool:
+    """A ``lookup`` tool that returns or raises ``result``, recording its 
calls in ``ran``."""
+
+    async def function(arguments: dict[str, Any]) -> ToolResult:
+        if ran is not None:
+            ran.append(arguments)
+        if isinstance(result, Exception):
+            raise result
+        return result
+
+    return AirflowTool(
+        name="lookup",
+        description="Look something up.",
+        parameters={"type": "object", "properties": {"key": {"type": 
"string"}}, "required": ["key"]},
+        function=function,
+    )
+
+
[email protected]("tools_class", [AirflowTools, AsyncAirflowTools])
+def test_each_toolset_tool_becomes_a_runner_tool_with_its_schema(tools_class):
+    tools = tools_class(_tool(ToolResult("ok")), SQLToolset("pg_default"))
+
+    definitions = [tool.to_dict() for tool in tools.tools]
+
+    assert [d["name"] for d in definitions] == ["lookup", "list_tables", 
"get_schema", "query", "check_query"]
+    assert definitions[3]["input_schema"]["required"] == ["sql"]
+
+
[email protected]("tools_class", [AirflowTools, AsyncAirflowTools])
+def test_a_runner_tool_does_not_share_the_source_schema(tools_class):
+    """A toolset's schema can be a module-level constant that the pydantic-ai 
path also uses."""
+    source = _tool(ToolResult("ok"))
+    before = copy.deepcopy(source.parameters)
+
+    
tools_class(source).tools[0].to_dict()["input_schema"]["properties"]["key"]["type"]
 = "integer"
+
+    assert source.parameters == before
+
+
+class TestRun:
+    def test_the_model_reads_a_toolset_result(self, run_agent):
+        ts = SQLToolset("pg_default")
+        ts._hook = _make_mock_db_hook(records=[(1, "Ada")], 
last_description=[("id",), ("name",)])
+        model = _ScriptedModel("query", {"sql": "SELECT id, name FROM users"})
+
+        result = _answer(run_agent(model, ts))
+
+        assert "is_error" not in result
+        assert json.loads(result["content"])["rows"] == [[1, "Ada"]]
+
+    def test_a_correctable_failure_reaches_the_model_as_an_error(self, 
run_agent):
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        result = _answer(run_agent(model, _tool(ToolResult("Unknown column 
'nme'", is_error=True))))
+
+        assert result["is_error"] is True
+        assert "Unknown column 'nme'" in json.dumps(result["content"])
+
+    def 
test_any_other_failure_fails_the_run_before_the_next_model_request(self, 
run_agent, caplog):
+        """The runner alone would hand the exception to the model and ask it 
again."""
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        with pytest.raises(ToolCallError, match="PermissionError: role cannot 
read orders"):
+            run_agent(model, _tool(PermissionError("role cannot read orders")))
+
+        assert len(model.requests) == 1
+        # The runner logs the exceptions it catches; this one reaches the task 
log once, as the failure.
+        assert "Error occurred while calling tool" not in caplog.text
+
+    def 
test_a_failure_goes_to_the_model_with_a_warning_when_run_is_not_used(self, 
caplog):
+        model = _ScriptedModel("lookup", {"key": "a"})
+        runner = _sync_runner(model, AirflowTools(_tool(PermissionError("role 
cannot read orders"))))
+
+        runner.until_done()
+
+        assert model.results()[0]["is_error"] is True
+        assert "not driven by AirflowTools.run" in caplog.text
+
+    def 
test_a_tool_that_keeps_failing_fails_the_run_once_its_retries_are_spent(self, 
run_agent):
+        model = _ScriptedModel("step", {}, calls=5)
+        step = _scripted(ModelRetry("no such key"), ModelRetry("no such key"), 
max_retries=1)
+
+        with pytest.raises(ToolCallError, match="kept failing after 1 
correction"):
+            run_agent(model, step)
+
+        # One failure the model may correct, then the second ends the run.
+        assert len(model.requests) == 2
+
+    @pytest.mark.parametrize(
+        ("stop_reason", "model_requests"),
+        [
+            pytest.param("max_tokens", 1, id="cut-off-turn-ends-the-run"),
+            pytest.param("pause_turn", 2, id="paused-turn-is-resumed"),
+        ],
+    )
+    def test_a_turn_that_did_not_end_in_tool_use_runs_no_tools(self, 
run_agent, stop_reason, model_requests):
+        """The runner itself only runs a turn's tool calls when it ended in 
``tool_use``."""
+        ran: list[dict[str, Any]] = []
+        model = _ScriptedModel("lookup", {"key": "a"}, stop_reason=stop_reason)
+
+        run_agent(model, _tool(ToolResult("ran"), ran))
+
+        assert ran == []
+        assert len(model.requests) == model_requests
+
+    def test_a_run_that_reaches_max_iterations_while_calling_tools_fails(self, 
run_agent):
+        """The runner's last message is then a turn asking for tools, with no 
answer in it."""
+        model = _ScriptedModel("lookup", {"key": "a"}, calls=5)
+
+        with pytest.raises(ToolRunnerMaxIterationsError, match="reached 
max_iterations"):
+            run_agent(model, _tool(ToolResult("ran")), max_iterations=2)
+
+        assert len(model.requests) == 2
+
+    def test_a_run_that_answers_on_its_last_iteration_returns_the_answer(self, 
run_agent):
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        result = _answer(run_agent(model, _tool(ToolResult("ran")), 
max_iterations=2))
+
+        assert result["content"] == "ran"
+
+    def test_calls_after_a_failure_in_the_same_turn_do_not_run(self, 
run_agent):
+        """The turn's results are never sent, so a later call's side effects 
would be for nothing."""
+        ran: list[dict[str, Any]] = []
+        model = _ScriptedModel("lookup", {"key": "a"}, per_turn=3)
+
+        with pytest.raises(ToolCallError, match="PermissionError"):
+            run_agent(model, _tool(PermissionError("role cannot read orders"), 
ran))
+
+        assert len(ran) == 1
+
+    def 
test_the_sync_tools_fail_the_run_when_called_under_a_running_event_loop(self):
+        """The sync tools run on a worker thread there, which must still see 
the run's state."""
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        async def caller() -> None:
+            _drive_sync(model, AirflowTools(_tool(PermissionError("role cannot 
read orders"))))
+
+        with pytest.raises(ToolCallError, match="PermissionError"):
+            asyncio.run(caller())
+
+        assert len(model.requests) == 1
+
+    def test_a_structured_result_reaches_the_model_as_json(self, run_agent):
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        result = _answer(run_agent(model, _tool(ToolResult({"rows": [[1, 
None]]}))))
+
+        assert json.loads(result["content"]) == {"rows": [[1, None]]}
+
+    def test_failed_calls_in_one_turn_count_once_against_the_retry_limit(self, 
run_agent):
+        model = _ScriptedModel("step", {}, calls=2, per_turn=2)
+
+        run_agent(model, _scripted(ModelRetry("no such key"), ModelRetry("no 
such key"), max_retries=1))
+
+        assert [r.get("is_error") for r in model.results()] == [True, True]
+
+    def 
test_each_run_of_a_reused_instance_starts_with_a_fresh_retry_budget(self, 
flavour):
+        tools_class, drive = flavour
+        tools = tools_class(_scripted(ModelRetry("no such key"), 
ModelRetry("no such key"), max_retries=1))
+
+        first, second = _ScriptedModel("step", {}), _ScriptedModel("step", {})
+        drive(first, tools)
+        drive(second, tools)
+
+        assert second.results()[0]["is_error"] is True
+
+    @pytest.mark.enable_redact
+    def test_a_secret_in_a_result_reaches_the_model_masked(self, run_agent, 
registered_secret):
+        model = _ScriptedModel("lookup", {"key": "a"})
+
+        run_agent(model, _tool(ToolResult(f"key={registered_secret}")))
+
+        sent = json.dumps(model.requests)
+        assert registered_secret not in sent
+        assert "key=***" in sent
+
+    def test_the_toolsets_calls_are_counted_as_anthropic(self, run_agent):
+        ts = SQLToolset("pg_default")
+        ts._hook = _make_mock_db_hook()
+
+        with patch("airflow.providers.common.ai.utils.tool_metrics.Stats", 
MagicMock(spec=["incr"])) as stats:
+            run_agent(_ScriptedModel("list_tables", {}), ts)
+
+        assert stats.incr.call_args.kwargs["tags"]["framework"] == "anthropic"
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_coroutines.py 
b/providers/common/ai/tests/unit/common/ai/utils/test_coroutines.py
index d4a467f56a0..f96cbecd691 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_coroutines.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_coroutines.py
@@ -18,14 +18,21 @@ from __future__ import annotations
 
 import asyncio
 import threading
+from contextvars import ContextVar
 
 from airflow.providers.common.ai.utils.coroutines import run_coroutine_sync
 
+_value: ContextVar[str] = ContextVar("value", default="unset")
+
 
 async def _thread_name() -> str:
     return threading.current_thread().name
 
 
+async def _read_value() -> str:
+    return _value.get()
+
+
 class TestRunCoroutineSync:
     def test_runs_on_the_calling_thread_when_no_loop_is_running(self):
         assert run_coroutine_sync(_thread_name()) == 
threading.current_thread().name
@@ -35,3 +42,10 @@ class TestRunCoroutineSync:
             return run_coroutine_sync(_thread_name())
 
         assert asyncio.run(caller()) != threading.current_thread().name
+
+    def test_sees_the_callers_context_variables_from_a_running_loop(self):
+        async def caller() -> str:
+            _value.set("caller")
+            return run_coroutine_sync(_read_value())
+
+        assert asyncio.run(caller()) == "caller"
diff --git 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
index 4782f69b7d7..428ae4e3e35 100644
--- 
a/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
+++ 
b/shared/observability/src/airflow_shared/observability/metrics/metrics_template.yaml
@@ -428,8 +428,8 @@ metrics:
 
   - name: "common_ai.tool_calls"
     description: "Number of calls to a toolset of the common.ai provider. 
Metric with toolset
-    (the toolset class), framework (``pydantic_ai``, ``strands``, ``adk``, 
``langchain`` or
-    ``none``) and outcome (``executed``, ``failed`` or ``replayed``) tagging."
+    (the toolset class), framework (``pydantic_ai``, ``strands``, ``adk``, 
``langchain``,
+    ``anthropic`` or ``none``) and outcome (``executed``, ``failed`` or 
``replayed``) tagging."
     type: "counter"
     legacy_name: "-"
     name_variables: ["toolset", "framework", "outcome"]
diff --git a/uv.lock b/uv.lock
index 5d262af491d..61072416bf8 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4673,7 +4673,7 @@ docs = [
 
 [package.metadata]
 requires-dist = [
-    { name = "anthropic", marker = "extra == 'anthropic'", specifier = 
">=1.0.0" },
+    { name = "anthropic", marker = "extra == 'anthropic'", specifier = 
">=1.1.0" },
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
     { name = "apache-airflow-providers-common-sql", marker = "extra == 
'common-sql'", editable = "providers/common/sql" },
@@ -4715,7 +4715,7 @@ provides-extras = ["anthropic", "bedrock", "google", 
"openai", "typesafe", "mcp"
 
 [package.metadata.requires-dev]
 dev = [
-    { name = "anthropic", specifier = ">=1.0.0" },
+    { name = "anthropic", specifier = ">=1.1.0" },
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-devel-common", editable = "devel-common" },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },

Reply via email to