Lee-W commented on code in PR #74026:
URL: https://github.com/apache/airflow/pull/74026#discussion_r4398448160


##########
providers/common/ai/provider.yaml:
##########
@@ -192,6 +192,7 @@ connection-types:
       - Heroku
       - Hugging Face
       - LiteLLM
+      - Meta

Review Comment:
   Reset `uv.lock` to main and ran a plain `uv lock`; it now only adds 
`claude-agent-sdk`. The Meta entry and the floor bump are no longer part of 
this PR, since main has since added Meta itself.
   



##########
providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py:
##########
@@ -0,0 +1,362 @@
+# 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 Claude Agent SDK.
+
+.. note:: Experimental; see :mod:`airflow.providers.common.ai.tools`.
+"""
+
+from __future__ import annotations
+
+try:
+    from claude_agent_sdk import (
+        AssistantMessage,
+        ClaudeAgentOptions,
+        McpSdkServerConfig,
+        Message,
+        ResultMessage,
+        ToolAnnotations,
+        create_sdk_mcp_server,
+        tool,
+    )
+except ImportError as e:
+    from airflow.providers.common.compat.sdk import 
AirflowOptionalProviderFeatureException
+
+    raise AirflowOptionalProviderFeatureException(e)
+
+import copy
+import logging
+from typing import TYPE_CHECKING, Any
+
+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.tool_definition import serialize_for_llm
+from airflow.providers.common.ai.utils.tool_metrics import calling_framework
+
+if TYPE_CHECKING:
+    from collections.abc import AsyncIterable, Awaitable, Callable
+
+    from airflow.providers.common.ai.tools import ToolProvider
+
+log = logging.getLogger(__name__)
+
+__all__ = ["AirflowTools"]
+
+
+_MAX_ERROR_DETAIL_CHARS = 500
+_MAX_ERROR_ENTRIES = 5
+# The bundled CLI clamps ``maxResultSizeChars`` to 500_000 (observed on CLI 
2.1.286; other
+# versions may differ), so a larger value is a no-op. With ``tools=[]`` the 
model has no Read
+# tool, so a result over the ceiling that the CLI spills to a 
``<persisted-output>`` file is lost.
+_MAX_INLINE_RESULT_CHARS = 500_000
+# The CLI writes each tool result to stdout twice, JSON-escaped, and the SDK 
fails the stream on any
+# line over ``max_buffer_size`` (1 MiB by default). Escaping can multiply a 
result by up to ~6, so the
+# buffer must sit far above ``_MAX_INLINE_RESULT_CHARS``. Sizes observed with 
the CLI bundled in
+# claude-agent-sdk 0.2.165, not a documented guarantee.
+_MAX_BUFFER_BYTES = 16 * 1024 * 1024
+
+
+def _count_utf16_units(text: str) -> int:
+    return len(text.encode("utf-16-le", errors="surrogatepass")) // 2
+
+
+def _truncate_result_text(text: str) -> str:
+    # The CLI measures the ceiling in UTF-16 code units, so a code point 
outside the BMP (most
+    # emoji) counts twice; ``len(text)`` would let such a result through and 
the CLI would spill it.
+    if _count_utf16_units(text) <= _MAX_INLINE_RESULT_CHARS:
+        return text
+    notice = (
+        f"\n\n[Truncated: this result had {len(text)} characters and only the 
beginning is shown. "
+        "Narrow the request, for example by adding filters, selecting fewer 
columns or lowering "
+        "the LIMIT, to get a smaller result.]"
+    )
+    # The notice counts toward the ceiling: text kept up to the ceiling plus 
the notice would
+    # still be spilled by the CLI.
+    keep_units = _MAX_INLINE_RESULT_CHARS - _count_utf16_units(notice)
+    units = text.encode("utf-16-le", errors="surrogatepass")[: keep_units * 2]
+    # A cut that lands between the two halves of a surrogate pair would leave 
a lone high surrogate.
+    if units and 0xD8 <= units[-1] <= 0xDB:
+        units = units[:-2]
+    return units.decode("utf-16-le", errors="surrogatepass") + notice
+
+
+def _bound_detail(text: str) -> str:
+    omitted = len(text) - _MAX_ERROR_DETAIL_CHARS
+    return text if omitted <= 0 else 
f"{text[:_MAX_ERROR_DETAIL_CHARS]}...(+{omitted} chars)"
+
+
+def _describe_error_result(result: ResultMessage) -> str:
+    errors = result.errors or []
+    omitted_errors = len(errors) - _MAX_ERROR_ENTRIES
+    fields: dict[str, Any] = {
+        "subtype": result.subtype,
+        "result": result.result,
+        "errors": errors[:_MAX_ERROR_ENTRIES],
+        "stop_reason": result.stop_reason,
+        "api_error_status": result.api_error_status,
+        "terminal_reason": result.terminal_reason,
+    }
+    details = []
+    for name, value in fields.items():
+        if not value:
+            continue
+        text = _bound_detail(repr(value)) if name in ("result", "errors") else 
repr(value)
+        if name == "errors" and omitted_errors > 0:
+            text = f"{text} (+{omitted_errors} more)"
+        details.append(f"{name}={text}")
+    return f"The Claude Agent SDK run ended with an error result ({', 
'.join(details)})."
+
+
+async def _close_stream(messages: AsyncIterable[Message]) -> None:
+    # ``AsyncIterable`` does not guarantee ``aclose``; ``query()`` and 
``receive_response()`` do.
+    aclose = getattr(messages, "aclose", None)
+    if aclose is not None:
+        await aclose()
+
+
+class _Run:
+    """
+    One run of :meth:`AirflowTools.run`.
+
+    Identifies its calls, collects their failures, and records whether it has 
ended.
+    """
+
+    def __init__(self) -> None:
+        self.failures: list[ToolCallError] = []
+        self.turn: str | None = None
+        self.ended = False
+
+
+class AirflowTools:
+    """
+    Airflow's tools for an agent loop run by the Claude Agent SDK.
+
+    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. They are 
served
+    through an in-process MCP server; add it and :attr:`allowed_tools` to
+    ``ClaudeAgentOptions``, or build them with :meth:`build_options`, and 
drive the SDK's
+    ``query()`` with :meth:`run`:
+
+    .. code-block:: python
+
+        from claude_agent_sdk import query
+
+        from airflow.providers.common.ai.tools.claude_agent_sdk import 
AirflowTools
+        from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+        tools = AirflowTools(SQLToolset(db_conn_id="warehouse", 
allowed_tables=["orders"]))
+        options = tools.build_options(model="claude-sonnet-5", max_turns=10)
+        result = await tools.run(query(prompt="...", options=options))
+
+    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 an 
``is_error``
+    tool result. A failure the model cannot fix, such as a hook raising, makes
+    :meth:`run` raise 
:class:`~airflow.providers.common.ai.tools.ToolCallError`, so the
+    task fails and Airflow retries it. The SDK's own tool dispatch catches 
every
+    exception a handler raises and turns it into an ``is_error`` result the 
model
+    reads, so drive the query with :meth:`run` rather than iterating it 
yourself.
+
+    An instance runs one query at a time: build a new ``AirflowTools`` per 
task, or
+    call :meth:`run` again once the previous run has finished. A tool call 
that arrives
+    after a run has ended is refused until the next :meth:`run` starts. A call 
that lands
+    after the next run has started cannot be told apart and is served by that 
run.
+
+    :param sources: Toolsets and tools to serve, in order.
+    :param server_name: The MCP server name the tools are served under. Also 
the
+        prefix of each tool's name in :attr:`allowed_tools`
+        (``mcp__<server_name>__<tool name>``).
+    """
+
+    def __init__(self, *sources: ToolProvider | AirflowTool, server_name: str 
= "airflow") -> None:
+        self.server_name = server_name
+        airflow_tools = collect_tools(sources)
+        self.allowed_tools: list[str] = [f"mcp__{server_name}__{t.name}" for t 
in airflow_tools]
+        # ``_run`` is the run in progress (guards re-entry); ``_last_run`` is 
the one that most
+        # recently ended (refuses late calls). It is only read while ``_run`` 
is None.
+        self._run: _Run | None = None
+        self._last_run: _Run | None = None
+        annotations = 
ToolAnnotations(maxResultSizeChars=_MAX_INLINE_RESULT_CHARS)
+        sdk_tools = [
+            tool(t.name, t.description, self._build_tool_schema(t), 
annotations=annotations)(
+                self._build_handler(t)
+            )
+            for t in airflow_tools
+        ]
+        self.server: McpSdkServerConfig = 
create_sdk_mcp_server(name=server_name, tools=sdk_tools)
+
+    def build_options(self, **kwargs: Any) -> ClaudeAgentOptions:
+        """
+        Build ``ClaudeAgentOptions`` wired to this instance's tools, with safe 
defaults.
+
+        ``ClaudeAgentOptions()`` on its own loads every settings source on the 
worker
+        (``~/.claude``, project settings, a project ``.mcp.json``) and leaves 
the CLI's
+        built-in tools on, including a worker-host ``Bash``. This method 
instead
+        defaults ``tools`` to ``[]`` (no built-in tools), ``setting_sources`` 
to ``[]``
+        (no settings files) and ``strict_mcp_config`` to ``True`` (only the 
MCP servers
+        passed here), and ``verbatim_prompts`` to ``True``. It adds 
:attr:`server` and
+        :attr:`allowed_tools` to whatever ``mcp_servers`` and 
``allowed_tools`` the caller
+        passes. A keyword this method does not default, such as ``model`` or 
``env``, is
+        passed through unchanged.
+
+        ``verbatim_prompts`` stops the CLI from expanding ``@path`` file 
mentions in a prompt;
+        otherwise anyone who controls the prompt could send a file the worker 
user can read to
+        the model, without passing the secret masker. It also turns off slash 
command dispatch
+        and the CLI's turn-start attachment pass.
+
+        When ``can_use_tool`` is passed, the Airflow tools are not added to 
``allowed_tools``,
+        because an allowed tool never reaches the callback, and 
``permission_mode`` defaults to
+        ``"default"``. The callback then decides on every Airflow tool call; 
the names are still
+        in :attr:`allowed_tools` if you want to allow some by hand.
+
+        Everything it builds can also be assembled by hand; see :attr:`server` 
and
+        :attr:`allowed_tools`.
+
+        :raises ValueError: when ``mcp_servers`` already has a server named
+            :attr:`server_name`.
+        """
+        mcp_servers = dict(kwargs.pop("mcp_servers", {}))
+        if self.server_name in mcp_servers:
+            raise ValueError(
+                f"mcp_servers already has a server named {self.server_name!r}. 
Pass a different "
+                "server_name to AirflowTools, or drop it from mcp_servers."
+            )
+        mcp_servers[self.server_name] = self.server
+        allowed_tools = list(kwargs.pop("allowed_tools", []))
+        if kwargs.get("can_use_tool") is None:
+            allowed_tools.extend(self.allowed_tools)
+        else:
+            kwargs.setdefault("permission_mode", "default")
+        kwargs.setdefault("tools", [])
+        kwargs.setdefault("setting_sources", [])
+        kwargs.setdefault("strict_mcp_config", True)
+        kwargs.setdefault("verbatim_prompts", True)
+        kwargs.setdefault("max_buffer_size", _MAX_BUFFER_BYTES)
+        return ClaudeAgentOptions(mcp_servers=mcp_servers, 
allowed_tools=allowed_tools, **kwargs)
+
+    async def run(self, messages: AsyncIterable[Message]) -> ResultMessage:
+        """
+        Drive ``messages``, the stream ``query()`` or 
``ClaudeSDKClient.receive_response()`` returns.
+
+        ``ClaudeSDKClient.receive_messages()`` is not supported: it never ends.
+
+        ``messages`` is closed when this returns or raises. For ``query()`` 
that ends the CLI
+        session, but only once the SDK finishes tearing down, so a tool call 
can still arrive
+        after this returns or raises; it is answered with an ``is_error`` "Not 
run" result.
+        A ``ClaudeSDKClient`` stays connected until you disconnect it, for 
example by leaving
+        its ``async with`` block.
+
+        :raises RuntimeError: when this instance is already running a query, 
when
+            ``messages`` ends without a 
:class:`~claude_agent_sdk.ResultMessage`, or, for
+            ``ClaudeSDKClient.receive_response()``, when the result message 
has ``is_error``
+            set (for example ``max_turns`` reached or an API failure).
+        :raises ResultError: for ``query()`` when the result message has 
``is_error`` set.
+            The CLI exits non-zero right after such a result, so the SDK raises
+            ``claude_agent_sdk.ResultError`` (a ``ProcessError``) from the 
stream before this
+            method checks the result.
+        :raises ToolCallError: when a tool failed in a way the model cannot 
fix.
+        """
+        if self._run is not None:
+            raise RuntimeError(
+                f"{self!r} is already running a query. Build a separate 
AirflowTools instance "
+                "for a concurrent run, or await the first run before starting 
another."
+            )
+        state = _Run()
+        self._run = state
+        raised = True
+        try:
+            result: ResultMessage | None = None
+            async for message in messages:
+                if isinstance(message, AssistantMessage):
+                    # The CLI sends each turn's message before running that 
turn's tools, which is
+                    # what ``handle_tool_call`` relies on. Observed with the 
bundled CLI, not documented.
+                    state.turn = message.message_id
+                if isinstance(message, ResultMessage):
+                    result = message
+                if state.failures:
+                    raise state.failures[0]
+            if result is None:
+                if state.failures:
+                    raise state.failures[0]
+                raise RuntimeError("The Claude Agent SDK stream ended without 
a ResultMessage.")
+            if result.is_error:
+                raise RuntimeError(_describe_error_result(result))

Review Comment:
   `run()` now keeps the first `is_error` result and raises on it after the 
loop, so an error turn followed by a successful one still fails.
   



##########
providers/common/ai/docs/frameworks/claude_agent_sdk.rst:
##########
@@ -0,0 +1,275 @@
+ .. 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:claude_agent_sdk:
+
+Claude Agent SDK
+=================
+
+If your agent is a loop on the `Claude Agent SDK
+<https://platform.claude.com/docs/en/api/agent-sdk/overview>`__'s ``query()``, 
which runs
+the Claude Code CLI as a subprocess, 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.claude_agent_sdk.AirflowTools` 
serves
+``SQLToolset``, ``HookToolset`` and most other toolsets to the CLI through an 
in-process
+MCP server. 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 loop in a task
+---------------------
+
+This task answers a question about a database. The client's API key comes from 
an
+Airflow connection:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_claude_agent_sdk.py
+    :language: python
+    :start-after: [START example_claude_agent_sdk]
+    :end-before: [END example_claude_agent_sdk]
+
+The model can call ``list_tables``, ``get_schema`` and ``query``, the tools 
``SQLToolset``
+serves. Each call writes a group to the task log. This is the log of one run 
against a
+temporary SQLite database, captured as the task logger wrote it, for the 
question "Inspect
+the columns of each table first with get_schema, then tell me which customer 
name has the
+highest total order amount and in which city they live." Which tools the model 
calls, in
+what order, and how long each takes depend on the model and the question:
+
+.. code-block:: text
+
+    2026-10-19T07:56:54.324694Z [info     ] ::group::Tool call: list_tables 
[airflow.providers.common.ai.tools] loc=__init__.py:116
+    2026-10-19T07:56:54.603210Z [info     ] Tool list_tables returned in 0.28s 
[airflow.providers.common.ai.tools] loc=__init__.py:130
+    2026-10-19T07:56:54.603326Z [info     ] ::endgroup::                   
[airflow.providers.common.ai.tools] loc=__init__.py:132
+    2026-10-19T07:56:56.308886Z [info     ] ::group::Tool call: get_schema 
[airflow.providers.common.ai.tools] loc=__init__.py:116
+    2026-10-19T07:56:56.309896Z [info     ] Tool get_schema returned in 0.00s 
[airflow.providers.common.ai.tools] loc=__init__.py:130
+    2026-10-19T07:56:56.310010Z [info     ] ::endgroup::                   
[airflow.providers.common.ai.tools] loc=__init__.py:132
+    2026-10-19T07:56:56.476896Z [info     ] ::group::Tool call: get_schema 
[airflow.providers.common.ai.tools] loc=__init__.py:116
+    2026-10-19T07:56:56.478181Z [info     ] Tool get_schema returned in 0.00s 
[airflow.providers.common.ai.tools] loc=__init__.py:130
+    2026-10-19T07:56:56.478343Z [info     ] ::endgroup::                   
[airflow.providers.common.ai.tools] loc=__init__.py:132
+    2026-10-19T07:56:58.700718Z [info     ] ::group::Tool call: query      
[airflow.providers.common.ai.tools] loc=__init__.py:116
+    2026-10-19T07:56:58.704926Z [info     ] Running statement: SELECT c.name, 
c.city, SUM(o.amount) AS total_amount
+    FROM customers c
+    JOIN orders o ON o.customer_id = c.id
+    GROUP BY c.id, c.name, c.city
+    ORDER BY total_amount DESC
+    LIMIT 5, parameters: None 
[airflow.task.hooks.airflow.providers.sqlite.hooks.sqlite.SqliteHook] 
loc=sql.py:896
+    2026-10-19T07:56:59.495567Z [info     ] Tool query returned in 0.79s   
[airflow.providers.common.ai.tools] loc=__init__.py:130
+    2026-10-19T07:56:59.495691Z [info     ] ::endgroup::                   
[airflow.providers.common.ai.tools] loc=__init__.py:132
+
+Only ``AirflowTools`` and ``tools.run(query(...))`` come from Airflow. 
``query()``, the
+system prompt, the model and ``max_turns`` are the SDK's own.
+:meth:`~airflow.providers.common.ai.tools.claude_agent_sdk.AirflowTools.build_options`
 builds
+``ClaudeAgentOptions`` with the Airflow tools wired in and safe defaults for 
the fields
+that matter most for this adapter; see `Built-in tools run on the worker 
host`_. You can
+also build ``ClaudeAgentOptions`` yourself and add
+:attr:`~airflow.providers.common.ai.tools.claude_agent_sdk.AirflowTools.server`
 and
+:attr:`~airflow.providers.common.ai.tools.claude_agent_sdk.AirflowTools.allowed_tools`
 to

Review Comment:
   Added a sentence: the server must be keyed by `tools.server_name` in 
`mcp_servers`, with the mismatched-key example.
   



##########
providers/common/ai/src/airflow/providers/common/ai/tools/claude_agent_sdk.py:
##########
@@ -0,0 +1,362 @@
+# 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 Claude Agent SDK.
+
+.. note:: Experimental; see :mod:`airflow.providers.common.ai.tools`.

Review Comment:
   The note now points at :ref:`howto/stability`, like the sibling adapters.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to