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 06e7e284e32 Log capability tool calls in AgentOperator (#72202)
06e7e284e32 is described below
commit 06e7e284e323fd8e7787b54e62aa38db3e236db3
Author: Jeff(Wei-Hao) Lu <[email protected]>
AuthorDate: Mon Oct 12 09:10:31 2026 +0900
Log capability tool calls in AgentOperator (#72202)
---
docs/spelling_wordlist.txt | 4 +
providers/common/ai/docs/capabilities.rst | 10 +-
providers/common/ai/docs/operators/agent.rst | 11 +-
providers/common/ai/docs/toolsets/logging.rst | 30 ++-
.../airflow/providers/common/ai/operators/agent.py | 44 +++--
.../providers/common/ai/toolsets/logging.py | 51 ++++-
.../airflow/providers/common/ai/utils/logging.py | 11 --
.../tests/unit/common/ai/decorators/test_agent.py | 12 +-
.../tests/unit/common/ai/operators/test_agent.py | 219 +++++++++++++++++++--
.../tests/unit/common/ai/toolsets/test_logging.py | 135 ++++++++++++-
.../ai/tests/unit/common/ai/utils/test_logging.py | 17 --
11 files changed, 459 insertions(+), 85 deletions(-)
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index c9ec654ce71..d7e3d0cdfe2 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -288,6 +288,7 @@ Colour
colour
colours
ColumnLineageDatasetFacet
+CombinedCapability
CommandType
comparator
compat
@@ -678,6 +679,7 @@ Filesystem
filesystem
filesystems
filetype
+FilteredToolset
finalStatus
fips
firebase
@@ -1332,6 +1334,7 @@ preflight
prek
Preload
preload
+PreparedToolset
prepend
prepended
prepends
@@ -1971,6 +1974,7 @@ winrm
WIT
workgroup
workspaces
+WrapperToolset
writeable
wsman
WTF
diff --git a/providers/common/ai/docs/capabilities.rst
b/providers/common/ai/docs/capabilities.rst
index 0284396dff6..1a2b76e9959 100644
--- a/providers/common/ai/docs/capabilities.rst
+++ b/providers/common/ai/docs/capabilities.rst
@@ -85,9 +85,13 @@ 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=``, but
-``enable_tool_logging`` only logs calls to ``toolsets=``. Pass a toolset in
``toolsets=`` unless
-you need it inside the capability list, for example to order it against a
guardrail.
+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
+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
+against another capability, such as a guardrail.
With durable execution
----------------------
diff --git a/providers/common/ai/docs/operators/agent.rst
b/providers/common/ai/docs/operators/agent.rst
index 1f03bf00d0b..08f01e4f475 100644
--- a/providers/common/ai/docs/operators/agent.rst
+++ b/providers/common/ai/docs/operators/agent.rst
@@ -373,9 +373,12 @@ Parameters
``AgentSkillsToolset`` for :ref:`agent-skills`, etc.).
- ``capabilities``: List of pydantic-ai capabilities (``Thinking``,
``WebSearch``, guardrails,
etc.). See :ref:`capabilities`.
-- ``enable_tool_logging``: Wrap each toolset in
+- ``enable_tool_logging``: Wrap the assembled function toolset in
:class:`~airflow.providers.common.ai.toolsets.logging.LoggingToolset` so that
- every tool call is logged in real time. Default ``True``.
+ tools supplied through ``toolsets=``, ``agent_params={"tools": [...]}``, and
+ capabilities are logged in real time. Output tools, provider-native tools,
and
+ tools another capability adds through its own wrapper (code mode's
``run_code``,
+ ToolSearch's ``search_tools``) are not covered. Default ``True``.
- ``agent_params``: Additional keyword arguments passed to the pydantic-ai
``Agent`` constructor (e.g. ``retries``, ``model_settings``).
- .. _agent-usage-budget:
@@ -458,8 +461,8 @@ Logging
-------
All AI operators automatically log a post-run summary after ``run_sync()``
-completes. ``AgentOperator`` additionally wraps toolsets for real-time
-per-tool-call logging (controlled by ``enable_tool_logging``).
+completes. ``AgentOperator`` additionally wraps the assembled function toolset
+for real-time per-tool-call logging (controlled by ``enable_tool_logging``).
**Real-time tool call logging** (AgentOperator only): each tool call is
logged as it happens:
diff --git a/providers/common/ai/docs/toolsets/logging.rst
b/providers/common/ai/docs/toolsets/logging.rst
index 76e300bdbe4..6cba50b98b5 100644
--- a/providers/common/ai/docs/toolsets/logging.rst
+++ b/providers/common/ai/docs/toolsets/logging.rst
@@ -26,8 +26,23 @@ Tool call logging: ``LoggingToolset``
:class:`~airflow.providers.common.ai.toolsets.logging.LoggingToolset` is a
``WrapperToolset`` that intercepts ``call_tool()`` to log each tool invocation
in real time. ``AgentOperator`` applies it automatically (see
-``enable_tool_logging``), but you can also use it directly with any pydantic-ai
-``Agent``:
+``enable_tool_logging``) through
+:class:`~airflow.providers.common.ai.toolsets.logging.ToolLoggingCapability`.
+Applying the wrapper as a capability means logging covers the assembled
+function toolset, including tools supplied through ``toolsets=``,
+``agent_params={"tools": [...]}``, and capabilities such as factory-backed
+toolsets, nested capabilities, and MCP toolsets. Output tools such as
+``final_result`` and provider-native tools, including native MCP, are not
+covered by Airflow's real-time tool-call logging. Tools added by another
+capability through its own wrapper toolset, such as ToolSearch's
+``search_tools`` and CodeMode's ``run_code``, are also not covered.
+
+``AgentOperator`` adds ``ToolLoggingCapability`` automatically when
+``enable_tool_logging=True``. If ``capabilities=`` already contains one,
+including inside a combined or wrapper capability, the operator does not add
+another, so the logger on the supplied instance is used.
+
+You can also use ``LoggingToolset`` directly with any pydantic-ai ``Agent``:
.. code-block:: python
@@ -36,7 +51,10 @@ in real time. ``AgentOperator`` applies it automatically (see
logged_toolset = LoggingToolset(wrapped=SQLToolset(db_conn_id="my_db"))
-Each call logs the tool's name and how long it took at INFO, inside a
collapsible
-``::group::`` block in the task log, and its arguments at DEBUG. A call that
raises
-is logged with its traceback and the exception is re-raised. Pass ``logger`` to
-send the lines to a logger other than the toolset module's own.
+Each tool call logs its name and timing at INFO, inside a collapsible
+``::group::`` block in the task log, and its arguments at DEBUG. Control-flow
+signals that let the model retry, report a failed tool result, defer or skip a
+call, or wait for approval are logged at INFO and re-raised. Other exceptions,
+including ``SkipToolValidation`` raised by a tool body, are logged at ERROR
+with a traceback and re-raised. Pass ``logger`` to send the lines to a logger
+other than the toolset module's own.
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 f856756c05e..cc073097bb2 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
@@ -51,13 +51,13 @@ from airflow.providers.common.ai.observability import (
make_task_instance_run_key,
stamp_identity_on_agent_spans,
)
+from airflow.providers.common.ai.toolsets.logging import ToolLoggingCapability
from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
from airflow.providers.common.ai.utils.logging import (
MODEL_NAME_XCOM_KEY,
format_usage_for_xcom,
log_run_summary,
log_run_usage,
- wrap_toolsets_for_logging,
)
from airflow.providers.common.ai.utils.output_type import
rehydrate_pydantic_output
from airflow.providers.common.ai.utils.prompt_cache import PromptCaching
@@ -168,24 +168,19 @@ def _replace_capability_toolset(
return capability
-def _contains_code_mode(capabilities: Iterable[AgentCapability[Any]]) -> bool:
+def _contains_capability(
+ capabilities: Iterable[AgentCapability[Any]], capability_type:
type[AbstractCapability[Any]]
+) -> bool:
"""
- Whether any capability, or one nested inside a combined or wrapper
capability, is ``CodeMode``.
+ Whether any capability, or one nested inside a combined or wrapper
capability, has the given type.
A capability function, or a ``DynamicCapability``, builds its capability
when the run
starts, so there is nothing to inspect here.
"""
- # CodeMode's own module is in sys.modules once CodeMode has been imported,
and only then.
- # The pydantic_ai_harness package root is not a safe place to look: it
exports CodeMode
- # through a module __getattr__ that imports that module, which fails
without the
- # ``code-mode`` extra.
- code_mode_cls = getattr(sys.modules.get("pydantic_ai_harness.code_mode"),
"CodeMode", None)
- if code_mode_cls is None:
- return False
pending = [capability for capability in capabilities if
isinstance(capability, AbstractCapability)]
while pending:
capability = pending.pop()
- if isinstance(capability, code_mode_cls):
+ if isinstance(capability, capability_type):
return True
if isinstance(capability, WrapperCapability):
# apply() does not visit a wrapper's single wrapped capability,
only a combined one's children.
@@ -197,6 +192,16 @@ def _contains_code_mode(capabilities:
Iterable[AgentCapability[Any]]) -> bool:
return False
+def _contains_code_mode(capabilities: Iterable[AgentCapability[Any]]) -> bool:
+ """Whether the declared capabilities contain ``CodeMode``."""
+ # CodeMode's own module is in sys.modules once CodeMode has been imported,
and only then.
+ # The pydantic_ai_harness package root is not a safe place to look: it
exports CodeMode
+ # through a module __getattr__ that imports that module, which fails
without the
+ # ``code-mode`` extra.
+ code_mode_cls = getattr(sys.modules.get("pydantic_ai_harness.code_mode"),
"CodeMode", None)
+ return code_mode_cls is not None and _contains_capability(capabilities,
code_mode_cls)
+
+
def _declares_agent_template_fields(toolset: Any) -> bool:
"""Whether *toolset*, or a toolset it wraps or combines, has connection
IDs to render."""
return isinstance(toolset, AbstractToolset) and any(
@@ -269,9 +274,14 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin, Usa
``agent_params`` still works, but stores each capability's repr
in the serialized Dag, and cannot be combined with this argument (the
task fails when it runs).
- :param enable_tool_logging: When ``True`` (default), wraps each toolset in
a
- ``LoggingToolset`` that logs tool calls with timing at INFO level and
- arguments at DEBUG level. Set to ``False`` to disable.
+ :param enable_tool_logging: When ``True`` (default), wraps the agent's
+ assembled function toolset in a ``LoggingToolset`` that logs tool calls
+ with timing at INFO level and arguments at DEBUG level. This includes
+ tools supplied through ``toolsets=``, ``agent_params["tools"]``, and
+ capabilities, but not output tools, provider-native tools that run
+ server-side, or tools another capability adds through its own wrapper
+ (code mode's ``run_code``, ToolSearch's ``search_tools``). Set to
+ ``False`` to disable.
:param agent_params: Additional keyword arguments passed to the pydantic-ai
``Agent`` constructor (e.g. ``retries``, ``model_settings``).
:param usage_limits: Optional pydantic-ai
@@ -712,8 +722,6 @@ class AgentOperator(CancellableAgentRunMixin, BaseOperator,
HITLReviewMixin, Usa
toolsets: list[AbstractToolset] = [ensure_masked(ts) for ts in
self.toolsets]
if self.durable and storage is not None and counter is not None:
toolsets = self._build_durable_toolsets(toolsets, storage,
counter)
- if self.enable_tool_logging:
- toolsets = wrap_toolsets_for_logging(toolsets, self.log)
extra_kwargs["toolsets"] = toolsets
elif extra_kwargs.get("toolsets"):
extra_kwargs["toolsets"] = [ensure_masked(ts) for ts in
extra_kwargs["toolsets"]]
@@ -728,6 +736,10 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin, Usa
capabilities = self._build_durable_capabilities(capabilities,
storage, counter)
if self.cache_prompt:
capabilities.append(PromptCaching())
+ if self.enable_tool_logging and not _contains_capability(capabilities,
ToolLoggingCapability):
+ # ToolLoggingCapability's innermost ordering keeps logging inside
capability wrappers,
+ # including CodeModeToolset where code mode expects the wrapped
tools.
+ capabilities.append(ToolLoggingCapability(logger=self.log))
if capabilities:
extra_kwargs["capabilities"] = capabilities
return self.llm_hook.create_agent(
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/logging.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/logging.py
index 805f7cf32e2..4708d685d8b 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/logging.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/logging.py
@@ -18,17 +18,25 @@
from __future__ import annotations
+import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
-from pydantic_ai.exceptions import ApprovalRequired
+from pydantic_ai.capabilities import AbstractCapability, CapabilityOrdering
+from pydantic_ai.exceptions import (
+ ApprovalRequired,
+ CallDeferred,
+ ModelRetry,
+ SkipToolExecution,
+ ToolFailed,
+)
from pydantic_ai.toolsets.wrapper import WrapperToolset
if TYPE_CHECKING:
- from pydantic_ai.toolsets.abstract import ToolsetTool
+ from pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetTool
from airflow.sdk.types import Logger
@@ -54,8 +62,12 @@ class LoggingToolset(WrapperToolset[Any]):
tool: ToolsetTool[Any],
) -> Any:
self.logger.info("::group::Tool call: %s", name)
- if tool_args:
- self.logger.debug("Tool args: %s", json.dumps(tool_args,
default=str))
+ if tool_args and self.logger.isEnabledFor(logging.DEBUG):
+ try:
+ serialized_args = json.dumps(tool_args, default=str)
+ except (TypeError, ValueError):
+ serialized_args = repr(tool_args)
+ self.logger.debug("Tool args: %s", serialized_args)
start = time.monotonic()
try:
result = await self.wrapped.call_tool(name, tool_args, ctx, tool)
@@ -63,13 +75,40 @@ class LoggingToolset(WrapperToolset[Any]):
self.logger.info("Tool %s returned in %.2fs", name, elapsed)
self.logger.info("::endgroup::")
return result
+ except asyncio.CancelledError:
+ self.logger.info("::endgroup::")
+ raise
except ApprovalRequired:
# Not a failure: the run pauses here until a person approves or
rejects the call.
- self.logger.info("Tool %s is waiting to be approved", name)
+ elapsed = time.monotonic() - start
+ self.logger.info("Tool %s is waiting to be approved after %.2fs",
name, elapsed)
+ self.logger.info("::endgroup::")
+ raise
+ except (ModelRetry, ToolFailed, CallDeferred, SkipToolExecution) as e:
+ elapsed = time.monotonic() - start
+ self.logger.info("Tool %s requested %s after %.2fs: %s", name,
type(e).__name__, elapsed, e)
self.logger.info("::endgroup::")
raise
except Exception:
elapsed = time.monotonic() - start
- self.logger.exception("Tool %s failed after %.2fs", name, elapsed)
self.logger.info("::endgroup::")
+ self.logger.exception("Tool %s failed after %.2fs", name, elapsed)
raise
+
+
+@dataclass
+class ToolLoggingCapability(AbstractCapability[Any]):
+ """Apply tool-call logging to the assembled function toolset for an agent
run."""
+
+ logger: Logger | logging.Logger = field(default_factory=lambda:
logging.getLogger(__name__))
+
+ @classmethod
+ def get_serialization_name(cls) -> str | None:
+ return None
+
+ def get_ordering(self) -> CapabilityOrdering:
+ """Place logging innermost so outer capability wrappers delegate
base-tool calls through it."""
+ return CapabilityOrdering(position="innermost")
+
+ def get_wrapper_toolset(self, toolset: AbstractToolset[Any]) ->
AbstractToolset[Any]:
+ return LoggingToolset(wrapped=toolset, logger=self.logger)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/logging.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/logging.py
index 57b1380705e..7cd0ecaafff 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/logging.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/logging.py
@@ -24,11 +24,8 @@ from typing import TYPE_CHECKING, Any
from pydantic import BaseModel
from pydantic_ai.messages import ToolCallPart
-from airflow.providers.common.ai.toolsets.logging import LoggingToolset
-
if TYPE_CHECKING:
from pydantic_ai.result import AgentRunResult
- from pydantic_ai.toolsets.abstract import AbstractToolset
from pydantic_ai.usage import RunUsage
from airflow.sdk.types import Logger
@@ -137,11 +134,3 @@ def _extract_tool_sequence(result: AgentRunResult[Any]) ->
list[str]:
for part in getattr(message, "parts", [])
if isinstance(part, ToolCallPart)
]
-
-
-def wrap_toolsets_for_logging(
- toolsets: list[AbstractToolset[Any]],
- logger: Logger | logging.Logger,
-) -> list[AbstractToolset[Any]]:
- """Wrap each toolset in a LoggingToolset."""
- return [LoggingToolset(wrapped=ts, logger=logger) for ts in toolsets]
diff --git a/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
b/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
index fffdd51a15f..a1c4a5330fa 100644
--- a/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
+++ b/providers/common/ai/tests/unit/common/ai/decorators/test_agent.py
@@ -25,7 +25,7 @@ from pydantic_ai.messages import ImageUrl
from pydantic_ai.toolsets.function import FunctionToolset
from airflow.providers.common.ai.decorators.agent import
_AgentDecoratedOperator
-from airflow.providers.common.ai.toolsets.logging import LoggingToolset
+from airflow.providers.common.ai.toolsets.logging import ToolLoggingCapability
from airflow.providers.common.ai.utils.prompt_cache import PromptCaching
from airflow.providers.common.ai.utils.toolset_base import MaskingToolset
@@ -176,10 +176,9 @@ class TestAgentDecoratedOperator:
op.execute(context=_make_context())
create_call =
mock_hook_cls.get_hook.return_value.create_agent.call_args
- passed_toolsets = create_call[1]["toolsets"]
- assert len(passed_toolsets) == 1
- assert isinstance(passed_toolsets[0], LoggingToolset)
- assert passed_toolsets[0].wrapped == MaskingToolset(wrapped=toolset)
+ assert create_call[1]["toolsets"] == [MaskingToolset(wrapped=toolset)]
+ assert create_call[1]["capabilities"][0] == PromptCaching()
+ assert isinstance(create_call[1]["capabilities"][1],
ToolLoggingCapability)
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_execute_passes_capabilities_through(self, mock_hook_cls,
make_mock_run_result):
@@ -197,7 +196,8 @@ class TestAgentDecoratedOperator:
op.execute(context=_make_context())
create_call =
mock_hook_cls.get_hook.return_value.create_agent.call_args
- assert create_call.kwargs["capabilities"] == [thinking,
PromptCaching()]
+ assert create_call.kwargs["capabilities"][:2] == [thinking,
PromptCaching()]
+ assert isinstance(create_call.kwargs["capabilities"][2],
ToolLoggingCapability)
@requires_typed_xcom
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
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 2b4b2bd3bbe..4f09990822c 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
@@ -16,6 +16,7 @@
# under the License.
from __future__ import annotations
+import asyncio
import dataclasses
import json
import logging
@@ -26,6 +27,7 @@ from decimal import Decimal
from types import ModuleType, SimpleNamespace
from typing import Any
from unittest.mock import ANY, MagicMock, PropertyMock, call, patch
+from uuid import UUID
import pytest
from pydantic import BaseModel
@@ -73,7 +75,7 @@ from airflow.providers.common.ai.sandbox.base import (
SandboxExecResult,
)
from airflow.providers.common.ai.toolsets.hook import HookToolset
-from airflow.providers.common.ai.toolsets.logging import LoggingToolset
+from airflow.providers.common.ai.toolsets.logging import ToolLoggingCapability
from airflow.providers.common.ai.toolsets.mcp import MCPToolset
from airflow.providers.common.ai.toolsets.sandbox import SandboxToolset
from airflow.providers.common.ai.toolsets.sql import SQLToolset
@@ -757,9 +759,12 @@ class TestAgentOperatorExecute:
)
# On 3.3+ the agent may also end on a tool call awaiting approval.
expected_output_type = [str, DeferredToolRequests] if
AIRFLOW_V_3_3_PLUS else str
-
mock_hook_cls.get_hook.return_value.create_agent.assert_called_once_with(
- output_type=expected_output_type, instructions="You are helpful.",
capabilities=[PromptCaching()]
- )
+ create_agent_kwargs =
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs
+ assert create_agent_kwargs["output_type"] == expected_output_type
+ assert create_agent_kwargs["instructions"] == "You are helpful."
+ assert len(create_agent_kwargs["capabilities"]) == 2
+ assert create_agent_kwargs["capabilities"][0] == PromptCaching()
+ assert isinstance(create_agent_kwargs["capabilities"][1],
ToolLoggingCapability)
mock_agent.run_sync.assert_called_once_with(
"What is the answer?", usage_limits=None, run_id="ti-1",
cancellation_token=ANY, usage=ANY
)
@@ -795,11 +800,9 @@ class TestAgentOperatorExecute:
op.execute(context=MagicMock())
create_call =
mock_hook_cls.get_hook.return_value.create_agent.call_args
- passed_toolsets = create_call[1]["toolsets"]
- assert len(passed_toolsets) == 1
- assert isinstance(passed_toolsets[0], LoggingToolset)
- assert isinstance(passed_toolsets[0].wrapped, MaskingToolset)
- assert passed_toolsets[0].wrapped.wrapped is mock_toolset
+ assert create_call[1]["toolsets"] ==
[MaskingToolset(wrapped=mock_toolset)]
+ assert create_call[1]["capabilities"][0] == PromptCaching()
+ assert isinstance(create_call[1]["capabilities"][1],
ToolLoggingCapability)
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_enable_tool_logging_false_skips_wrapping(self, mock_hook_cls,
make_mock_run_result):
@@ -820,6 +823,126 @@ class TestAgentOperatorExecute:
create_call =
mock_hook_cls.get_hook.return_value.create_agent.call_args
assert create_call[1]["toolsets"] ==
[MaskingToolset(wrapped=mock_toolset)]
+ assert create_call[1]["capabilities"] == [PromptCaching()]
+
+ @pytest.mark.parametrize("tool_source", ["tools", "agent_params",
"capabilities"])
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_tool_logging_wraps_tools_from_all_sources(self, mock_hook_cls,
caplog, tool_source):
+ """Tool logging wraps the complete toolset assembled by pydantic-ai."""
+
+ def my_tool() -> str:
+ return "tool-result"
+
+ def model_fn(messages, info):
+ saw_return = any(isinstance(p, ToolReturnPart) for m in messages
for p in getattr(m, "parts", []))
+ if saw_return:
+ return ModelResponse(parts=[TextPart(content="done")])
+ return ModelResponse(parts=[ToolCallPart(tool_name="my_tool",
args={}, tool_call_id="c1")])
+
+ mock_hook_cls.get_hook.return_value.create_agent.side_effect = lambda
**kw: Agent(
+ FunctionModel(model_fn), **kw
+ )
+ if tool_source == "tools":
+ kwargs = {"agent_params": {"tools": [my_tool]}}
+ else:
+ capability = Toolset(FunctionToolset([my_tool]))
+ kwargs = (
+ {"agent_params": {"capabilities": [capability]}}
+ if tool_source == "agent_params"
+ else {"capabilities": [capability]}
+ )
+ op = AgentOperator(
+ task_id="test",
+ prompt="Do something",
+ llm_conn_id="my_llm",
+ **kwargs,
+ )
+
+ with caplog.at_level(logging.INFO):
+ result = op.execute(context=_make_context())
+
+ assert result == "done"
+ assert any(record.message == "::group::Tool call: my_tool" for record
in caplog.records)
+
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_tool_logging_closes_groups_when_parallel_tool_fails(self,
mock_hook_cls, caplog):
+ slow_cancelled = False
+
+ async def boom() -> str:
+ await asyncio.sleep(0)
+ raise RuntimeError("boom")
+
+ async def slow() -> None:
+ nonlocal slow_cancelled
+ try:
+ await asyncio.sleep(60)
+ except asyncio.CancelledError:
+ slow_cancelled = True
+ raise
+
+ def model_fn(messages, info):
+ return ModelResponse(
+ parts=[
+ ToolCallPart(tool_name="boom", args={},
tool_call_id="c-boom"),
+ ToolCallPart(tool_name="slow", args={},
tool_call_id="c-slow"),
+ ]
+ )
+
+ mock_hook_cls.get_hook.return_value.create_agent.side_effect = lambda
**kw: Agent(
+ FunctionModel(model_fn), **kw
+ )
+ op = AgentOperator(
+ task_id="test",
+ prompt="Do something",
+ llm_conn_id="my_llm",
+ agent_params={"tools": [boom, slow]},
+ )
+
+ with caplog.at_level(logging.INFO):
+ with pytest.raises(RuntimeError, match="boom"):
+ op.execute(context=_make_context())
+
+ assert slow_cancelled
+ messages = [record.message for record in caplog.records]
+ assert sum(message.startswith("::group::Tool call:") for message in
messages) == 2
+ assert messages.count("::endgroup::") == 2
+
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_tool_logging_handles_non_json_mapping_keys(self, mock_hook_cls,
caplog):
+ def my_tool(values: dict[UUID, int]) -> int:
+ return sum(values.values())
+
+ def model_fn(messages, info):
+ saw_return = any(isinstance(p, ToolReturnPart) for m in messages
for p in getattr(m, "parts", []))
+ if saw_return:
+ return ModelResponse(parts=[TextPart(content="done")])
+ return ModelResponse(
+ parts=[
+ ToolCallPart(
+ tool_name="my_tool",
+ args={"values":
{"12345678-1234-5678-1234-567812345678": 1}},
+ tool_call_id="c1",
+ )
+ ]
+ )
+
+ mock_hook_cls.get_hook.return_value.create_agent.side_effect = lambda
**kw: Agent(
+ FunctionModel(model_fn), **kw
+ )
+ op = AgentOperator(
+ task_id="test",
+ prompt="Do something",
+ llm_conn_id="my_llm",
+ agent_params={"tools": [my_tool]},
+ )
+
+ with caplog.at_level(logging.DEBUG, logger="airflow.task"):
+ result = op.execute(context=_make_context())
+
+ assert result == "done"
+ assert any(
+ "UUID('12345678-1234-5678-1234-567812345678')" in record.message
for record in caplog.records
+ )
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_execute_passes_agent_params(self, mock_hook_cls,
make_mock_run_result):
@@ -853,6 +976,7 @@ class TestAgentOperatorExecute:
llm_conn_id="my_llm",
toolsets=[MagicMock(spec=AbstractToolset)],
cache_prompt=False,
+ enable_tool_logging=False,
)
op.execute(context=_make_context())
@@ -867,7 +991,11 @@ class TestAgentOperatorExecute:
)
op = AgentOperator(
- task_id="t", prompt="hi", llm_conn_id="my_llm",
agent_params={"capabilities": ["existing"]}
+ task_id="t",
+ prompt="hi",
+ llm_conn_id="my_llm",
+ agent_params={"capabilities": ["existing"]},
+ enable_tool_logging=False,
)
op.execute(context=_make_context())
@@ -1191,12 +1319,51 @@ class TestAgentOperatorCapabilities:
)
thinking, search = Thinking(effort="high"), WebSearch()
- op = AgentOperator(task_id="t", prompt="p", llm_conn_id="llm",
capabilities=[thinking, search])
+ op = AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="llm",
+ capabilities=[thinking, search],
+ enable_tool_logging=False,
+ )
op.execute(context=_make_context())
create_call =
mock_hook_cls.get_hook.return_value.create_agent.call_args
assert create_call.kwargs["capabilities"] == [thinking, search,
PromptCaching()]
+ @pytest.mark.parametrize(
+ "wrap",
+ [
+ pytest.param(lambda capability: capability, id="direct"),
+ pytest.param(lambda capability: CombinedCapability([Thinking(),
capability]), id="combined"),
+ pytest.param(lambda capability: PrefixTools(wrapped=capability,
prefix="custom"), id="wrapped"),
+ ],
+ )
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_declared_tool_logging_capability_prevents_default(
+ self, mock_hook_cls, make_mock_run_result, wrap
+ ):
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
_make_mock_agent(
+ "ok", make_mock_run_result
+ )
+ custom_logging =
ToolLoggingCapability(logger=MagicMock(spec=logging.Logger))
+ declared = wrap(custom_logging)
+ op = AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="llm",
+ capabilities=[declared],
+ )
+
+ op.execute(context=_make_context())
+
+ capabilities =
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs["capabilities"]
+ assert capabilities[0] is declared
+ assert not any(
+ isinstance(capability, ToolLoggingCapability) and capability is
not custom_logging
+ for capability in capabilities
+ )
+
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_capabilities_in_both_places_are_refused(self, mock_hook_cls):
op = AgentOperator(
@@ -1504,6 +1671,36 @@ class TestAgentOperatorDurable:
assert result[0] is cap
+ @pytest.mark.parametrize("passed_as", ["capabilities", "agent_params"])
+ def test_toolset_capability_logging_wraps_durable_cache(self, passed_as):
+ """Concrete ``Toolset`` capabilities receive durable caching before
agent creation."""
+ inner = FunctionToolset()
+ capability = Toolset(inner)
+ kwargs = (
+ {"capabilities": [capability]}
+ if passed_as == "capabilities"
+ else {"agent_params": {"capabilities": [capability]}}
+ )
+ op = AgentOperator(
+ task_id="t",
+ prompt="p",
+ llm_conn_id="c",
+ durable=True,
+ **kwargs,
+ )
+ op._durable_storage = MagicMock(spec=DurableStorageProtocol)
+ op._durable_counter = DurableStepCounter()
+ hook = MagicMock(spec=["create_agent"])
+ op.llm_hook = hook
+
+ op._build_agent()
+
+ capabilities = hook.create_agent.call_args.kwargs["capabilities"]
+ assert isinstance(capabilities[0].toolset, CachingToolset)
+ assert isinstance(capabilities[0].toolset.wrapped, MaskingToolset)
+ assert capabilities[0].toolset.wrapped.wrapped is inner
+ assert isinstance(capabilities[-1], ToolLoggingCapability)
+
def test_toolset_capability_tool_replayed_on_retry(self):
"""A tool supplied via a ``Toolset`` capability is cached and replayed
on a
retry instead of re-executing. Regression: such tools bypassed the
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_logging.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_logging.py
index 6ffecbf4926..909f8ade0f5 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_logging.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_logging.py
@@ -16,15 +16,28 @@
# under the License.
from __future__ import annotations
+import asyncio
import logging
+from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
-from pydantic_ai import RunContext
-from pydantic_ai.exceptions import ApprovalRequired
+from pydantic_ai import Agent, RunContext
+from pydantic_ai.capabilities import AbstractCapability
+from pydantic_ai.exceptions import (
+ ApprovalRequired,
+ CallDeferred,
+ ModelRetry,
+ SkipToolExecution,
+ SkipToolValidation,
+ ToolFailed,
+)
+from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart,
ToolReturnPart
+from pydantic_ai.models.function import FunctionModel
+from pydantic_ai.toolsets import FunctionToolset, PrefixedToolset
from pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetTool
-from airflow.providers.common.ai.toolsets.logging import LoggingToolset
+from airflow.providers.common.ai.toolsets.logging import LoggingToolset,
ToolLoggingCapability
@pytest.fixture
@@ -91,8 +104,78 @@ class TestLoggingToolset:
with pytest.raises(RuntimeError, match="boom"):
await logging_toolset.call_tool("bad_tool", {}, ctx, tool)
- assert any("Tool bad_tool failed after" in r.message for r in
caplog.records)
- assert any("::endgroup::" in r.message for r in caplog.records)
+ end_group_index = next(
+ i for i, record in enumerate(caplog.records) if record.message ==
"::endgroup::"
+ )
+ failure_index = next(
+ i for i, record in enumerate(caplog.records) if "Tool bad_tool
failed after" in record.message
+ )
+ assert end_group_index < failure_index
+ assert caplog.records[failure_index].levelno == logging.ERROR
+
+ @pytest.mark.asyncio
+ async def test_closes_group_when_call_is_cancelled(
+ self, logging_toolset, wrapped_toolset, logger, caplog
+ ):
+ wrapped_toolset.call_tool =
AsyncMock(side_effect=asyncio.CancelledError())
+
+ with caplog.at_level(logging.INFO, logger="test.logging_toolset"):
+ with pytest.raises(asyncio.CancelledError):
+ await logging_toolset.call_tool("slow_tool", {}, MagicMock(),
MagicMock())
+
+ assert caplog.records[-1].message == "::endgroup::"
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "signal",
+ [
+ ModelRetry("retry"),
+ ToolFailed("failed"),
+ CallDeferred(),
+ SkipToolExecution("result"),
+ ],
+ ids=lambda signal: type(signal).__name__,
+ )
+ async def test_logs_control_flow_at_info(self, logging_toolset,
wrapped_toolset, logger, caplog, signal):
+ wrapped_toolset.call_tool = AsyncMock(side_effect=signal)
+
+ with caplog.at_level(logging.INFO, logger="test.logging_toolset"):
+ with pytest.raises(type(signal)):
+ await logging_toolset.call_tool("retrying_tool", {},
MagicMock(), MagicMock())
+
+ assert any(
+ record.message.startswith(f"Tool retrying_tool requested
{type(signal).__name__} after")
+ and record.levelno == logging.INFO
+ for record in caplog.records
+ )
+ assert not any(record.levelno >= logging.ERROR for record in
caplog.records)
+ assert caplog.records[-1].message == "::endgroup::"
+
+ @pytest.mark.asyncio
+ async def test_logs_control_flow_reason(self, logging_toolset,
wrapped_toolset, logger, caplog):
+ wrapped_toolset.call_tool = AsyncMock(side_effect=ModelRetry("column X
not found"))
+
+ with caplog.at_level(logging.INFO, logger="test.logging_toolset"):
+ with pytest.raises(ModelRetry, match="column X not found"):
+ await logging_toolset.call_tool("retrying_tool", {},
MagicMock(), MagicMock())
+
+ assert any("column X not found" in record.message for record in
caplog.records)
+
+ @pytest.mark.asyncio
+ async def test_skip_tool_validation_is_logged_as_error(
+ self, logging_toolset, wrapped_toolset, logger, caplog
+ ):
+ signal = SkipToolValidation({"value": 1})
+ wrapped_toolset.call_tool = AsyncMock(side_effect=signal)
+
+ with caplog.at_level(logging.INFO, logger="test.logging_toolset"):
+ with pytest.raises(SkipToolValidation):
+ await logging_toolset.call_tool("invalid_tool", {"value": 1},
MagicMock(), MagicMock())
+
+ assert any(
+ record.message.startswith("Tool invalid_tool failed after") and
record.levelno == logging.ERROR
+ for record in caplog.records
+ )
@pytest.mark.asyncio
async def test_a_call_waiting_for_approval_is_not_logged_as_a_failure(
@@ -127,3 +210,45 @@ class TestLoggingToolset:
await logging_toolset.call_tool("list_tables", {}, ctx, tool)
assert not any("Tool args:" in r.message for r in caplog.records)
+
+
+class TestToolLoggingCapability:
+ def test_wraps_assembled_toolset(self, logger):
+ toolset = FunctionToolset()
+
+ wrapped =
ToolLoggingCapability(logger=logger).get_wrapper_toolset(toolset)
+
+ assert isinstance(wrapped, LoggingToolset)
+ assert wrapped.wrapped is toolset
+ assert wrapped.logger is logger
+
+ def test_ordering_keeps_logging_inside_other_capability_wrappers(self,
logger, caplog):
+ class PrefixingCapability(AbstractCapability[Any]):
+ def get_wrapper_toolset(self, toolset):
+ return PrefixedToolset(toolset, prefix="outer")
+
+ def my_tool() -> str:
+ return "tool-result"
+
+ def model_fn(messages, info):
+ if any(isinstance(part, ToolReturnPart) for message in messages
for part in message.parts):
+ return ModelResponse(parts=[TextPart(content="done")])
+ return ModelResponse(
+ parts=[ToolCallPart(tool_name="outer_my_tool", args={},
tool_call_id="call-1")]
+ )
+
+ agent = Agent(
+ FunctionModel(model_fn),
+ tools=[my_tool],
+ capabilities=[PrefixingCapability(),
ToolLoggingCapability(logger=logger)],
+ )
+
+ with caplog.at_level(logging.INFO, logger="test.logging_toolset"):
+ result = agent.run_sync("run the tool")
+
+ assert result.output == "done"
+ assert any(record.message == "::group::Tool call: my_tool" for record
in caplog.records)
+ assert not any(record.message == "::group::Tool call: outer_my_tool"
for record in caplog.records)
+
+ def test_is_not_serializable(self):
+ assert ToolLoggingCapability.get_serialization_name() is None
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_logging.py
b/providers/common/ai/tests/unit/common/ai/utils/test_logging.py
index 6733315f66b..49273a49341 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_logging.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_logging.py
@@ -34,13 +34,11 @@ from pydantic_ai.models.function import FunctionModel
from pydantic_ai.models.test import TestModel
from pydantic_ai.usage import RunUsage
-from airflow.providers.common.ai.toolsets.logging import LoggingToolset
from airflow.providers.common.ai.utils.logging import (
_log_output_debug,
format_usage_for_xcom,
log_run_summary,
log_run_usage,
- wrap_toolsets_for_logging,
)
@@ -342,18 +340,3 @@ class TestLogOutputDebug:
debug_records = [r for r in caplog.records if r.levelno ==
logging.DEBUG]
assert len(debug_records) == 0
-
-
-class TestWrapToolsetsForLogging:
- def test_wraps_each_toolset(self):
- ts_a = MagicMock()
- ts_b = MagicMock()
- logger = logging.getLogger("test.wrap")
-
- wrapped = wrap_toolsets_for_logging([ts_a, ts_b], logger)
-
- assert len(wrapped) == 2
- assert all(isinstance(w, LoggingToolset) for w in wrapped)
- assert wrapped[0].wrapped is ts_a
- assert wrapped[1].wrapped is ts_b
- assert wrapped[0].logger is logger