This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 2448be40082 Make common.ai tool_prefix and output_type signatures
consistent (#74695)
2448be40082 is described below
commit 2448be40082ffbb14bcc3a10db42e9cd743a087e
Author: Wei Lee <[email protected]>
AuthorDate: Thu Nov 12 14:59:34 2026 +0900
Make common.ai tool_prefix and output_type signatures consistent (#74695)
Public signatures freeze at 1.0, and changing a parameter's type, default
or availability afterwards is a breaking change. tool_prefix was str on some
toolsets, str | None on MCPToolset and MCPHook, and missing on SQLToolset and
DataFusionToolset. AgentOperator also annotated output_type narrower than
LLMOperator even though both accept the same values at runtime. HookToolset
keeps its deprecated tool_name_prefix alias until 2.0.
SQLToolset and DataFusionToolset also override tool_name_conflict_hint so a
name conflict points users to tool_prefix. rehydrate_pydantic_output now
accepts a list output_type by validating it as a union, so a list output_type
no longer crashes after a HITL review.
Models returned under a list output_type are dumped through a shared
dump_output_for_xcom helper. With serialize_output=True, and on task-sdk
versions without the walker, the dump keeps model_dump() as in 1.0.0. A model
whose class the worker cannot register is dumped with to_jsonable_python, so
HttpUrl, Path and Enum fields serialize.
---
providers/common/ai/docs/changelog.rst | 36 +++
providers/common/ai/docs/concepts.rst | 2 +-
providers/common/ai/docs/operators/agent.rst | 13 +-
providers/common/ai/docs/operators/llm.rst | 1 +
.../common/ai/docs/operators/llm_file_analysis.rst | 13 +-
providers/common/ai/docs/stability.rst | 11 +-
providers/common/ai/docs/structured_output.rst | 48 ++-
providers/common/ai/docs/toolsets/datafusion.rst | 3 +
providers/common/ai/docs/toolsets/mcp.rst | 4 +-
providers/common/ai/docs/toolsets/sql.rst | 10 +-
.../airflow/providers/common/ai/decorators/llm.py | 3 +-
.../src/airflow/providers/common/ai/hooks/mcp.py | 4 +-
.../airflow/providers/common/ai/operators/agent.py | 70 ++---
.../airflow/providers/common/ai/operators/llm.py | 37 ++-
.../common/ai/operators/llm_file_analysis.py | 10 +-
.../providers/common/ai/toolsets/datafusion.py | 33 +-
.../airflow/providers/common/ai/toolsets/hook.py | 6 +-
.../airflow/providers/common/ai/toolsets/mcp.py | 6 +-
.../providers/common/ai/toolsets/object_storage.py | 17 +-
.../providers/common/ai/toolsets/sandbox.py | 24 +-
.../airflow/providers/common/ai/toolsets/sql.py | 35 ++-
.../providers/common/ai/utils/output_type.py | 81 ++++-
.../providers/common/ai/utils/query_results.py | 20 +-
.../providers/common/ai/utils/tool_prefix.py | 56 ++++
.../ai/tests/unit/common/ai/hooks/test_mcp.py | 18 ++
.../tests/unit/common/ai/operators/test_agent.py | 82 ++++-
.../ai/tests/unit/common/ai/operators/test_llm.py | 78 ++++-
.../common/ai/operators/test_llm_file_analysis.py | 28 ++
.../unit/common/ai/toolsets/test_datafusion.py | 90 ++++++
.../ai/tests/unit/common/ai/toolsets/test_mcp.py | 9 +-
.../unit/common/ai/toolsets/test_object_storage.py | 13 +-
.../tests/unit/common/ai/toolsets/test_sandbox.py | 3 +
.../ai/tests/unit/common/ai/toolsets/test_sql.py | 114 +++++++
.../tests/unit/common/ai/utils/test_output_type.py | 343 ++++++++++++++++++++-
.../unit/common/ai/utils/test_query_results.py | 27 ++
.../tests/unit/common/ai/utils/test_tool_prefix.py | 78 +++++
36 files changed, 1240 insertions(+), 186 deletions(-)
diff --git a/providers/common/ai/docs/changelog.rst
b/providers/common/ai/docs/changelog.rst
index 17fb111bd10..fab49897489 100644
--- a/providers/common/ai/docs/changelog.rst
+++ b/providers/common/ai/docs/changelog.rst
@@ -44,6 +44,42 @@ Changelog
with ``result.to_state()`` and ``state.approve()`` after
``run_durably_sync()`` should call
``Runner.run`` and handle ``result.interruptions`` itself.
+.. note::
+ **Significant change: output serialization of the LLM operators**
+
+ ``LLMOperator``, ``@task.llm`` and ``LLMFileAnalysisOperator`` now push a
model as a
+ JSON-compatible dict, where 1.1.0 pushed it as a model instance, in these
cases:
+
+ * On Airflow 3.3 and later, the model is itself the output,
``serialize_output`` is not set,
+ and ``output_type`` is one the worker cannot register a class for (a list
of output types
+ such as ``[Summary, int]``, an output marker, an output function, a
``TypeAliasType`` or a
+ ``NewType``).
+ * On Airflow 3.3 and later, the model is inside a list, tuple, set or dict
in the output and
+ the worker cannot register its class, whether or not ``serialize_output``
is set.
+ * On Airflow < 3.3, the model is inside a list, tuple, set or dict in the
output, whatever the
+ ``output_type`` (for example ``list[MyModel]``).
+
+ When a model inside a list, tuple, set or dict is dumped, the whole output
is dumped with
+ it, so a tuple in the output arrives as a list and a ``date`` next to the
model arrives as a
+ string. A downstream task indexes the dict (``result["field"]``) instead of
using attribute
+ access. As in 1.1.0, a model that is itself the output is dumped with
``model_dump()`` when
+ ``serialize_output=True`` is set or on Airflow < 3.3.
+
+ ``AgentOperator`` and ``@task.agent`` already pushed these models as dicts
in 1.1.0.
+ Compared with 1.1.0, when they dump to JSON values, a model that sets
+ ``serialize_by_alias=True`` now arrives with its field names (``full_name``,
not
+ ``fullName``), a tuple in the output arrives as a list, and a ``date`` next
to the model
+ arrives as a string. A model inside a set is now dumped too, and the set
arrives as a list.
+ A model that is itself the output and is dumped with ``model_dump()`` keeps
its aliases, as
+ in 1.1.0. See :ref:`When a model arrives as a dict
<structured-output-dict-shape>`.
+
+.. note::
+ **Signature change: MCP ``tool_prefix``**
+
+ The ``tool_prefix`` parameter of ``MCPHook`` and ``MCPToolset`` now defaults
to ``""`` and is
+ typed ``str``; in 1.1.0 it defaulted to ``None`` and was typed ``str |
None``. This changes
+ only the signature and the annotated type, not the behaviour.
+
1.1.0
.....
diff --git a/providers/common/ai/docs/concepts.rst
b/providers/common/ai/docs/concepts.rst
index 27c64b05426..c63f73e872a 100644
--- a/providers/common/ai/docs/concepts.rst
+++ b/providers/common/ai/docs/concepts.rst
@@ -110,7 +110,7 @@ Results flow through XCom
Every operator pushes its result to XCom like any other task. A plain string
arrives as a
string. A Pydantic ``output_type`` arrives as the model instance, typed, so a
downstream
-task can use attribute access. :doc:`structured_output` explains how the class
is
+task can use attribute access (a model inside a list of types arrives as a
``dict``). :doc:`structured_output` explains how the class is
registered for deserialization and where that stops working.
Related providers
diff --git a/providers/common/ai/docs/operators/agent.rst
b/providers/common/ai/docs/operators/agent.rst
index c8b1ea31dd4..f0506d1a494 100644
--- a/providers/common/ai/docs/operators/agent.rst
+++ b/providers/common/ai/docs/operators/agent.rst
@@ -112,7 +112,8 @@ Structured output
Set ``output_type`` to a Pydantic ``BaseModel`` subclass to get structured data
back. The model instance is pushed to XCom unchanged so downstream tasks can
type-hint the class directly (``def downstream(result: MyModel)``) and use
-attribute access (``result.field``).
+attribute access (``result.field``). Inside a list of types (``[MyModel,
int]``) the
+model is pushed as a ``dict`` instead.
:doc:`../structured_output` explains the XCom deserialization rules, the
cross-Dag gap and
``serialize_output``.
@@ -454,11 +455,11 @@ Parameters
- ``emit_message_history``: Push the post-run transcript to XCom under the key
``message_history``. Defaults to ``True`` when ``message_history`` is set
and ``False``
otherwise; ``True`` records a single-turn run without seeding it. See
:doc:`../message_history`.
-- ``serialize_output``: If ``True`` and ``output_type`` is a Pydantic
- ``BaseModel`` subclass, the model instance is dumped to a ``dict`` via
- ``model_dump()`` before being pushed to XCom. Default ``False`` -- the
- Pydantic instance flows through XCom unchanged. Set to ``True`` when a
- downstream consumer needs the dict shape.
+- ``serialize_output``: If ``True``, an output that is itself a Pydantic
``BaseModel`` is dumped with
+ ``model_dump()`` before being pushed to XCom, so ``date`` and ``Decimal``
fields keep their types.
+ Default ``False``: a model whose class the worker registers from
``output_type`` flows through XCom
+ unchanged. Some outputs are pushed as dicts either way; see
+ :ref:`When a model arrives as a dict <structured-output-dict-shape>`.
**HITL review parameters**: ``enable_hitl_review``, ``max_hitl_iterations``,
``hitl_timeout`` and ``hitl_poll_interval`` turn on and bound the iterative
review
diff --git a/providers/common/ai/docs/operators/llm.rst
b/providers/common/ai/docs/operators/llm.rst
index e6e1040518b..83b134c439b 100644
--- a/providers/common/ai/docs/operators/llm.rst
+++ b/providers/common/ai/docs/operators/llm.rst
@@ -46,6 +46,7 @@ Structured output
Set ``output_type`` to a Pydantic ``BaseModel`` subclass and the model
instance is pushed to
XCom unchanged, so downstream tasks can type-hint the class and use attribute
access.
+Inside a list of types (``[MyModel, int]``) the model is pushed as a ``dict``
instead.
:doc:`../structured_output` explains the XCom deserialization rules, the
cross-Dag gap and
``serialize_output``.
diff --git a/providers/common/ai/docs/operators/llm_file_analysis.rst
b/providers/common/ai/docs/operators/llm_file_analysis.rst
index e4efec17dae..1e58a977123 100644
--- a/providers/common/ai/docs/operators/llm_file_analysis.rst
+++ b/providers/common/ai/docs/operators/llm_file_analysis.rst
@@ -77,7 +77,8 @@ Structured Output
Set ``output_type`` to a Pydantic ``BaseModel`` when you want a typed response
back from the LLM instead of a plain string. The model instance is pushed to
-XCom unchanged so downstream tasks can type-hint the class directly. The
+XCom unchanged so downstream tasks can type-hint the class directly. Inside a
+list of types (``[MyModel, int]``) the model is pushed as a ``dict`` instead.
The
declared ``output_type`` (and any ``BaseModel`` reachable from
``Union``/``Optional``/``list`` shapes) is registered for deserialization by
the
worker when it loads the Dag. Define the class at **module scope** and bind it
to
@@ -157,11 +158,11 @@ Parameters
- ``usage_limits``: Optional pydantic-ai ``UsageLimits`` (or a templated
``dict`` of
the same fields) enforced on the run; the task fails when a budget is
exceeded.
Default ``None``. See :ref:`Usage Limits <howto/operator:llm_usage_limits>`.
-- ``serialize_output``: If ``True`` and ``output_type`` is a Pydantic
- ``BaseModel`` subclass, the model instance is dumped to a ``dict`` via
- ``model_dump()`` before being pushed to XCom. Default ``False`` -- the
- Pydantic instance flows through XCom unchanged. Set to ``True`` when a
- downstream consumer needs the dict shape.
+- ``serialize_output``: If ``True``, an output that is itself a Pydantic
``BaseModel`` is dumped with
+ ``model_dump()`` before being pushed to XCom, so ``date`` and ``Decimal``
fields keep their types.
+ Default ``False``: a model whose class the worker registers from
``output_type`` flows through XCom
+ unchanged. Some outputs are pushed as dicts either way; see
+ :ref:`When a model arrives as a dict <structured-output-dict-shape>`.
``decision_policy`` is not supported here: the operator runs its own
``execute``
without the confidence gate and rejects a policy with a bar at construction.
diff --git a/providers/common/ai/docs/stability.rst
b/providers/common/ai/docs/stability.rst
index 733ccedc052..22a4cdb7807 100644
--- a/providers/common/ai/docs/stability.rst
+++ b/providers/common/ai/docs/stability.rst
@@ -46,11 +46,13 @@ Pydantic AI toolsets, but the Pydantic AI class they
inherit from can change.
* - ``@task.llm`` and
:class:`~airflow.providers.common.ai.operators.llm.LLMOperator`
- Sends the prompt to the model from ``llm_conn_id`` and returns the
output as the
task's return value: a string by default, or an instance of
``output_type``,
- dumped to a dict when ``serialize_output=True``. Before Airflow 3.3 an
instance is
- always dumped to a dict. ``decision_policy`` is experimental; see below.
+ dumped to a dict when ``serialize_output=True``. Some models arrive as
dicts without it;
+ see :ref:`When a model arrives as a dict
<structured-output-dict-shape>`.
+ ``decision_policy`` is experimental; see below.
* - ``@task.agent`` and
:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`
- Runs an agent with the model from ``llm_conn_id`` and the given
toolsets, and
- returns its output under the same rules as ``@task.llm``. Code mode is
+ returns its output as the task's return value. Some models arrive as
dicts; see
+ :ref:`When a model arrives as a dict <structured-output-dict-shape>`.
Code mode is
experimental; see below.
* - Per-tool approval on ``AgentOperator`` (:doc:`tool_approval`)
- On Airflow 3.3 and later, a call to a tool marked with Pydantic AI's
approval API
@@ -142,7 +144,8 @@ Pydantic AI toolsets, but the Pydantic AI class they
inherit from can change.
:doc:`connections/pydantic_ai_azure` keep their meaning, so an existing
connection
keeps producing the same model.
* - :class:`~airflow.providers.common.ai.toolsets.sql.SQLToolset`
- - Exposes ``list_tables``, ``get_schema``, ``query`` and ``check_query``.
Read-only
+ - Exposes ``list_tables``, ``get_schema``, ``query`` and ``check_query``,
each named
+ ``<tool_prefix>_<name>`` when ``tool_prefix`` is set. Read-only
unless ``allow_writes=True``. When ``allowed_tables`` is set, other
tables are
refused. ``query`` returns at most ``max_rows`` rows, and keeps the
rows and column
names within ``max_result_bytes`` bytes. When ``query`` fails or
refuses a
diff --git a/providers/common/ai/docs/structured_output.rst
b/providers/common/ai/docs/structured_output.rst
index e8ca62d062a..1bd2c9cd087 100644
--- a/providers/common/ai/docs/structured_output.rst
+++ b/providers/common/ai/docs/structured_output.rst
@@ -24,6 +24,7 @@ Set ``output_type`` to a Pydantic ``BaseModel`` subclass. The
LLM is instructed
to return structured data, and the model instance is pushed to XCom unchanged
so downstream tasks can type-hint the class directly
(``def downstream(result: MyModel)``) and use attribute access
(``result.field``).
+Inside a list of types (``[MyModel, int]``) the model is pushed as a ``dict``
instead.
The declared ``output_type`` (and any ``BaseModel`` reachable from
``Union``/``Optional``/``list[...]`` types) is registered for XCom
deserialization by
@@ -34,6 +35,8 @@ classes nested inside a function or ``@dag``-decorated body,
parameterized
generics, and dynamically-built classes whose ``__name__`` does not match the
attribute they are bound to cannot be re-imported, so they are skipped with a
warning at worker startup and the value fails to deserialize at the consumer.
+A list of types (``output_type=[MyModel, int]``) registers no class, so the
operator pushes the
+model as a ``dict`` even when ``serialize_output`` is ``False``.
.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm.py
:language: python
@@ -53,7 +56,8 @@ cache that bypasses operator construction.
The worker finds no class to register when ``output_type`` is a list of output
types such as ``[Summary, int]``, an output marker such as
``ToolOutput(Summary)``, an output function, a ``TypeAliasType`` or a
-``NewType``. For these output types, ``AgentOperator`` and ``@task.agent`` push
+``NewType``. For these output types, ``LLMOperator``, ``@task.llm``,
+``LLMFileAnalysisOperator``, ``AgentOperator`` and ``@task.agent`` push
each model in the output as a JSON-compatible dict, including models inside a
list, tuple or dict. A model is pushed as a dict even if its class is listed in
``[core] allowed_deserialization_classes``, or if another task in the same Dag
@@ -69,10 +73,7 @@ A model at the top level is still dumped with plain
``model_dump()`` when
condition is enough. A plain ``Enum`` member (one that does not also subclass
``str`` or ``int``), an ``HttpUrl`` or a ``Path`` in that model then stays a
Python object. The default XCom backend cannot serialize these objects, so the
-XCom push fails with ``TypeError``. Before Airflow 3.3 the worker registers no
-model class. ``AgentOperator`` and ``@task.agent`` therefore push a model
-inside a list, tuple or dict as a JSON-compatible dict for every
-``output_type``, not only for the output types listed above.
+XCom push fails with ``TypeError``.
Upgrading from common.ai 1.0.0 to 1.1.0 can change the type that a downstream
task receives:
@@ -108,6 +109,37 @@ class is not auto-registered. Add the class qualified name
to
``[core] allowed_deserialization_classes`` (or a glob that matches it) to make
that pattern work.
-If a downstream consumer needs the dict shape (e.g. forwarding to an external
-system that expects JSON-style payloads), pass ``serialize_output=True`` and
the
-operator calls ``model_dump()`` before pushing to XCom.
+.. _structured-output-dict-shape:
+
+When a model arrives as a dict
+------------------------------
+
+``LLMOperator``, ``@task.llm``, ``LLMFileAnalysisOperator``, ``AgentOperator``
and ``@task.agent``
+decide in the same way whether a model is pushed as an instance or dumped. Set
+``serialize_output=True`` when a downstream consumer needs the dict shape (for
example to forward
+it to a system that expects JSON-style payloads).
+
+* With ``serialize_output=True``, an output that is itself a model is dumped
with ``model_dump()``, so
+ field types such as ``date`` and ``Decimal`` are kept.
+* A model inside a list, tuple, set or dict is not dumped when the worker
registers its class
+ (``output_type=list[MyModel]`` on Airflow 3.3 and later). The XCom
serializer pushes it as an
+ instance, with or without ``serialize_output``, unless the output also holds
a model whose class
+ the worker does not register (see the next bullets).
+* On Airflow 3.3 and later, a model whose class the worker cannot register is
dumped to JSON values
+ when it is itself the output and ``serialize_output`` is ``False``. With
``serialize_output=True`` it is dumped with
+ ``model_dump()`` as the first bullet describes. The worker registers the
classes it finds by
+ reading ``output_type`` and finds none in a list of types such as
``output_type=[MyModel, int]``.
+* The operator dumps the whole output with ``to_jsonable_python``, using field
names rather than
+ aliases (also for a model that sets ``serialize_by_alias=True``), as soon as
it holds such a model
+ in a list, tuple, set or dict, or as a set member or a dict key. A ``date``
or ``Decimal`` next to
+ the model becomes a string, a set becomes a list, and a model used as a dict
key becomes its
+ string form.
+* Before Airflow 3.3 the worker registers no class. A model that is itself the
output is then dumped
+ with ``model_dump()`` whether or not ``serialize_output`` is set, and an
output that holds a model
+ in a list, tuple, set or dict is dumped as the previous bullet describes,
for every
+ ``output_type``.
+
+In a dump to JSON values, ``HttpUrl``, ``Path`` and ``Enum`` fields become
plain JSON values. A model
+that is itself the output with ``serialize_output=True`` keeps its Python
field values instead, so an
+``HttpUrl``, a ``Path`` or a plain ``Enum`` member in it stays an object that
the default XCom backend
+cannot serialize.
diff --git a/providers/common/ai/docs/toolsets/datafusion.rst
b/providers/common/ai/docs/toolsets/datafusion.rst
index fd70c00532c..854913d8a94 100644
--- a/providers/common/ai/docs/toolsets/datafusion.rst
+++ b/providers/common/ai/docs/toolsets/datafusion.rst
@@ -144,6 +144,9 @@ Parameters
See :ref:`bounded-query-results` and :ref:`bounded-schema-results`.
- ``max_columns``: Maximum columns ``get_schema`` returns in full. Default
``100``.
Above it the result becomes a bounded summary. See
:ref:`bounded-schema-results`.
+- ``tool_prefix``: Optional prefix joined to each tool name with an underscore
(e.g. ``"lake"`` produces ``"lake_query"``).
+ Must be a valid Python identifier. Set it when one agent has another toolset
with the
+ same tool names, since duplicate tool names are rejected.
- ``max_retries``: How many times the model may correct a failed call to these
tools. Default ``None``, the agent's ``retries``. See
:ref:`toolset-retry-budget`.
diff --git a/providers/common/ai/docs/toolsets/mcp.rst
b/providers/common/ai/docs/toolsets/mcp.rst
index 65ca204f769..30cb615acf0 100644
--- a/providers/common/ai/docs/toolsets/mcp.rst
+++ b/providers/common/ai/docs/toolsets/mcp.rst
@@ -55,9 +55,9 @@ Parameters
connection runs its ``Extra.command`` on the worker, so whoever picks the
connection picks the command. A ``token_provider`` or ``env_provider`` is
shared by every connection the template renders to.
-- ``tool_prefix``: Optional prefix prepended to tool names to avoid
+- ``tool_prefix``: Optional prefix joined to each tool name with an underscore
to avoid
collisions when using multiple MCP servers (e.g. ``"weather"`` produces
- ``"weather_get_forecast"``).
+ ``"weather_get_forecast"``). Default ``""`` (no prefix).
- ``token_provider``: Optional zero-argument callable returning a bearer token.
When set, it overrides the connection's static ``password`` for the
``Authorization`` header. Called once, the first time this toolset
diff --git a/providers/common/ai/docs/toolsets/sql.rst
b/providers/common/ai/docs/toolsets/sql.rst
index 1e44b4b87ad..399262d5f37 100644
--- a/providers/common/ai/docs/toolsets/sql.rst
+++ b/providers/common/ai/docs/toolsets/sql.rst
@@ -317,7 +317,11 @@ Parameters
Default ``False`` -- only SELECT-family and read-only metadata
(``DESCRIBE``/``SHOW``) statements are permitted. To have a person approve a
``query`` call before it runs, wrap the toolset with
``.approval_required()`` and
- return ``tool_def.name == "query"`` from its function. Reads and writes both
go
+ return ``tool_def.name == f"{prefix}_query"`` from its function, where
``prefix`` is
+ your ``tool_prefix`` (with none set, compare against ``"query"``). The
function sees
+ the prefixed name, so a bare ``"query"`` comparison never matches once a
prefix is
+ set and writes run without approval. Compare the full name with ``==``; do
not use
+ ``endswith("query")``, which also matches ``check_query``. Reads and writes
both go
through ``query``, so every query pauses for a decision. On Airflow 3.3 a
task
instance can pause only once per Dag run, so there this fits an agent that
runs one
query, such as a single write; see :doc:`../tool_approval`.
@@ -329,6 +333,10 @@ Parameters
See :ref:`bounded-query-results` and :ref:`bounded-schema-results`.
- ``max_columns``: Maximum columns ``get_schema`` returns in full. Default
``100``.
Above it the result becomes a bounded summary. See
:ref:`bounded-schema-results`.
+- ``tool_prefix``: Optional prefix joined to each tool name with an underscore
(e.g. ``"pg"`` produces ``"pg_query"``).
+ Must be a valid Python identifier. Set it when one agent has another toolset
with the
+ same tool names, since duplicate tool names are rejected. A function passed
to
+ ``.approval_required()`` sees these prefixed names; see ``allow_writes``
above.
- ``max_retries``: How many times the model may correct a failed call to these
tools. Default ``None``, the agent's ``retries``. See
:ref:`toolset-retry-budget`.
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm.py
b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm.py
index 32514c62513..60824be3ed6 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/decorators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/decorators/llm.py
@@ -20,7 +20,8 @@ TaskFlow decorator for general-purpose LLM calls.
The user writes a function that **returns the prompt string**. The decorator
handles hook creation, agent configuration, and the LLM call. When
``output_type`` is a Pydantic ``BaseModel`` subclass, the model instance is
-returned to XCom unchanged so downstream tasks can type-hint it directly.
+returned to XCom unchanged so downstream tasks can type-hint it directly,
except
+inside a list of types (``[MyModel, int]``), where it is returned as a
``dict``.
The class must be defined at module scope.
"""
diff --git a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
index 810b0fccbc4..dc39a8ddc5e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/hooks/mcp.py
@@ -66,7 +66,7 @@ class MCPHook(BaseHook):
:param mcp_conn_id: Airflow connection ID for the MCP server.
:param tool_prefix: Optional prefix prepended to tool names
- (e.g. ``"weather"`` → ``"weather_get_forecast"``).
+ (e.g. ``"weather"`` → ``"weather_get_forecast"``). Defaults to ``""``
(no prefix).
:param token_provider: Optional zero-argument callable returning a bearer
token string. When set, it overrides the connection ``password`` for
the
``Authorization`` header on HTTP/SSE transports. Called once, the first
@@ -88,7 +88,7 @@ class MCPHook(BaseHook):
def __init__(
self,
mcp_conn_id: str = default_conn_name,
- tool_prefix: str | None = None,
+ tool_prefix: str = "",
*,
token_provider: Callable[[], str] | None = None,
env_provider: Callable[[], dict[str, str]] | None = None,
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 05da22c2760..71b2b626564 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
@@ -28,7 +28,7 @@ from datetime import timedelta
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn
-from pydantic import BaseModel, TypeAdapter
+from pydantic import TypeAdapter
from pydantic_ai import DeferredToolRequests, DeferredToolResults, ToolDenied
from pydantic_ai.capabilities import AbstractCapability, Toolset,
WrapperCapability
from pydantic_ai.messages import ModelMessagesTypeAdapter, ModelResponse,
ToolCallPart
@@ -60,6 +60,7 @@ from airflow.providers.common.ai.utils.logging import (
log_run_summary,
log_run_usage,
)
+from airflow.providers.common.ai.utils.output_type import dump_output_for_xcom
from airflow.providers.common.ai.utils.prompt_cache import PromptCaching
from airflow.providers.common.ai.utils.toolset_base import ensure_masked
from airflow.providers.common.ai.utils.toolsets import iter_toolsets
@@ -93,18 +94,18 @@ try:
# See LLMOperator: Newer ``apache-airflow-task-sdk`` versions register
declared ``output_type`` classes
# from a worker-side DAG walk, so the model instance flows through XCom;
older
# ``apache-airflow-task-sdk`` versions without the walk dump to a dict
instead.
- from airflow.sdk.serde import (
- SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as _CORE_WALKER,
- iter_pydantic_models,
- )
+ from airflow.sdk.serde import SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as
_CORE_WALKER
except ImportError: # pragma: no cover - missing ``apache-airflow-task-sdk``
walker
_CORE_WALKER = False
if TYPE_CHECKING:
+ from types import UnionType
+
import jinja2
from pydantic_ai import Agent
from pydantic_ai.capabilities import AgentCapability
from pydantic_ai.messages import ModelMessage
+ from pydantic_ai.output import OutputSpec
from pydantic_ai.usage import UsageLimits
from airflow.providers.common.ai.durable.base import DurableStorageProtocol
@@ -241,26 +242,6 @@ def _declares_agent_template_fields(toolset: Any) -> bool:
)
-def _dump_unregistered_models(value: Any, registered: set[type]) -> Any:
- """Return *value* with each model whose class is not in *registered*
dumped to a JSON-compatible dict."""
- if isinstance(value, BaseModel):
- if type(value) in registered:
- return value
- # A python-mode model_dump() leaves values such as plain Enum members,
HttpUrl and Path in the
- # dict, and the default XCom serializer raises TypeError for those
values.
- return value.model_dump(mode="json")
- # The default XCom serializer already pushes a subclass of list, tuple or
dict, such as a NamedTuple
- # or an OrderedDict, as the plain type. Building the plain type here does
not change what a
- # downstream task receives.
- if isinstance(value, list):
- return [_dump_unregistered_models(item, registered) for item in value]
- if isinstance(value, tuple):
- return tuple(_dump_unregistered_models(item, registered) for item in
value)
- if isinstance(value, dict):
- return {key: _dump_unregistered_models(item, registered) for key, item
in value.items()}
- return value
-
-
# CancellableAgentRunMixin must precede BaseOperator so its on_kill overrides
BaseOperator's
# no-op. The other mixins only add methods, so they can trail BaseOperator.
See the MRO guard
# test in tests/unit/common/ai/mixins/test_cancellable_run.py.
@@ -296,10 +277,16 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin, Usa
:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook`
for how blank entries in the list are dropped.
:param system_prompt: System-level instructions for the agent.
- :param output_type: Expected output type. Default ``str``. Set to a
Pydantic
- ``BaseModel`` subclass for structured output; the model instance is
+ :param output_type: Expected output type. Default ``str``. A single type
+ (``str``, ``int``, ``list[str]``, a Pydantic ``BaseModel`` subclass)
or a
+ union (``A | B``). A list of types (``[str, int]``) is also accepted
and treated
+ as alternatives (a union). pydantic-ai output markers
(``ToolOutput(...)``,
+ ``NativeOutput(...)``) and output functions are also accepted.
+ For a ``BaseModel`` subclass, the model instance is
returned to XCom unchanged so downstream tasks can type-hint it
- directly. The class must be defined at module scope -- nested classes
+ directly, except inside a list of types, where it is returned as a
``dict``
+ because the worker registers only the classes it finds in a single
type or a union.
+ The class must be defined at module scope -- nested classes
cannot be deserialized from XCom.
:param toolsets: List of pydantic-ai toolsets the agent can use
(e.g. ``SQLToolset``, ``HookToolset``). The connection IDs of
@@ -491,11 +478,11 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin, Usa
:param tool_approval_assigned_users: Users allowed to decide. ``None``
(default)
leaves it to anyone who can act on the task's Required Actions.
- :param serialize_output: If ``True`` and ``output_type`` is a Pydantic
- ``BaseModel`` subclass, the model instance is dumped to a ``dict`` via
- ``model_dump()`` before being pushed to XCom. Default ``False`` --
- the Pydantic instance flows through XCom unchanged. Set to ``True``
- when a downstream consumer needs the dict shape.
+ :param serialize_output: If ``True``, an output that is itself a Pydantic
``BaseModel`` is
+ dumped with ``model_dump()`` before being pushed to XCom, so ``date``
and ``Decimal``
+ fields keep their types. Default ``False``: a model whose class the
worker registers from
+ ``output_type`` flows through XCom unchanged. Some outputs are pushed
as dicts either way;
+ see :ref:`When a model arrives as a dict
<structured-output-dict-shape>`.
"""
deserialization_allowed_class_fields: ClassVar[tuple[str, ...]] =
("output_type",)
@@ -527,7 +514,7 @@ class AgentOperator(CancellableAgentRunMixin, BaseOperator,
HITLReviewMixin, Usa
model_id: str | None = None,
fallback_conn_ids: list[str] | None = None,
system_prompt: str = "",
- output_type: type = str,
+ output_type: OutputSpec[Any] | UnionType = str,
toolsets: list[AbstractToolset] | None = None,
capabilities: list[AgentCapability[Any]] | None = None,
enable_tool_logging: bool = True,
@@ -1140,20 +1127,7 @@ class AgentOperator(CancellableAgentRunMixin,
BaseOperator, HITLReviewMixin, Usa
"""Return the XCom value, with dicts in place of models that a
downstream task could not load."""
if not self.do_xcom_push:
return output
- if self._serialize_model_output and isinstance(output, BaseModel):
- return output.model_dump()
- if not _CORE_WALKER:
- # iter_pydantic_models could not be imported, so the worker
registers no class.
- # _serialize_model_output is always True when that import fails,
so a model at the top level was
- # already dumped above. The class of a model inside a list, tuple
or dict is not registered
- # either, so this call passes an empty set of registered classes
to dump that model too.
- return _dump_unregistered_models(output, set())
- # A downstream task can load a model from XCom only if the worker
registered the model's class.
- # The worker registers the classes that iter_pydantic_models finds in
output_type.
- # iter_pydantic_models finds no class in a list of output types, an
output marker, an output
- # function, a TypeAliasType or a NewType. For these output types the
operator pushes each model
- # as a dict, including models inside a list, tuple or dict.
- return _dump_unregistered_models(output,
set(iter_pydantic_models(self.output_type)))
+ return dump_output_for_xcom(output, self.output_type,
serialize_output=self._serialize_model_output)
def _pause_for_tool_approval(self, context: Context, result: Any, *,
attempt_usage: RunUsage) -> NoReturn:
"""
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
index 5c4eceafd2e..0ed6273962f 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/operators/llm.py
@@ -23,7 +23,6 @@ from datetime import timedelta
from functools import cached_property
from typing import TYPE_CHECKING, Any, ClassVar, Literal
-from pydantic import BaseModel
from pydantic_ai.usage import RunUsage
from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
@@ -56,7 +55,11 @@ from airflow.providers.common.ai.utils.logging import (
log_run_summary,
log_run_usage,
)
-from airflow.providers.common.ai.utils.output_type import dump_output_to_json,
rehydrate_pydantic_output
+from airflow.providers.common.ai.utils.output_type import (
+ dump_output_for_xcom,
+ dump_output_to_json,
+ rehydrate_pydantic_output,
+)
from airflow.providers.common.ai.utils.prompt import describe_prompt
from airflow.providers.common.ai.utils.usage import coerce_usage_limits
from airflow.providers.common.ai.utils.usage_budget import copy_run_usage,
subtract_run_usage
@@ -82,6 +85,7 @@ if TYPE_CHECKING:
from pydantic_ai import Agent
from pydantic_ai.agent import AgentRunResult
+ from pydantic_ai.output import OutputSpec
from pydantic_ai.settings import ModelSettings
from pydantic_ai.usage import UsageLimits
@@ -104,6 +108,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator,
LLMApprovalMixin, Usag
via a Pydantic ``BaseModel``. When ``output_type`` is a ``BaseModel``
subclass,
the model instance is returned to XCom unchanged so downstream tasks can
type-hint it directly (e.g. ``def downstream(result: MyModel) -> None``).
+ Inside a list of types (``[MyModel, int]``) the model is returned as a
``dict`` instead.
The class is auto-registered for deserialization in each process that
parses
the DAG, so no edit to ``[core] allowed_deserialization_classes`` is
required.
The Pydantic class must be defined at module scope: classes nested inside
@@ -143,9 +148,14 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator,
LLMApprovalMixin, Usag
:param system_prompt: System-level instructions for the LLM agent.
:param output_type: Expected output type. Default ``str``. A single type
(``str``, ``int``, ``list[str]``, a Pydantic ``BaseModel`` subclass)
or a
- union (``A | B``). For a ``BaseModel`` subclass, the model instance is
+ union (``A | B``). A list of types (``[str, int]``) is also accepted
and treated
+ as alternatives (a union). pydantic-ai output markers
(``ToolOutput(...)``,
+ ``NativeOutput(...)``) and output functions are also accepted.
+ For a ``BaseModel`` subclass, the model instance is
returned to XCom unchanged so downstream tasks can type-hint it
- directly. The class must be defined at module scope -- nested classes
+ directly, except inside a list of types, where it is returned as a
``dict``
+ because the worker registers only the classes it finds in a single
type or a union.
+ The class must be defined at module scope -- nested classes
cannot be deserialized from XCom.
:param model_settings: Pydantic AI
:class:`~pydantic_ai.settings.ModelSettings`
for the model call, such as ``temperature`` or ``max_tokens``,
including
@@ -228,12 +238,11 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator,
LLMApprovalMixin, Usag
connection does not silently switch off a control the author set.
Independent of
``require_approval``, which always asks. ``on_uncertain="review"``
needs Airflow 3.1+,
like ``require_approval``. Default ``None``: no gate, behaviour
unchanged.
- :param serialize_output: If ``True`` and ``output_type`` is a Pydantic
- ``BaseModel`` subclass, the model instance is dumped to a ``dict`` via
- ``model_dump()`` before being pushed to XCom. Default ``False`` --
- the Pydantic instance flows through XCom unchanged. Set to ``True``
- when a downstream consumer needs the dict shape (e.g. sending to an
- external system that expects JSON-style payloads).
+ :param serialize_output: If ``True``, an output that is itself a Pydantic
``BaseModel`` is
+ dumped with ``model_dump()`` before being pushed to XCom, so ``date``
and ``Decimal``
+ fields keep their types. Default ``False``: a model whose class the
worker registers from
+ ``output_type`` flows through XCom unchanged. Some outputs are pushed
as dicts either way;
+ see :ref:`When a model arrives as a dict
<structured-output-dict-shape>`.
"""
deserialization_allowed_class_fields: ClassVar[tuple[str, ...]] =
("output_type",)
@@ -263,7 +272,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator,
LLMApprovalMixin, Usag
model_id: str | None = None,
fallback_conn_ids: list[str] | None = None,
system_prompt: str = "",
- output_type: type | UnionType = str,
+ output_type: OutputSpec[Any] | UnionType = str,
model_settings: ModelSettings | None = None,
output_retries: int | None = None,
usage_limits: UsageLimits | dict[str, Any] | None = None,
@@ -431,11 +440,7 @@ class LLMOperator(CancellableAgentRunMixin, BaseOperator,
LLMApprovalMixin, Usag
)
self.defer_for_approval(context, output, body=body,
decision=record) # type: ignore[misc]
- if self._serialize_model_output and isinstance(output, BaseModel):
- # ``serialize_output=True``, or an ``apache-airflow-task-sdk``
version without the worker-side
- # deserialization-class walk: dump to a dict so XCom carries a
plain
- # JSON payload that deserializes without an allow-list entry.
- output = output.model_dump()
+ output = dump_output_for_xcom(output, self.output_type,
serialize_output=self._serialize_model_output)
self._clear_usage_budget(context)
return output
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
index 08b37247df8..1bb2997a4e0 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/operators/llm_file_analysis.py
@@ -21,10 +21,9 @@ from __future__ import annotations
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar
-from pydantic import BaseModel
-
from airflow.providers.common.ai.operators.llm import LLMOperator
from airflow.providers.common.ai.utils.file_analysis import
build_file_analysis_request
+from airflow.providers.common.ai.utils.output_type import dump_output_for_xcom
from airflow.providers.common.ai.utils.usage import coerce_usage_limits
if TYPE_CHECKING:
@@ -174,12 +173,7 @@ class LLMFileAnalysisOperator(LLMOperator):
if self.require_approval:
self.defer_for_approval(context, output) # type: ignore[misc]
- if self._serialize_model_output and isinstance(output, BaseModel):
- # Honour ``serialize_output=True`` and the older-Airflow fallback,
- # matching ``LLMOperator.execute``. ``LLMFileAnalysisOperator``
- # overrides ``execute`` rather than calling ``super().execute()``,
- # so the dump step has to be repeated here.
- output = output.model_dump()
+ output = dump_output_for_xcom(output, self.output_type,
serialize_output=self._serialize_model_output)
self._clear_usage_budget(context)
return output
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
index 2eda37a280e..b8fa3a2cf77 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/datafusion.py
@@ -39,12 +39,13 @@ from airflow.providers.common.ai.utils.masking import
dumps_masked
from airflow.providers.common.ai.utils.query_results import (
DEFAULT_MAX_COLUMNS,
DEFAULT_MAX_RESULT_BYTES,
- GET_SCHEMA_TOOL_DESCRIPTION as _GET_SCHEMA_DESCRIPTION,
QUERY_TOOL_DESCRIPTION as _QUERY_DESCRIPTION,
+ build_get_schema_description,
build_query_result,
build_schema_result,
)
from airflow.providers.common.ai.utils.tool_definition import
build_args_validator
+from airflow.providers.common.ai.utils.tool_prefix import ToolPrefixMixin
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset,
validate_max_retries
if TYPE_CHECKING:
@@ -92,7 +93,7 @@ _RETRYABLE_QUERY_ERROR_PATTERNS = (
)
-class DataFusionToolset(AirflowToolset):
+class DataFusionToolset(ToolPrefixMixin, AirflowToolset):
"""
Curated toolset that gives an LLM agent SQL access to object-storage data
via Apache DataFusion.
@@ -137,6 +138,10 @@ class DataFusionToolset(AirflowToolset):
the full list is replaced by a bounded summary (column count, a type
histogram, a
sample of columns) that points the agent at the ``name_contains``
filter, so a
several-thousand-column table cannot exhaust the context before a
query is written.
+ :param tool_prefix: Optional prefix joined to each tool name with an
underscore
+ (e.g. ``"lake"`` produces ``"lake_query"``). Must be a valid Python
identifier. Set it
+ when one agent has another toolset with the same tool names, such as a
``SQLToolset``,
+ since duplicate tool names are rejected.
:param max_retries: How many times the model may correct a failed call to
one of these
tools before the run fails. ``None`` (the default) uses the agent's
tool retry
budget, its ``retries``, as pydantic-ai's own toolsets do.
@@ -150,9 +155,11 @@ class DataFusionToolset(AirflowToolset):
max_rows: int = 50,
max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES,
max_columns: int = DEFAULT_MAX_COLUMNS,
+ tool_prefix: str = "",
max_retries: int | None = None,
) -> None:
self._max_retries = validate_max_retries(max_retries)
+ self._set_tool_prefix(tool_prefix)
if not datasource_configs:
raise ValueError("datasource_configs must contain at least one
DataSourceConfig")
self._datasource_configs = datasource_configs
@@ -165,7 +172,7 @@ class DataFusionToolset(AirflowToolset):
@property
def id(self) -> str:
suffix = "_".join(config.table_name.replace("-", "_") for config in
self._datasource_configs)
- return f"sql_datafusion_{suffix}"
+ return f"sql_datafusion_{suffix}{self._get_tool_prefix_id_suffix('_')}"
def _get_engine(self) -> DataFusionEngine:
"""Lazily create and configure a DataFusionEngine from
*datasource_configs*."""
@@ -180,11 +187,16 @@ class DataFusionToolset(AirflowToolset):
max_retries = self._get_tool_max_retries(ctx)
tools: dict[str, ToolsetTool[Any]] = {}
- for name, description, schema in (
+ for base, description, schema in (
("list_tables", "List available table names.",
_LIST_TABLES_SCHEMA),
- ("get_schema", _GET_SCHEMA_DESCRIPTION, _GET_SCHEMA_SCHEMA),
+ (
+ "get_schema",
+
build_get_schema_description(tool_name=self._build_tool_name("get_schema")),
+ _GET_SCHEMA_SCHEMA,
+ ),
("query", _QUERY_DESCRIPTION, _QUERY_SCHEMA),
):
+ name = self._build_tool_name(base)
tool_def = ToolDefinition(
name=name,
description=description,
@@ -207,13 +219,14 @@ class DataFusionToolset(AirflowToolset):
ctx: RunContext[Any],
tool: ToolsetTool[Any],
) -> Any:
- if name == "list_tables":
+ base = self._find_base_tool_name(name)
+ if base == "list_tables":
return await self.run_blocking(self._list_tables)
- if name == "get_schema":
+ if base == "get_schema":
return await self.run_blocking(
self._get_schema, tool_args["table_name"],
tool_args.get("name_contains")
)
- if name == "query":
+ if base == "query":
return await self.run_blocking(self._query, tool_args["sql"])
raise ValueError(f"Unknown tool: {name!r}")
@@ -245,6 +258,7 @@ class DataFusionToolset(AirflowToolset):
max_columns=self._max_columns,
max_result_bytes=self._max_result_bytes,
name_contains=name_contains,
+ tool_name=self._build_tool_name("get_schema"),
)
def _query(self, sql: str) -> str:
@@ -277,7 +291,8 @@ class DataFusionToolset(AirflowToolset):
except QueryExecutionException as ex:
if self._is_retryable_query_error(ex):
raise ModelRetry(
- f"error: {ex!s}, Use get_schema and list_tables tools for
more details."
+ f"error: {ex!s}, Use {self._build_tool_name('get_schema')}
and "
+ f"{self._build_tool_name('list_tables')} tools for more
details."
) from ex
return dumps_masked({"error": str(ex), "query": sql})
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
index 7c7f57c6c4b..a3b03d931dc 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
@@ -39,6 +39,7 @@ from airflow.providers.common.ai.utils.tool_definition import
(
return_schema_kwargs,
serialize_for_llm,
)
+from airflow.providers.common.ai.utils.tool_prefix import validate_tool_prefix
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset,
validate_max_retries
if TYPE_CHECKING:
@@ -156,10 +157,7 @@ class HookToolset(AirflowToolset):
pinned_arguments: dict[str, Any] | None = None,
max_retries: int | None = None,
) -> None:
- if tool_prefix and not tool_prefix.isidentifier():
- # The prefixed names are rendered as Python function signatures
under
- # code mode, so a name that is not an identifier breaks there.
- raise ValueError(f"tool_prefix must be a valid Python identifier,
got {tool_prefix!r}.")
+ validate_tool_prefix(tool_prefix)
if tool_name_prefix is not None:
if tool_prefix and f"{tool_prefix}_" != tool_name_prefix:
raise ValueError(
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
index 43b860995ea..71c2d4f9493 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/mcp.py
@@ -67,8 +67,8 @@ class MCPToolset(AirflowToolset):
:param mcp_conn_id: Airflow connection ID for the MCP server. Templated
when
the toolset is passed to ``AgentOperator`` / ``@task.agent``.
- :param tool_prefix: Optional prefix prepended to tool names
- (e.g. ``"weather"`` → ``"weather_get_forecast"``).
+ :param tool_prefix: Optional prefix joined to each tool name with an
underscore
+ (e.g. ``"weather"`` → ``"weather_get_forecast"``). Defaults to ``""``
(no prefix).
:param token_provider: Optional zero-argument callable returning a bearer
token string, overriding the connection ``password`` for HTTP/SSE auth.
Called once, the first time this toolset establishes a connection.
@@ -86,7 +86,7 @@ class MCPToolset(AirflowToolset):
self,
mcp_conn_id: str,
*,
- tool_prefix: str | None = None,
+ tool_prefix: str = "",
token_provider: Callable[[], str] | None = None,
env_provider: Callable[[], dict[str, str]] | None = None,
) -> None:
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/object_storage.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/object_storage.py
index 53e2217485d..ee72c6ff01e 100644
---
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/object_storage.py
+++
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/object_storage.py
@@ -44,6 +44,7 @@ from airflow.providers.common.ai.utils.tool_definition import
(
return_schema_kwargs,
serialize_for_llm,
)
+from airflow.providers.common.ai.utils.tool_prefix import ToolPrefixMixin
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset,
validate_max_retries
from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException, ObjectStoragePath
@@ -109,7 +110,7 @@ _COLUMNAR_FORMATS: tuple[Literal["parquet", "avro"], ...] =
("parquet", "avro")
_MEDIA_FORMATS = frozenset({"jpeg", "jpg", "pdf", "png"})
-class ObjectStorageToolset(AirflowToolset):
+class ObjectStorageToolset(ToolPrefixMixin, AirflowToolset):
"""
Give an agent read-only access to the files under one object-storage path.
@@ -171,28 +172,22 @@ class ObjectStorageToolset(AirflowToolset):
):
if value < 1:
raise ValueError(f"{name} must be at least 1, got {value}.")
- if tool_prefix and not tool_prefix.isidentifier():
- raise ValueError(f"tool_prefix must be a valid Python identifier,
got {tool_prefix!r}.")
+ self._set_tool_prefix(tool_prefix)
self._path = path
self._conn_id = conn_id
self._max_files = max_files
self._max_read_bytes = max_read_bytes
self._max_output_bytes = max_output_bytes
- self._tool_prefix = tool_prefix
@property
def id(self) -> str:
- suffix = f"-{self._tool_prefix}" if self._tool_prefix else ""
- return f"object-storage-{self._conn_id or 'default'}{suffix}"
-
- def _tool_name(self, base: str) -> str:
- return f"{self._tool_prefix}_{base}" if self._tool_prefix else base
+ return f"object-storage-{self._conn_id or
'default'}{self._get_tool_prefix_id_suffix()}"
async def get_tools(self, ctx: RunContext[Any]) -> dict[str,
ToolsetTool[Any]]:
max_retries = self._get_tool_max_retries(ctx)
tools: dict[str, ToolsetTool[Any]] = {}
for base, schema in _SCHEMAS.items():
- name = self._tool_name(base)
+ name = self._build_tool_name(base)
tools[name] = ToolsetTool(
toolset=self,
tool_def=ToolDefinition(
@@ -214,7 +209,7 @@ class ObjectStorageToolset(AirflowToolset):
ctx: RunContext[Any],
tool: ToolsetTool[Any],
) -> str:
- base = name.removeprefix(f"{self._tool_prefix}_") if self._tool_prefix
else name
+ base = self._find_base_tool_name(name)
relative = tool_args.get("path") or ""
try:
if base == LIST_FILES:
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
index a03c968b390..4424c5df932 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sandbox.py
@@ -55,6 +55,7 @@ from airflow.providers.common.ai.utils.tool_definition import
(
code_arg_kwargs,
return_schema_kwargs,
)
+from airflow.providers.common.ai.utils.tool_prefix import ToolPrefixMixin
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset
from airflow.providers.common.compat.sdk import ObjectStoragePath,
get_current_context
@@ -167,7 +168,7 @@ _DESCRIPTIONS = {
}
-class SandboxToolset(AirflowToolset):
+class SandboxToolset(ToolPrefixMixin, AirflowToolset):
"""
Give an agent shell and file access inside a disposable sandbox, off the
Airflow worker.
@@ -291,10 +292,7 @@ class SandboxToolset(AirflowToolset):
f"default_command_timeout ({default_command_timeout}) must not
exceed "
f"max_command_timeout ({max_command_timeout})."
)
- if tool_prefix and not tool_prefix.isidentifier():
- # The prefixed names are rendered as Python function signatures
under
- # code mode, so a name that is not an identifier breaks there.
- raise ValueError(f"tool_prefix must be a valid Python identifier,
got {tool_prefix!r}.")
+ self._set_tool_prefix(tool_prefix)
if attach_to is not None:
if not isinstance(backend, AttachableSandboxBackend):
raise ValueError(
@@ -355,7 +353,6 @@ class SandboxToolset(AirflowToolset):
self._exports = dict(exports or {})
self._export_conn_id = export_conn_id
self._max_export_bytes = int(max_export_bytes)
- self._tool_prefix = tool_prefix
self._sandbox: str | None = None
self._create_future: concurrent.futures.Future[str] | None = None
# Set while attached: who this run claimed the sandbox as, when the
sandbox ends
@@ -378,19 +375,10 @@ class SandboxToolset(AirflowToolset):
@property
def id(self) -> str:
- suffix = f"-{self._tool_prefix}" if self._tool_prefix else ""
- return f"sandbox-{self._backend.name}{suffix}"
-
- def _tool_name(self, base: str) -> str:
- return f"{self._tool_prefix}_{base}" if self._tool_prefix else base
+ return
f"sandbox-{self._backend.name}{self._get_tool_prefix_id_suffix()}"
def _base_name(self, tool_name: str) -> str | None:
- if not self._tool_prefix:
- return tool_name if tool_name in _SCHEMAS else None
- prefix = f"{self._tool_prefix}_"
- if not tool_name.startswith(prefix):
- return None
- base = tool_name[len(prefix) :]
+ base = self._find_base_tool_name(tool_name)
return base if base in _SCHEMAS else None
async def for_run(self, ctx: RunContext[Any]) -> AbstractToolset[Any]:
@@ -860,7 +848,7 @@ class SandboxToolset(AirflowToolset):
# run_command additionally carries code_arg metadata so code mode
# leaves it native instead of folding a shell surface into
run_code.
extra = code_arg_kwargs("command", "shell") if base == RUN_COMMAND
else {}
- name = self._tool_name(base)
+ name = self._build_tool_name(base)
description = _DESCRIPTIONS[base]
if base == RUN_COMMAND:
description = f"{description}
{self._network_note}{self._export_note}"
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
index 1311c9ee157..5da4eaaf48e 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/sql.py
@@ -44,12 +44,13 @@ from airflow.providers.common.ai.utils.masking import
dumps_masked
from airflow.providers.common.ai.utils.query_results import (
DEFAULT_MAX_COLUMNS,
DEFAULT_MAX_RESULT_BYTES,
- GET_SCHEMA_TOOL_DESCRIPTION as _GET_SCHEMA_DESCRIPTION,
QUERY_TOOL_DESCRIPTION as _QUERY_DESCRIPTION,
+ build_get_schema_description,
build_query_result,
build_schema_result,
)
from airflow.providers.common.ai.utils.tool_definition import
build_args_validator, return_schema_kwargs
+from airflow.providers.common.ai.utils.tool_prefix import ToolPrefixMixin
from airflow.providers.common.ai.utils.toolset_base import AirflowToolset,
validate_max_retries
from airflow.providers.common.compat.sdk import BaseHook
@@ -173,7 +174,7 @@ class _CappedFetch:
return rows
-class SQLToolset(AirflowToolset):
+class SQLToolset(ToolPrefixMixin, AirflowToolset):
"""
Curated toolset that gives an LLM agent safe access to a SQL database.
@@ -266,6 +267,10 @@ class SQLToolset(AirflowToolset):
the full list is replaced by a bounded summary (column count, a type
histogram, a
sample of columns) that points the agent at the ``name_contains``
filter, so a
several-thousand-column table cannot exhaust the context before a
query is written.
+ :param tool_prefix: Optional prefix joined to each tool name with an
underscore
+ (e.g. ``"pg"`` produces ``"pg_query"``). Must be a valid Python
identifier. Set it
+ when one agent has another toolset with the same tool names, such as a
second
+ ``SQLToolset``, since duplicate tool names are rejected.
:param max_retries: How many times the model may correct a failed call to
one of these
tools before the run fails. ``None`` (the default) uses the agent's
tool retry
budget, its ``retries``, as pydantic-ai's own toolsets do.
@@ -286,9 +291,11 @@ class SQLToolset(AirflowToolset):
max_rows: int = 50,
max_result_bytes: int = DEFAULT_MAX_RESULT_BYTES,
max_columns: int = DEFAULT_MAX_COLUMNS,
+ tool_prefix: str = "",
max_retries: int | None = None,
) -> None:
self._max_retries = validate_max_retries(max_retries)
+ self._set_tool_prefix(tool_prefix)
self._allowed_tables: frozenset[str] | None
if allowed_tables is _UNSET:
self._allowed_tables = None
@@ -369,7 +376,7 @@ class SQLToolset(AirflowToolset):
@property
def id(self) -> str:
- return f"sql-{self._db_conn_id}"
+ return f"sql-{self._db_conn_id}{self._get_tool_prefix_id_suffix()}"
# ------------------------------------------------------------------
# Lazy hook resolution
@@ -395,9 +402,13 @@ class SQLToolset(AirflowToolset):
max_retries = self._get_tool_max_retries(ctx)
tools: dict[str, ToolsetTool[Any]] = {}
- for name, description, schema in (
+ for base, description, schema in (
("list_tables", "List available table names in the database.",
_LIST_TABLES_SCHEMA),
- ("get_schema", _GET_SCHEMA_DESCRIPTION, _GET_SCHEMA_SCHEMA),
+ (
+ "get_schema",
+
build_get_schema_description(tool_name=self._build_tool_name("get_schema")),
+ _GET_SCHEMA_SCHEMA,
+ ),
("query", _QUERY_DESCRIPTION, _QUERY_SCHEMA),
("check_query", "Validate SQL syntax without executing it.",
_CHECK_QUERY_SCHEMA),
):
@@ -406,6 +417,7 @@ class SQLToolset(AirflowToolset):
# blocking calls of the other toolsets that use it.
# return_schema is "string": every tool returns a JSON-encoded
string
# (json.dumps), so code mode renders `-> str` instead of `-> Any`.
+ name = self._build_tool_name(base)
tool_def = ToolDefinition(
name=name,
description=description,
@@ -429,10 +441,11 @@ class SQLToolset(AirflowToolset):
ctx: RunContext[Any],
tool: ToolsetTool[Any],
) -> Any:
- if name not in ("list_tables", "get_schema", "query", "check_query"):
+ base = self._find_base_tool_name(name)
+ if base not in ("list_tables", "get_schema", "query", "check_query"):
raise ValueError(f"Unknown tool: {name!r}")
try:
- return await self.run_blocking(self._run_tool, name, tool_args)
+ return await self.run_blocking(self._run_tool, base, tool_args)
except Exception as e:
# Hand the database's own error back to the agent as a retry so it
can
# read the message and fix its SQL within the run. pydantic-ai
bounds
@@ -441,7 +454,8 @@ class SQLToolset(AirflowToolset):
# for Airflow to retry, rather than being silently worked around.
raise ModelRetry(
f"The {name} tool failed: {e}\n"
- "Use the list_tables and get_schema tools to inspect the
database, "
+ f"Use the {self._build_tool_name('list_tables')} and
{self._build_tool_name('get_schema')} tools "
+ "to inspect the database, "
"then fix the query and try again."
) from e
@@ -504,6 +518,7 @@ class SQLToolset(AirflowToolset):
max_columns=self._max_columns,
max_result_bytes=self._max_result_bytes,
name_contains=name_contains,
+ tool_name=self._build_tool_name("get_schema"),
)
def _dialect_for_validation(self) -> str | None:
@@ -593,7 +608,7 @@ class SQLToolset(AirflowToolset):
raise SQLSafetyError(
f"Query uses a data source that cannot be checked against
allowed_tables: "
f"{'; '.join(scan.unverifiable_sources)}. Query the allowed
tables directly: "
- f"use list_tables to see them."
+ f"use {self._build_tool_name('list_tables')} to see them."
)
disallowed = [
".".join(part for part in (catalog, schema, table) if part)
@@ -603,5 +618,5 @@ class SQLToolset(AirflowToolset):
if disallowed:
raise SQLSafetyError(
f"Query references tables that are not in the allowed tables
list: "
- f"{', '.join(sorted(set(disallowed)))}. Use list_tables to see
the allowed tables."
+ f"{', '.join(sorted(set(disallowed)))}. Use
{self._build_tool_name('list_tables')} to see the allowed tables."
)
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
index 70de2cd73b4..03ce698608a 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/output_type.py
@@ -20,14 +20,22 @@ from __future__ import annotations
import inspect
import logging
+import operator
+from collections.abc import Iterator
+from functools import reduce
from typing import Any, get_origin
from pydantic import BaseModel, TypeAdapter, ValidationError
from pydantic.errors import PydanticUserError
-from pydantic_core import CoreSchema, PydanticSerializationError
+from pydantic_core import CoreSchema, PydanticSerializationError,
to_jsonable_python
from airflow.providers.common.ai.exceptions import
ReviewedOutputValidationError
+try:
+ from airflow.sdk.serde import iter_pydantic_models
+except ImportError: # pragma: no cover - missing ``apache-airflow-task-sdk``
walker
+ iter_pydantic_models = None # type: ignore[assignment]
+
log = logging.getLogger(__name__)
_MAX_SHOWN_CHARS = 200
@@ -38,6 +46,59 @@ _MAX_SHOWN_CHARS = 200
_CONVERSION_ERRORS = (ValidationError, ValueError, TypeError, AttributeError,
KeyError, IndexError)
+def _is_model_dump_required(output: Any, output_type: Any) -> bool:
+ """
+ Tell whether an output holding ``BaseModel`` instances must be pushed to
XCom dumped.
+
+ The instances are looked up in the output itself, as
``_iter_models_in_output`` walks it: the elements
+ of a list, tuple (a NamedTuple included), set or frozenset, and the keys
and values of a dict. A
+ downstream task can load a model from XCom only if the worker registered
the model's class, and the
+ worker registers the classes ``iter_pydantic_models`` finds in
+ ``output_type``. It finds none in a list of output types, an output
marker, an output function, a
+ ``TypeAliasType`` or a ``NewType``, so an instance of a class it did not
find is dumped. Without
+ ``iter_pydantic_models`` (Airflow < 3.3 or an older task-sdk) the worker
registers no class, so every
+ model in the output counts as unregistered and is dumped.
+ """
+ registered = set() if iter_pydantic_models is None else
set(iter_pydantic_models(output_type))
+ return any(type(model) not in registered for model in
_iter_models_in_output(output))
+
+
+def dump_output_for_xcom(output: Any, output_type: Any, *, serialize_output:
bool) -> Any:
+ """
+ Return the output as it is pushed to XCom.
+
+ With ``serialize_output`` a model output is dumped with ``model_dump()``,
as 1.0.0 does, so a ``date``
+ or ``Decimal`` field keeps its type. Any other output is dumped, whole and
to JSON values, only when it
+ holds a model the worker registers no class for, with field names rather
than aliases, so a ``HttpUrl``,
+ ``Path`` or ``Enum`` field is a string that XCom accepts. Registered
models, in a container or not, are
+ left for the serde serializer.
+ """
+ if serialize_output and isinstance(output, BaseModel):
+ return output.model_dump()
+ if not _is_model_dump_required(output, output_type):
+ return output
+ return to_jsonable_python(output, by_alias=False)
+
+
+def _iter_models_in_output(output: Any) -> Iterator[BaseModel]:
+ """
+ Yield the ``BaseModel`` instances in an output.
+
+ Looks inside list, tuple (a NamedTuple included), set, frozenset and dict.
Dataclass contents and
+ model fields are not walked.
+ """
+ stack = [output]
+ while stack:
+ item = stack.pop()
+ if isinstance(item, BaseModel):
+ yield item
+ elif isinstance(item, (set, frozenset, list, tuple)):
+ stack.extend(item)
+ elif isinstance(item, dict):
+ stack.extend(item)
+ stack.extend(item.values())
+
+
def dump_output_to_json(output: Any) -> str:
"""Serialize an output as JSON that ``rehydrate_pydantic_output``
validates back, or ``str()`` if pydantic cannot."""
# ``round_trip`` drops computed fields, and ``by_alias=False`` keeps field
names even when a model
@@ -72,7 +133,7 @@ def rehydrate_pydantic_output(
"""
Turn the reviewed string back into ``output_type``.
- ``raw`` is JSON, or bare text for types pydantic builds from a string.
``str`` outputs, output
+ ``raw`` is JSON, or bare text for types pydantic builds from a string. A
list of candidate types is validated as their union. ``str`` outputs, output
functions, pydantic-ai output markers such as ``ToolOutput`` and types
pydantic has no schema for
return ``raw`` unchanged, and so does an unedited output that does not
validate back (with a warning).
@@ -82,11 +143,19 @@ def rehydrate_pydantic_output(
"""
if output_type is str:
return raw
+ declared_type = output_type
+ if isinstance(output_type, (list, tuple)):
+ # pydantic-ai treats a list of output types as "any of these".
Unioning an empty list or output
+ # markers is a ``TypeError``, and leaves the output as it is.
+ try:
+ output_type = reduce(operator.or_, output_type)
+ except TypeError:
+ return raw
+ # A pydantic-ai output marker (``ToolOutput``, ``NativeOutput``, ...) is
not a type, and
+ # ``TypeAdapter`` fails on it with an ``AttributeError``.
try:
adapter: TypeAdapter[Any] = TypeAdapter(output_type)
is_output_function = _is_output_function(adapter.core_schema)
- # A pydantic-ai output marker (``ToolOutput``, ``NativeOutput``, ...) is
not a type, and
- # ``TypeAdapter`` fails on it with an ``AttributeError``.
except (PydanticUserError, AttributeError):
return raw
if is_output_function:
@@ -105,9 +174,7 @@ def rehydrate_pydantic_output(
log.warning("%s Returning the output unchanged.", message)
return raw
raise ReviewedOutputValidationError(message) from json_error
- if serialize_output and isinstance(rehydrated, BaseModel):
- return rehydrated.model_dump()
- return rehydrated
+ return dump_output_for_xcom(rehydrated, declared_type,
serialize_output=serialize_output)
def _validate_bare_text(adapter: TypeAdapter[Any], raw: str) -> Any:
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/query_results.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/query_results.py
index 09b03c17cf4..eff89771fdc 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/utils/query_results.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/query_results.py
@@ -86,19 +86,24 @@ QUERY_TOOL_DESCRIPTION = (
#: unlike query rows they cannot be silently dropped: the description states
the ``name_contains``
#: filter and the summary shape so a truncated result is read as "narrow your
request", not as a
#: small table.
-GET_SCHEMA_TOOL_DESCRIPTION = (
+_GET_SCHEMA_DESCRIPTION_TEMPLATE = (
"Get a table's columns. Returns JSON of the form "
- '{"columns": [{"name": ..., "type": ...}, ...], "column_count": N}. Pass
`name_contains` to '
+ '{{"columns": [{{"name": ..., "type": ...}}, ...], "column_count": N}}.
Pass `name_contains` to '
"return only the columns whose name contains that substring
(case-insensitive) -- use it to "
"find the columns relevant to your question on a wide table. When a table
has more columns "
"than can be returned at once, the full list is replaced by a summary
(`column_count`, and "
"where the budget allows a `type_histogram` and a `sample_columns`
preview) with `truncated` "
- "set, `truncated_by` naming the limit that was hit, and a `hint`. Call
get_schema again with "
+ "set, `truncated_by` naming the limit that was hit, and a `hint`. Call
{tool_name} again with "
"a `name_contains` substring to retrieve the specific columns you need
instead of the whole "
"table."
)
+def build_get_schema_description(*, tool_name: str = "get_schema") -> str:
+ """Render the ``get_schema`` tool description naming the tool as the model
sees it."""
+ return _GET_SCHEMA_DESCRIPTION_TEMPLATE.format(tool_name=tool_name)
+
+
def _dumps(payload: Any) -> str:
return dumps_masked(payload, **_DUMP_KWARGS)
@@ -222,6 +227,7 @@ def build_schema_result(
max_columns: int,
max_result_bytes: int,
name_contains: str | None = None,
+ tool_name: str = "get_schema",
) -> str:
"""
Render a table's columns as a bounded JSON tool result.
@@ -238,6 +244,8 @@ def build_schema_result(
:param name_contains: Case-insensitive substring. When given (and
non-empty), only matching
columns are considered and the value is echoed back so a filtered
subset is never mistaken
for the whole table.
+ :param tool_name: Name of the ``get_schema`` tool as the model sees it
(including any
+ ``tool_prefix``), so hints point at this toolset's own tool.
"""
name_contains = name_contains or None
total_columns = len(columns)
@@ -258,7 +266,7 @@ def build_schema_result(
)
else:
advice = (
- f"No columns match name_contains={name_contains!r}. Call
get_schema without "
+ f"No columns match name_contains={name_contains!r}. Call
{tool_name} without "
f"name_contains to list all {total_columns} column{plural}."
)
return _dumps(
@@ -284,6 +292,7 @@ def build_schema_result(
max_columns=max_columns,
max_result_bytes=max_result_bytes,
name_contains=name_contains,
+ tool_name=tool_name,
)
@@ -294,6 +303,7 @@ def _summarize_schema(
max_columns: int,
max_result_bytes: int,
name_contains: str | None,
+ tool_name: str,
) -> str:
"""Build the bounded summary returned when the full column list does not
fit."""
# Count is checked before bytes: it is the cheaper, more explainable
bound. A bytes-only
@@ -309,7 +319,7 @@ def _summarize_schema(
move = (
"Use a more specific name_contains substring to narrow to the columns
you need."
if name_contains is not None
- else "Call get_schema with a name_contains substring to return only
the columns you need."
+ else f"Call {tool_name} with a name_contains substring to return only
the columns you need."
)
output: dict[str, Any] = {
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/utils/tool_prefix.py
b/providers/common/ai/src/airflow/providers/common/ai/utils/tool_prefix.py
new file mode 100644
index 00000000000..d7c2a3ee7fc
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/utils/tool_prefix.py
@@ -0,0 +1,56 @@
+# 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.
+"""The ``tool_prefix`` handling shared by the toolsets that namespace their
tool names."""
+
+from __future__ import annotations
+
+
+def validate_tool_prefix(tool_prefix: str) -> None:
+ """Raise ``ValueError`` if a non-empty ``tool_prefix`` is not a valid
Python identifier."""
+ if tool_prefix and not tool_prefix.isidentifier():
+ # The prefixed names are rendered as Python function signatures under
+ # code mode, so a name that is not an identifier breaks there.
+ raise ValueError(f"tool_prefix must be a valid Python identifier, got
{tool_prefix!r}.")
+
+
+class ToolPrefixMixin:
+ """Build, and map back from, the tool names and the toolset id that a
``tool_prefix`` produces."""
+
+ _tool_prefix: str = ""
+
+ tool_name_conflict_hint: str = (
+ "Pass a distinct `tool_prefix=...` to this toolset so its tool names
and "
+ "model-facing hints are renamed together; wrapping it in
`PrefixedToolset` "
+ "renames only the tools."
+ )
+
+ def _set_tool_prefix(self, tool_prefix: str) -> None:
+ validate_tool_prefix(tool_prefix)
+ self._tool_prefix = tool_prefix
+
+ def _build_tool_name(self, base: str) -> str:
+ return f"{self._tool_prefix}_{base}" if self._tool_prefix else base
+
+ def _find_base_tool_name(self, name: str) -> str | None:
+ """Return ``name`` without the prefix, or ``None`` when a prefix is
set and ``name`` does not carry it."""
+ if not self._tool_prefix:
+ return name
+ prefix = f"{self._tool_prefix}_"
+ return name.removeprefix(prefix) if name.startswith(prefix) else None
+
+ def _get_tool_prefix_id_suffix(self, separator: str = "-") -> str:
+ return f"{separator}{self._tool_prefix}" if self._tool_prefix else ""
diff --git a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
index d0efd7f64dd..e7e67c82e2a 100644
--- a/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
+++ b/providers/common/ai/tests/unit/common/ai/hooks/test_mcp.py
@@ -44,6 +44,9 @@ class TestMCPHookInit:
hook = MCPHook(mcp_conn_id="my_mcp")
assert hook.mcp_conn_id == "my_mcp"
+ def test_tool_prefix_defaults_to_empty_string(self):
+ assert MCPHook(mcp_conn_id="my_mcp").tool_prefix == ""
+
def test_tool_prefix(self):
hook = MCPHook(mcp_conn_id="my_mcp", tool_prefix="weather")
assert hook.tool_prefix == "weather"
@@ -99,6 +102,21 @@ class TestMCPHookGetConn:
headers={"Authorization": "Bearer my-secret-token"},
)
+ @patch(_MCP_TOOLSET, autospec=True)
+ @patch(_HTTP_TRANSPORT, autospec=True)
+ def test_empty_tool_prefix_does_not_wrap(self, mock_transport_cls,
mock_toolset_cls):
+ hook = MCPHook(mcp_conn_id="test_conn")
+ conn = Connection(
+ conn_id="test_conn",
+ conn_type="mcp",
+ host="http://localhost:3001/mcp",
+ )
+ with patch.object(hook, "get_connection", return_value=conn):
+ result = hook.get_conn()
+
+ mock_toolset_cls.return_value.prefixed.assert_not_called()
+ assert result is mock_toolset_cls.return_value
+
@patch(_MCP_TOOLSET, autospec=True)
@patch(_HTTP_TRANSPORT, autospec=True)
def test_passes_tool_prefix(self, mock_transport_cls, mock_toolset_cls):
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 66ed0f025da..2e9a92f1340 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
@@ -18,12 +18,13 @@ from __future__ import annotations
import asyncio
import dataclasses
+import inspect
import json
import logging
import sys
from collections import OrderedDict
from contextlib import nullcontext
-from datetime import timedelta
+from datetime import date, timedelta
from decimal import Decimal
from enum import Enum
from types import ModuleType, SimpleNamespace
@@ -32,7 +33,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock, call,
patch
from uuid import UUID
import pytest
-from pydantic import BaseModel, ConfigDict, HttpUrl
+from pydantic import BaseModel, ConfigDict, Field, HttpUrl
from pydantic_ai import Agent, DeferredToolRequests, RunContext, Tool,
ToolOutput
from pydantic_ai.capabilities import (
AbstractCapability,
@@ -73,6 +74,7 @@ from airflow.providers.common.ai.durable.storage import
DurableStorage
from airflow.providers.common.ai.exceptions import UnsupportedToolDeferralError
from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
from airflow.providers.common.ai.operators.agent import AgentOperator,
HITLReviewLink
+from airflow.providers.common.ai.operators.llm import LLMOperator
from airflow.providers.common.ai.sandbox.base import (
HOLDER_TAG,
OWNER_TAG,
@@ -141,6 +143,11 @@ class SummaryKind(Enum):
SHORT = "short"
+class SerializedByAlias(BaseModel):
+ model_config = ConfigDict(serialize_by_alias=True)
+ full_name: str = Field(serialization_alias="fullName")
+
+
class LinkedSummary(BaseModel):
kind: SummaryKind
url: HttpUrl
@@ -789,6 +796,21 @@ class TestAgentOperatorExecute:
"What is the answer?", usage_limits=None, run_id="ti-1",
cancellation_token=ANY, usage=ANY
)
+ def test_output_type_is_annotated_like_llm_operator(self):
+ annotation =
inspect.signature(AgentOperator.__init__).parameters["output_type"].annotation
+ assert annotation ==
inspect.signature(LLMOperator.__init__).parameters["output_type"].annotation
+
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_execute_passes_a_union_output_type_to_the_agent(self,
mock_hook_cls, make_mock_run_result):
+ mock_agent = _make_mock_agent(Summary(text="ok"), make_mock_run_result)
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = AgentOperator(task_id="test", prompt="run", llm_conn_id="my_llm",
output_type=Summary | int)
+ op.execute(context=_make_context())
+
+ expected = [Summary | int, DeferredToolRequests] if AIRFLOW_V_3_3_PLUS
else Summary | int
+ assert
mock_hook_cls.get_hook.return_value.create_agent.call_args.kwargs["output_type"]
== expected
+
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
def test_execute_keys_the_run_by_attempt_without_a_task_instance_id(
self, mock_hook_cls, make_mock_run_result
@@ -1124,7 +1146,7 @@ class TestAgentOperatorExecute:
pytest.param(
ToolOutput(tuple[Summary, int]),
(Summary(text="ok"), 1),
- ({"text": "ok", "score": 0.0}, 1),
+ [{"text": "ok", "score": 0.0}, 1],
False,
id="tuple-in-output-marker",
),
@@ -1138,14 +1160,14 @@ class TestAgentOperatorExecute:
pytest.param(
ToolOutput(list[tuple[dict[str, list[Summary]], int]]),
[({"a": [Summary(text="ok")]}, 1)],
- [({"a": [{"text": "ok", "score": 0.0}]}, 1)],
+ [[{"a": [{"text": "ok", "score": 0.0}]}, 1]],
False,
id="nested-containers-in-output-marker",
),
pytest.param(
ToolOutput(SummaryPair),
SummaryPair(Summary(text="ok"), 1),
- ({"text": "ok", "score": 0.0}, 1),
+ [{"text": "ok", "score": 0.0}, 1],
False,
id="named-tuple-in-output-marker",
),
@@ -1238,9 +1260,9 @@ class TestAgentOperatorExecute:
pytest.param(list[Summary], id="list"),
],
)
- @patch("airflow.providers.common.ai.operators.agent._CORE_WALKER", False)
+
@patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
@patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
- def test_execute_dumps_nested_models_without_the_worker_walk(
+ def test_execute_pushes_nested_models_as_dicts_without_the_worker_walk(
self, mock_hook_cls, make_mock_run_result, output_type
):
mock_hook_cls.get_hook.return_value.create_agent.return_value =
_make_mock_agent(
@@ -1253,6 +1275,51 @@ class TestAgentOperatorExecute:
result = op.execute(context=MagicMock())
assert result == [{"text": "ok", "score": 0.0}]
+ assert type(result[0]) is dict
+
+ @pytest.mark.parametrize(
+ ("output_type", "output", "serialize_output"),
+ [
+ pytest.param(
+ [SerializedByAlias, int], SerializedByAlias(full_name="Ann"),
False, id="serialize-by-alias"
+ ),
+ pytest.param(
+ ToolOutput(tuple[Summary, date]),
+ (Summary(text="ok"), date(2026, 1, 2)),
+ False,
+ id="date-next-to-model-in-tuple",
+ ),
+ ],
+ )
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ @patch("airflow.providers.common.ai.operators.agent.PydanticAIHook",
autospec=True)
+ def test_execute_pushes_the_same_value_as_the_llm_operator(
+ self,
+ mock_agent_hook_cls,
+ mock_llm_hook_cls,
+ make_mock_run_result,
+ output_type,
+ output,
+ serialize_output,
+ ):
+ mock_agent_hook_cls.get_hook.return_value.create_agent.return_value =
_make_mock_agent(
+ output, make_mock_run_result
+ )
+ mock_llm_hook_cls.get_hook.return_value.create_agent.return_value =
_make_mock_agent(
+ output, make_mock_run_result
+ )
+ kwargs = {
+ "prompt": "Analyze this",
+ "llm_conn_id": "my_llm",
+ "output_type": output_type,
+ "serialize_output": serialize_output,
+ }
+
+ agent_result = AgentOperator(task_id="agent",
**kwargs).execute(context=MagicMock())
+ llm_result = LLMOperator(task_id="llm",
**kwargs).execute(context=MagicMock())
+
+ assert agent_result == llm_result
+ assert type(agent_result) is type(llm_result)
@pytest.mark.parametrize(
"output",
@@ -1452,6 +1519,7 @@ class TestAgentOperatorExecute:
[Summary(text="ok")],
[{"text": "ok", "score": 0.0}],
id="list-in-output-marker",
+ marks=requires_typed_xcom,
),
pytest.param(
ToolOutput(LinkedSummary),
diff --git a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
index 5cac9f17d38..618bd144ccd 100644
--- a/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
+++ b/providers/common/ai/tests/unit/common/ai/operators/test_llm.py
@@ -18,13 +18,13 @@ from __future__ import annotations
import contextlib
import logging
-from datetime import timedelta
+from datetime import date, timedelta
from decimal import Decimal
from unittest.mock import ANY, MagicMock, patch
from uuid import uuid4
import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, HttpUrl
from pydantic_ai import Agent
from pydantic_ai.exceptions import UnexpectedModelBehavior, UsageLimitExceeded
from pydantic_ai.messages import BinaryContent, ModelMessage, ModelResponse,
TextPart
@@ -48,7 +48,11 @@ from tests_common.test_utils.compat import
OperatorSerialization
from tests_common.test_utils.version_compat import AIRFLOW_V_3_0_PLUS,
AIRFLOW_V_3_1_PLUS, AIRFLOW_V_3_3_PLUS
try:
- from airflow.sdk.serde import SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as
_CORE_WALKER
+ from airflow.sdk.serde import (
+ SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as _CORE_WALKER,
+ deserialize,
+ serialize,
+ )
except ImportError:
_CORE_WALKER = False
@@ -85,6 +89,14 @@ class Entities(BaseModel):
names: list[str]
+class Dated(BaseModel):
+ d: date
+
+
+class Link(BaseModel):
+ url: HttpUrl
+
+
class Summary(BaseModel):
text: str
@@ -494,6 +506,63 @@ class TestLLMOperator:
assert result == {"names": ["A", "B"]}
assert not isinstance(result, Entities)
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_serialize_output_keeps_python_field_types(self,
mock_hook_cls, make_mock_run_result):
+ mock_agent = MagicMock(spec=["run_sync", "instrument"])
+ mock_agent.run_sync.return_value =
make_mock_run_result(Dated(d=date(2026, 1, 2)))
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c",
output_type=Dated, serialize_output=True)
+ result = op.execute(context=MagicMock())
+
+ assert type(result) is dict
+ assert type(result["d"]) is date
+
+ @requires_typed_xcom
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def
test_execute_dumps_a_model_the_worker_cannot_register_from_a_list_of_types(
+ self, mock_hook_cls, make_mock_run_result
+ ):
+ """The worker registers no class from a list of types, so the model
goes to XCom as a dict."""
+ mock_agent = MagicMock(spec=["run_sync", "instrument"])
+ mock_agent.run_sync.return_value =
make_mock_run_result(Entities(names=["A", "B"]))
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c",
output_type=[Entities, int])
+ result = op.execute(context=MagicMock())
+
+ assert result == {"names": ["A", "B"]}
+
+ @requires_typed_xcom
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_dumps_models_inside_a_list_output_under_a_list_of_types(
+ self, mock_hook_cls, make_mock_run_result
+ ):
+ """A list output of ``[list[Entities], str]`` is no model, yet its
models need dumping for the worker."""
+ mock_agent = MagicMock(spec=["run_sync", "instrument"])
+ mock_agent.run_sync.return_value =
make_mock_run_result([Entities(names=["A"])])
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c",
output_type=[list[Entities], str])
+ result = op.execute(context=MagicMock())
+
+ assert result == [{"names": ["A"]}]
+ assert deserialize(serialize(result)) == [{"names": ["A"]}]
+
+ @requires_typed_xcom
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ def test_execute_dumps_http_url_fields_so_xcom_can_serialize_them(
+ self, mock_hook_cls, make_mock_run_result
+ ):
+ mock_agent = MagicMock(spec=["run_sync", "instrument"])
+ mock_agent.run_sync.return_value =
make_mock_run_result(Link(url="https://example.com/a"))
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMOperator(task_id="t", prompt="p", llm_conn_id="c",
output_type=[Link, str])
+ result = op.execute(context=MagicMock())
+
+ assert deserialize(serialize(result)) == {"url":
"https://example.com/a"}
+
def _result(make_mock_run_result, output, details, *, cost=None):
result = make_mock_run_result(output, cost=cost)
@@ -1659,8 +1728,9 @@ class TestLLMOperatorApproval:
(int, "5", 5),
(list[str], '["a","b"]', ["a", "b"]),
pytest.param(Summary, '{"text":"hello"}', Summary(text="hello"),
marks=requires_typed_xcom),
+ pytest.param([Summary, int], '{"text":"hello"}', {"text":
"hello"}, marks=requires_typed_xcom),
],
- ids=["int", "list", "basemodel"],
+ ids=["int", "list", "basemodel", "basemodel-in-a-list-of-types"],
)
def test_execute_complete_restores_non_str_output_type(self, output_type,
generated_output, expected):
op = LLMOperator(
diff --git
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
index 61feeafa241..29fc50fad5d 100644
---
a/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
+++
b/providers/common/ai/tests/unit/common/ai/operators/test_llm_file_analysis.py
@@ -275,6 +275,34 @@ class TestLLMFileAnalysisOperator:
assert result == {"findings": ["error spike"]}
assert not isinstance(result, Summary)
+ @requires_typed_xcom
+ @patch("airflow.providers.common.ai.operators.llm.PydanticAIHook",
autospec=True)
+ @patch(
+
"airflow.providers.common.ai.operators.llm_file_analysis.build_file_analysis_request",
autospec=True
+ )
+ def
test_execute_dumps_a_model_the_worker_cannot_register_from_a_list_of_types(
+ self, mock_build_request, mock_hook_cls
+ ):
+ mock_build_request.return_value = FileAnalysisRequest(
+ user_content="prepared prompt",
+ resolved_paths=["/tmp/app.log"],
+ total_size_bytes=10,
+ )
+ mock_agent = MagicMock(spec=["run_sync"])
+ mock_agent.run_sync.return_value =
_make_mock_run_result(Summary(findings=["error spike"]))
+ mock_hook_cls.get_hook.return_value.create_agent.return_value =
mock_agent
+
+ op = LLMFileAnalysisOperator(
+ task_id="test",
+ prompt="Summarize the file",
+ llm_conn_id="my_llm",
+ file_path="/tmp/app.log",
+ output_type=[Summary, int],
+ )
+ result = op.execute(context={})
+
+ assert result == {"findings": ["error spike"]}
+
@patch(
"airflow.providers.common.ai.operators.llm_file_analysis.build_file_analysis_request",
autospec=True
)
diff --git
a/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
index 9e6372a96f9..f149912da23 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_datafusion.py
@@ -107,6 +107,96 @@ class TestDataFusionToolsetGetTools:
assert tool.tool_def.description
+class TestDataFusionToolsetToolPrefix:
+ def test_prefix_is_joined_to_every_tool_name_with_an_underscore(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()],
tool_prefix="lake")
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock(spec=RunContext)))
+ assert set(tools.keys()) == {"lake_list_tables", "lake_get_schema",
"lake_query"}
+ assert all(name == tool.tool_def.name for name, tool in tools.items())
+
+ def test_prefix_is_part_of_the_toolset_id(self):
+ ts = DataFusionToolset([_make_mock_datasource_config("orders")],
tool_prefix="lake")
+ assert ts.id == "sql_datafusion_orders_lake"
+
+ @pytest.mark.parametrize("tool_prefix", ["my-lake", "1abc", "has space"])
+ def test_prefix_must_be_identifier(self, tool_prefix):
+ with pytest.raises(ValueError, match="tool_prefix must be a valid
Python identifier"):
+ DataFusionToolset([_make_mock_datasource_config()],
tool_prefix=tool_prefix)
+
+ def test_prefixed_tool_dispatches_to_the_unprefixed_implementation(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()],
tool_prefix="lake")
+ ts._engine = _make_mock_engine(registered_tables={"sales":
"s3://bucket/sales/"})
+
+ result = asyncio.run(
+ ts.call_tool(
+ "lake_list_tables", {}, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool)
+ )
+ )
+
+ assert json.loads(result) == ["sales"]
+
+ def test_unprefixed_name_is_unknown_when_a_prefix_is_set(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()],
tool_prefix="lake")
+
+ with pytest.raises(ValueError, match="Unknown tool: 'list_tables'"):
+ asyncio.run(
+ ts.call_tool(
+ "list_tables", {}, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool)
+ )
+ )
+
+
+class TestDataFusionToolsetNameConflictHint:
+ def test_hint_points_to_tool_prefix(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()])
+ assert "tool_prefix" in ts.tool_name_conflict_hint
+
+
+class TestDataFusionToolsetToolPrefixModelFacingText:
+ def test_get_schema_description_names_the_prefixed_tool(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()],
tool_prefix="lake")
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock(spec=RunContext)))
+ assert "Call lake_get_schema again" in
tools["lake_get_schema"].tool_def.description
+
+ def test_get_schema_hint_names_the_prefixed_tool(self):
+ ts = DataFusionToolset([_make_mock_datasource_config()],
tool_prefix="lake", max_columns=2)
+ ts._engine = _make_mock_engine(
+ registered_tables={"sales": "s3://bucket/sales/"},
+ schema_fields=[(f"c{i}", "Int64") for i in range(5)],
+ )
+
+ result = asyncio.run(
+ ts.call_tool(
+ "lake_get_schema",
+ {"table_name": "sales"},
+ ctx=MagicMock(spec=RunContext),
+ tool=MagicMock(spec=ToolsetTool),
+ )
+ )
+
+ assert "Call lake_get_schema with a name_contains substring" in
json.loads(result)["hint"]
+
+ def test_query_retry_names_the_prefixed_tools(self, tmp_path: Path):
+ csv_path = tmp_path / "trips.csv"
+ csv_path.write_text("pickup_date,id\n2024-01-01,1\n")
+ ts = DataFusionToolset(
+ [DataSourceConfig(conn_id="", table_name="trips",
uri=f"file://{csv_path}", format="csv")],
+ tool_prefix="lake",
+ )
+
+ with pytest.raises(ModelRetry) as exc_info:
+ asyncio.run(
+ ts.call_tool(
+ "lake_query",
+ {"sql": "SELECT name FROM trips"},
+ ctx=MagicMock(spec=RunContext),
+ tool=MagicMock(spec=ToolsetTool),
+ )
+ )
+
+ assert "Use lake_get_schema and lake_list_tables tools" in
exc_info.value.message
+
+
class TestDataFusionToolsetArgsValidation:
@pytest.mark.parametrize(
("tool_name", "valid_args"),
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
index 5391d62e0e0..edaad7de4c5 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_mcp.py
@@ -36,6 +36,9 @@ class TestMCPToolsetInit:
ts = MCPToolset("my_mcp_server", tool_prefix="weather")
assert ts._tool_prefix == "weather"
+ def test_tool_prefix_defaults_to_empty_string(self):
+ assert MCPToolset("my_mcp_server")._tool_prefix == ""
+
class TestMCPToolsetGetServer:
@patch(_HOOK_PATH, autospec=True)
@@ -47,7 +50,7 @@ class TestMCPToolsetGetServer:
server = ts._get_server()
mock_hook_cls.assert_called_once_with(
- mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=None,
env_provider=None
+ mcp_conn_id="mcp_conn", tool_prefix="", token_provider=None,
env_provider=None
)
mock_hook_cls.return_value.get_conn.assert_called_once()
assert server is mock_server
@@ -74,7 +77,7 @@ class TestMCPToolsetGetServer:
ts._get_server()
mock_hook_cls.assert_called_once_with(
- mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=provider,
env_provider=None
+ mcp_conn_id="mcp_conn", tool_prefix="", token_provider=provider,
env_provider=None
)
@patch(_HOOK_PATH, autospec=True)
@@ -88,7 +91,7 @@ class TestMCPToolsetGetServer:
ts._get_server()
mock_hook_cls.assert_called_once_with(
- mcp_conn_id="mcp_conn", tool_prefix=None, token_provider=None,
env_provider=env_provider
+ mcp_conn_id="mcp_conn", tool_prefix="", token_provider=None,
env_provider=env_provider
)
@patch(_HOOK_PATH, autospec=True)
diff --git
a/providers/common/ai/tests/unit/common/ai/toolsets/test_object_storage.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_object_storage.py
index 0dc15cdd5b4..83a08f451a0 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_object_storage.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_object_storage.py
@@ -22,13 +22,14 @@ import io
import json
import uuid
from typing import Any
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
from fsspec.implementations.memory import MemoryFileSystem
from pydantic_ai import RunContext
from pydantic_ai.exceptions import ToolFailed
from pydantic_ai.models.test import TestModel
+from pydantic_ai.toolsets.abstract import ToolsetTool
from pydantic_ai.usage import RunUsage
from airflow.providers.common.ai.toolsets.object_storage import
ObjectStorageToolset
@@ -111,6 +112,16 @@ class TestTools:
)
assert listing["path"] == "/"
+ def test_unprefixed_name_is_unknown_when_a_prefix_is_set(self, storage):
+ toolset = ObjectStorageToolset(f"file://{storage}",
tool_prefix="reports")
+ ctx = RunContext(deps=None, model=TestModel(), usage=RunUsage())
+
+ with pytest.raises(ValueError, match="Unknown tool: 'list_files'"):
+ asyncio.run(toolset.call_tool("list_files", {}, ctx,
MagicMock(spec=ToolsetTool)))
+
+ def test_hint_points_to_tool_prefix(self, storage):
+ assert "tool_prefix" in
ObjectStorageToolset(f"file://{storage}").tool_name_conflict_hint
+
@pytest.mark.parametrize(
("kwargs", "match"),
[
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
index 4c291cd2f76..562e978d2f4 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sandbox.py
@@ -193,6 +193,9 @@ class TestInit:
with pytest.raises(ValueError, match="tool_prefix"):
SandboxToolset(_RecordingBackend(), tool_prefix=bad)
+ def test_hint_points_to_tool_prefix(self):
+ assert "tool_prefix" in
SandboxToolset(_RecordingBackend()).tool_name_conflict_hint
+
def test_id_includes_backend_name_and_prefix(self):
assert SandboxToolset(_RecordingBackend()).id == "sandbox-rec"
assert SandboxToolset(_RecordingBackend(), tool_prefix="local").id ==
"sandbox-rec-local"
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
index dd16a1c1b17..57256030a57 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_sql.py
@@ -148,6 +148,120 @@ class TestSQLToolsetGetTools:
assert tool.tool_def.return_schema == {"type": "string"}
+class TestSQLToolsetToolPrefix:
+ def test_prefix_is_joined_to_every_tool_name_with_an_underscore(self):
+ ts = SQLToolset("pg_default", tool_prefix="pg")
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock()))
+ assert set(tools.keys()) == {"pg_list_tables", "pg_get_schema",
"pg_query", "pg_check_query"}
+ assert all(name == tool.tool_def.name for name, tool in tools.items())
+
+ def test_prefix_is_part_of_the_toolset_id(self):
+ assert SQLToolset("my_pg", tool_prefix="pg").id == "sql-my_pg-pg"
+
+ @pytest.mark.parametrize("tool_prefix", ["my-db", "1abc", "has space"])
+ def test_prefix_must_be_identifier(self, tool_prefix):
+ with pytest.raises(ValueError, match="tool_prefix must be a valid
Python identifier"):
+ SQLToolset("my_pg", tool_prefix=tool_prefix)
+
+ def test_prefixed_tool_dispatches_to_the_unprefixed_implementation(self):
+ ts = SQLToolset("pg_default", tool_prefix="pg")
+ ts._hook = _make_mock_db_hook(table_names=["users"])
+
+ result = asyncio.run(
+ ts.call_tool(
+ "pg_list_tables", {}, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool)
+ )
+ )
+
+ assert json.loads(result) == ["users"]
+
+ def test_unprefixed_name_is_unknown_when_a_prefix_is_set(self):
+ ts = SQLToolset("pg_default", tool_prefix="pg")
+
+ with pytest.raises(ValueError, match="Unknown tool: 'list_tables'"):
+ asyncio.run(
+ ts.call_tool(
+ "list_tables", {}, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool)
+ )
+ )
+
+
+def _call_prefixed(ts: SQLToolset, name: str, args: dict):
+ return asyncio.run(
+ ts.call_tool(name, args, ctx=MagicMock(spec=RunContext),
tool=MagicMock(spec=ToolsetTool))
+ )
+
+
+class TestSQLToolsetNameConflictHint:
+ def test_hint_points_to_tool_prefix(self):
+ assert "tool_prefix" in
SQLToolset("pg_default").tool_name_conflict_hint
+
+
+class TestSQLToolsetToolPrefixModelFacingText:
+ """Text shown to the model names this toolset's own tools, not the bare
base names."""
+
+ def test_get_schema_description_names_the_prefixed_tool(self):
+ tools = asyncio.run(SQLToolset("pg_default",
tool_prefix="pg").get_tools(ctx=MagicMock()))
+ description = tools["pg_get_schema"].tool_def.description
+ assert "Call pg_get_schema again" in description
+
+ def test_get_schema_description_without_prefix_is_unchanged(self):
+ tools =
asyncio.run(SQLToolset("pg_default").get_tools(ctx=MagicMock()))
+ assert "Call get_schema again" in
tools["get_schema"].tool_def.description
+
+ def test_tool_failure_retry_names_the_prefixed_tools(self):
+ ts = SQLToolset("pg_default", tool_prefix="pg")
+ ts._hook = _make_mock_db_hook()
+ ts._hook.run.side_effect = RuntimeError("boom")
+
+ with pytest.raises(ModelRetry) as exc_info:
+ _call_prefixed(ts, "pg_query", {"sql": "SELECT 1"})
+
+ assert "Use the pg_list_tables and pg_get_schema tools" in
exc_info.value.message
+
+ @pytest.mark.parametrize(
+ ("sql", "expected"),
+ [
+ ("SELECT * FROM dblink('host=evil', 'SELECT 1') AS t(x int)", "use
pg_list_tables to see them"),
+ ("SELECT * FROM secret_salaries", "Use pg_list_tables to see the
allowed tables"),
+ ],
+ ids=["unverifiable_source", "disallowed_table"],
+ )
+ def test_allowed_tables_refusal_names_the_prefixed_tool(self, sql,
expected):
+ ts = SQLToolset("pg_default", allowed_tables=["orders"],
tool_prefix="pg")
+ ts._hook = _make_mock_db_hook()
+ ts._hook.dialect_name = "postgresql"
+
+ with pytest.raises(ModelRetry) as exc_info:
+ _call_prefixed(ts, "pg_query", {"sql": sql})
+
+ assert expected in exc_info.value.message
+
+ @pytest.mark.parametrize(
+ ("table_schema", "args", "expected"),
+ [
+ (
+ [{"name": f"c{i}", "type": "INT"} for i in range(5)],
+ {"table_name": "users", "name_contains": "zzz"},
+ "Call pg_get_schema without name_contains",
+ ),
+ (
+ [{"name": f"c{i}", "type": "INT"} for i in range(300)],
+ {"table_name": "users"},
+ "Call pg_get_schema with a name_contains substring",
+ ),
+ ],
+ ids=["no_columns_match", "summary"],
+ )
+ def test_get_schema_hint_names_the_prefixed_tool(self, table_schema, args,
expected):
+ ts = SQLToolset("pg_default", tool_prefix="pg")
+ ts._hook = _make_mock_db_hook(table_schema=table_schema)
+
+ hint = json.loads(_call_prefixed(ts, "pg_get_schema", args))["hint"]
+
+ assert expected in hint
+
+
class TestSQLToolsetListTables:
def test_returns_all_tables(self):
ts = SQLToolset("pg_default")
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
index cd22f989c3e..213e6fc8bab 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_output_type.py
@@ -16,32 +16,103 @@
# under the License.
from __future__ import annotations
+import json
from collections import deque
from collections.abc import Sequence
from dataclasses import dataclass
+from datetime import UTC, date, datetime
+from decimal import Decimal
from enum import Enum
+from pathlib import Path
from typing import Annotated, Literal, NamedTuple
+from unittest import mock
+from uuid import UUID
import pytest
-from pydantic import BaseModel, ConfigDict, Field, computed_field,
model_validator
+from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field,
model_validator
from pydantic_ai import NativeOutput, PromptedOutput, ToolOutput
from airflow.providers.common.ai.exceptions import
ReviewedOutputValidationError
from airflow.providers.common.ai.utils.output_type import (
_is_container_mismatch,
+ _is_model_dump_required,
+ dump_output_for_xcom,
dump_output_to_json,
rehydrate_pydantic_output,
)
+try:
+ from airflow.sdk.serde import (
+ SUPPORTS_OPERATOR_DESERIALIZATION_WALKER as _CORE_WALKER,
+ deserialize,
+ serialize,
+ )
+except ImportError:
+ _CORE_WALKER = False
+
+requires_walker = pytest.mark.skipif(
+ not _CORE_WALKER,
+ reason="Requires an ``apache-airflow-task-sdk`` version with the
worker-side deserialization-class walk.",
+)
+
class A(BaseModel):
x: int
+class SubA(A):
+ pass
+
+
+class Item(BaseModel):
+ n: int
+
+
class B(BaseModel):
y: str
+class Stamped(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ d: date
+ at: datetime
+ amount: Decimal
+ ident: UUID
+
+
+STAMPED_JSON = {
+ "d": "2026-01-02",
+ "at": "2026-01-02T03:04:05Z",
+ "amount": "1.5",
+ "ident": "12345678-1234-5678-1234-567812345678",
+}
+STAMPED = Stamped(
+ d=date(2026, 1, 2),
+ at=datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC),
+ amount=Decimal("1.5"),
+ ident=UUID("12345678-1234-5678-1234-567812345678"),
+)
+
+
+class Link(BaseModel):
+ url: HttpUrl
+
+
+LINK = Link(url=HttpUrl("https://example.com/a"))
+LINK_SERIALIZED = {
+ "__classname__": f"{Link.__module__}.Link",
+ "__version__": 1,
+ "__data__": {"url": "https://example.com/a"},
+}
+
+
+class Key(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ n: int
+
+
class FromAttributesModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
x: int
@@ -56,6 +127,12 @@ class Color(str, Enum):
RED = "red"
+class Page(BaseModel):
+ url: HttpUrl
+ kind: Color
+ path: Path
+
+
class ValidationAlias(BaseModel):
user_name: str = Field(alias="userName")
@@ -140,17 +217,246 @@ class UnresolvedForwardReference:
value: Missing # type: ignore[name-defined] # noqa: F821
+class ItemPair(NamedTuple):
+ item: Item
+
+
class Point(NamedTuple):
x: int
y: str
+class TestIsModelDumpRequired:
+ @pytest.mark.parametrize(
+ ("output", "output_type", "expected"),
+ [
+ (A(x=1), [A, int], True),
+ (A(x=1), (A, int), True),
+ (A(x=1), ToolOutput(A), True),
+ (A(x=1), A, False),
+ (A(x=1), A | int, False),
+ (A(x=1), list[A], False),
+ (SubA(x=1), A, True),
+ (5, [A, int], False),
+ ],
+ ids=[
+ "list-of-types",
+ "tuple-of-types",
+ "output-marker",
+ "single-model",
+ "union",
+ "generic-list",
+ "subclass-instance",
+ "not-a-model",
+ ],
+ )
+ @requires_walker
+ def test_is_true_when_the_worker_registers_no_class_for_the_output(self,
output, output_type, expected):
+ result = _is_model_dump_required(output, output_type)
+
+ assert result is expected
+
+
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
+ def test_requires_a_dump_without_the_sdk_walker(self):
+ assert _is_model_dump_required(A(x=1), [A, int]) is True
+ assert _is_model_dump_required([A(x=1)], list[A]) is True
+ assert _is_model_dump_required(5, list[A]) is False
+
+
+class TestIsModelDumpRequiredForContainerOutputs:
+ @pytest.mark.parametrize(
+ ("output", "output_type"),
+ [
+ ([A(x=1)], list[A]),
+ ({"k": A(x=1)}, dict[str, A]),
+ (None, A | None),
+ ("text", [list[Item], str]),
+ ],
+ ids=["registered-list", "registered-dict", "none-for-optional",
"no-model-in-output"],
+ )
+ @requires_walker
+ def
test_is_false_for_a_registered_shape_or_an_output_without_a_model(self, output,
output_type):
+ assert _is_model_dump_required(output, output_type) is False
+
+ @pytest.mark.parametrize(
+ ("output", "output_type"),
+ [
+ ([Item(n=1)], [list[Item], str]),
+ ({"k": Item(n=1)}, [dict[str, Item], str]),
+ ((Item(n=1),), [tuple[Item, ...], str]),
+ (ItemPair(item=Item(n=1)), [ItemPair, str]),
+ ],
+ ids=["list", "dict", "tuple", "named-tuple"],
+ )
+ @requires_walker
+ def
test_is_true_for_models_inside_a_container_the_worker_registers_no_class_for(
+ self, output, output_type
+ ):
+ assert _is_model_dump_required(output, output_type) is True
+
+
+class TestDumpOutputForXcom:
+ @requires_walker
+ def
test_dumped_model_with_http_url_enum_path_serializes_through_serde(self):
+ page = Page(url="https://example.com/a", kind=Color.RED,
path=Path("/tmp/a"))
+
+ dumped = dump_output_for_xcom(page, [Page, str],
serialize_output=False)
+
+ assert deserialize(serialize(dumped)) == {
+ "url": "https://example.com/a",
+ "kind": "red",
+ "path": "/tmp/a",
+ }
+
+ @requires_walker
+ def
test_dumps_models_inside_a_list_when_output_type_is_a_list_of_types(self):
+ dumped = dump_output_for_xcom([Item(n=1)], [list[Item], str],
serialize_output=False)
+
+ assert dumped == [{"n": 1}]
+ assert deserialize(serialize(dumped)) == [{"n": 1}]
+
+ @requires_walker
+ def test_keeps_a_registered_container_as_model_instances(self):
+ output = {"k": A(x=1)}
+
+ dumped = dump_output_for_xcom(output, dict[str, A],
serialize_output=False)
+
+ assert dumped is output
+ assert isinstance(dumped["k"], A)
+
+ @pytest.mark.parametrize(
+ "output",
+ [datetime(2026, 1, 1, tzinfo=UTC), 5, "s", None, {"a": 1}],
+ ids=["datetime", "int", "str", "none", "dict"],
+ )
+ def test_non_model_outputs_are_returned_unchanged(self, output):
+ dumped = dump_output_for_xcom(output, [A, int], serialize_output=True)
+
+ assert dumped is output
+
+ @requires_walker
+ def
test_dumps_the_whole_output_when_only_some_of_its_models_are_unregistered(self):
+ output = [A(x=1), Item(n=1)]
+
+ assert _is_model_dump_required(output, A) is True
+ assert dump_output_for_xcom(output, A, serialize_output=False) ==
[{"x": 1}, {"n": 1}]
+
+ @pytest.mark.parametrize("model_cls", [SerializationAlias,
SerializedByAlias])
+ @requires_walker
+ def test_dumps_field_names_not_aliases_like_dump_output_to_json(self,
model_cls):
+ model = model_cls(user_name="kax")
+
+ dumped = dump_output_for_xcom(model, [model_cls, str],
serialize_output=False)
+
+ assert dumped == {"user_name": "kax"}
+ assert dumped == json.loads(dump_output_to_json(model))
+
+
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
+ def test_dumps_a_container_without_the_sdk_walker(self):
+ output = [A(x=1)]
+
+ for serialize_output in (True, False):
+ dumped = dump_output_for_xcom(output, list[A],
serialize_output=serialize_output)
+
+ assert dumped == [{"x": 1}]
+ assert dumped is not output
+
+ @pytest.mark.parametrize(
+ ("output", "output_type", "expected"),
+ [
+ (A(x=1), A, {"x": 1}),
+ ([A(x=1)], list[A], [{"x": 1}]),
+ ],
+ ids=["top-level-model", "list-of-models"],
+ )
+
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
+ def test_dumps_every_model_without_the_sdk_walker_when_not_serializing(
+ self, output, output_type, expected
+ ):
+ dumped = dump_output_for_xcom(output, output_type,
serialize_output=False)
+
+ assert dumped == expected
+ assert dumped is not output
+
+ def test_serialize_output_keeps_python_field_types(self):
+ dumped = dump_output_for_xcom(STAMPED, Stamped, serialize_output=True)
+
+ assert type(dumped) is dict
+ assert dumped == {"d": STAMPED.d, "at": STAMPED.at, "amount":
STAMPED.amount, "ident": STAMPED.ident}
+ assert [type(value) for value in dumped.values()] == [date, datetime,
Decimal, UUID]
+
+ @pytest.mark.parametrize(
+ ("output", "expected"),
+ [
+ ([STAMPED], [STAMPED_JSON]),
+ ({"k": STAMPED}, {"k": STAMPED_JSON}),
+ ((STAMPED,), [STAMPED_JSON]),
+ ({STAMPED}, [STAMPED_JSON]),
+ ],
+ ids=["list", "dict", "tuple", "set"],
+ )
+ @requires_walker
+ def
test_serialize_output_dumps_an_unregistered_container_to_json_values(self,
output, expected):
+ dumped = dump_output_for_xcom(output, [Stamped, str],
serialize_output=True)
+
+ assert dumped == expected
+
+ @requires_walker
+ def test_dumps_a_model_used_as_a_dict_key(self):
+ output = {Key(n=1): 1, "k": 2}
+
+ dumped = dump_output_for_xcom(output, [Key, str],
serialize_output=True)
+
+ assert dumped == {"n=1": 1, "k": 2}
+ assert json.dumps(dumped)
+
+
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
+ def
test_serialize_output_dumps_a_top_level_model_without_the_sdk_walker(self):
+ dumped = dump_output_for_xcom(STAMPED, Stamped, serialize_output=True)
+
+ assert type(dumped) is dict
+ assert type(dumped["d"]) is date
+
+ @pytest.mark.parametrize(
+ ("output", "output_type", "expected"),
+ [
+ ([LINK], list[Link], [LINK_SERIALIZED]),
+ ({"k": LINK}, dict[str, Link], {"k": LINK_SERIALIZED}),
+ ],
+ ids=["list", "dict"],
+ )
+ @requires_walker
+ def
test_serialize_output_keeps_registered_models_in_a_container_that_serde_pushes(
+ self, output, output_type, expected
+ ):
+ dumped = dump_output_for_xcom(output, output_type,
serialize_output=True)
+
+ assert dumped is output
+ assert serialize(dumped) == expected
+
+ @requires_walker
+ def test_unregistered_model_is_dumped_to_json_values(self):
+ dumped = dump_output_for_xcom(STAMPED, [Stamped, str],
serialize_output=False)
+
+ assert dumped == {
+ "d": "2026-01-02",
+ "at": STAMPED.at.isoformat().replace("+00:00", "Z"),
+ "amount": "1.5",
+ "ident": str(STAMPED.ident),
+ }
+
+
class TestRehydratePydanticOutput:
+ @requires_walker
def test_returns_model_instance(self):
result = rehydrate_pydantic_output(A, '{"x": 7}',
serialize_output=False)
assert isinstance(result, A)
assert result.x == 7
+
@mock.patch("airflow.providers.common.ai.utils.output_type.iter_pydantic_models",
None)
+ def test_returns_dict_without_the_sdk_walker(self):
+ assert rehydrate_pydantic_output(A, '{"x": 7}',
serialize_output=False) == {"x": 7}
+
def test_returns_dict_when_serialize_output(self):
result = rehydrate_pydantic_output(A, '{"x": 7}',
serialize_output=True)
assert result == {"x": 7}
@@ -187,6 +493,38 @@ class TestRehydratePydanticOutput:
assert result == expected
assert type(result) is type(expected)
+ @pytest.mark.parametrize(
+ ("output_type", "raw", "expected"),
+ [
+ ([str, int], '"x"', "x"),
+ ([int, float], "1.5", 1.5),
+ ((int, float), "2", 2),
+ pytest.param([A, int], '{"x": 7}', {"x": 7},
marks=requires_walker),
+ ],
+ ids=["str-int-string", "int-float", "tuple",
"model-in-a-list-of-types-is-dumped"],
+ )
+ def test_validates_list_output_type_as_union(self, output_type, raw,
expected):
+ assert rehydrate_pydantic_output(output_type, raw,
serialize_output=False) == expected
+
+ @pytest.mark.parametrize(
+ "output_type",
+ [[], [ToolOutput(int), ToolOutput(str)]],
+ ids=["empty-list", "unsupported-operand"],
+ )
+ def test_returns_raw_when_list_cannot_be_unioned(self, output_type):
+ assert rehydrate_pydantic_output(output_type, "1",
serialize_output=False) == "1"
+
+ @mock.patch("airflow.providers.common.ai.utils.output_type.TypeAdapter",
autospec=True)
+ def test_propagates_type_error_from_type_adapter(self, mock_type_adapter):
+ mock_type_adapter.side_effect = TypeError("broken schema")
+
+ with pytest.raises(TypeError, match="broken schema"):
+ rehydrate_pydantic_output(int, "1", serialize_output=False)
+
+ def test_rejects_edit_matching_no_list_candidate(self):
+ with pytest.raises(ReviewedOutputValidationError):
+ rehydrate_pydantic_output([int, float], '"x"',
serialize_output=False)
+
@pytest.mark.parametrize(
"takes_an_order", [False, True], ids=["call-schema",
"definitions-wrapped-schema"]
)
@@ -241,6 +579,7 @@ class TestRehydratePydanticOutput:
@pytest.mark.parametrize(
"raw", ['{"user_name": "kax"}', '{"userName": "kax"}'],
ids=["field-name", "alias"]
)
+ @requires_walker
def test_accepts_a_field_name_or_an_alias(self, raw):
result = rehydrate_pydantic_output(ValidationAlias, raw,
serialize_output=False)
@@ -335,6 +674,7 @@ class TestRehydratePydanticOutput:
"list-of-computed-field-forbid-extra",
],
)
+ @requires_walker
def test_a_dumped_output_validates_back(self, output_type, output):
raw = dump_output_to_json(output)
@@ -418,6 +758,7 @@ class TestRehydratePydanticOutput:
assert rehydrate_pydantic_output(A | str, raw, serialize_output=False)
== raw
+@requires_walker
def
test_a_model_that_turned_off_validation_by_alias_reads_its_dump_back_unchanged():
output = AliasNamedLikeAnotherField(source="SRC", destination="DST")
diff --git
a/providers/common/ai/tests/unit/common/ai/utils/test_query_results.py
b/providers/common/ai/tests/unit/common/ai/utils/test_query_results.py
index eee5a06c746..3ab3a2428ca 100644
--- a/providers/common/ai/tests/unit/common/ai/utils/test_query_results.py
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_query_results.py
@@ -24,6 +24,7 @@ import pytest
from airflow.providers.common.ai.utils.query_results import (
_SCHEMA_SAMPLE_SIZE,
+ build_get_schema_description,
build_query_result,
build_schema_result,
)
@@ -334,3 +335,29 @@ class TestSchemaResult:
assert data["sample_columns"] == []
assert "type_histogram" not in data
assert data["hint"]
+
+
+class TestSchemaToolName:
+ def test_description_names_the_given_tool(self):
+ assert "Call pg_get_schema again" in
build_get_schema_description(tool_name="pg_get_schema")
+
+ def test_description_defaults_to_the_bare_name(self):
+ assert "Call get_schema again" in build_get_schema_description()
+
+ def test_hints_name_the_given_tool(self):
+ no_match = json.loads(
+ build_schema_result(
+ _cols(3),
+ max_columns=100,
+ max_result_bytes=65_536,
+ name_contains="zzz",
+ tool_name="pg_get_schema",
+ )
+ )
+ summary = json.loads(
+ build_schema_result(
+ _cols(250), max_columns=100, max_result_bytes=65_536,
tool_name="pg_get_schema"
+ )
+ )
+ assert "Call pg_get_schema without name_contains" in no_match["hint"]
+ assert "Call pg_get_schema with a name_contains substring" in
summary["hint"]
diff --git a/providers/common/ai/tests/unit/common/ai/utils/test_tool_prefix.py
b/providers/common/ai/tests/unit/common/ai/utils/test_tool_prefix.py
new file mode 100644
index 00000000000..95b00500379
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/utils/test_tool_prefix.py
@@ -0,0 +1,78 @@
+# 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 pytest
+
+from airflow.providers.common.ai.utils.tool_prefix import ToolPrefixMixin,
validate_tool_prefix
+
+
+class _Prefixed(ToolPrefixMixin):
+ def __init__(self, tool_prefix: str = "") -> None:
+ self._set_tool_prefix(tool_prefix)
+
+
+class TestValidateToolPrefix:
+ @pytest.mark.parametrize("tool_prefix", ["a-b", "9lives", "has space"])
+ def test_rejects_a_prefix_that_is_not_an_identifier(self, tool_prefix):
+ with pytest.raises(
+ ValueError, match=f"tool_prefix must be a valid Python identifier,
got {tool_prefix!r}"
+ ):
+ validate_tool_prefix(tool_prefix)
+
+ @pytest.mark.parametrize("tool_prefix", ["", "pg", "_pg", "pg2"])
+ def test_accepts_an_empty_or_identifier_prefix(self, tool_prefix):
+ validate_tool_prefix(tool_prefix)
+
+
+class TestToolPrefixMixin:
+ def test_set_tool_prefix_rejects_and_keeps_the_previous_prefix(self):
+ toolset = _Prefixed("pg")
+
+ with pytest.raises(ValueError, match="valid Python identifier"):
+ toolset._set_tool_prefix("a-b")
+
+ assert toolset._tool_prefix == "pg"
+
+ def test_the_prefix_defaults_to_empty(self):
+ assert ToolPrefixMixin._tool_prefix == ""
+
+ @pytest.mark.parametrize(("tool_prefix", "expected"), [("", "query"),
("pg", "pg_query")])
+ def test_build_tool_name(self, tool_prefix, expected):
+ assert _Prefixed(tool_prefix)._build_tool_name("query") == expected
+
+ @pytest.mark.parametrize(
+ ("tool_prefix", "name", "expected"),
+ [
+ ("pg", "pg_query", "query"),
+ ("pg", "query", None),
+ ("pg", "pgquery", None),
+ ("", "query", "query"),
+ ],
+ )
+ def
test_find_base_tool_name_is_strict_about_a_name_without_the_prefix(self,
tool_prefix, name, expected):
+ assert _Prefixed(tool_prefix)._find_base_tool_name(name) == expected
+
+ @pytest.mark.parametrize(
+ ("tool_prefix", "separator", "expected"),
+ [("", "-", ""), ("", "_", ""), ("pg", "-", "-pg"), ("pg", "_", "_pg")],
+ )
+ def test_get_tool_prefix_id_suffix(self, tool_prefix, separator, expected):
+ assert _Prefixed(tool_prefix)._get_tool_prefix_id_suffix(separator) ==
expected
+
+ def test_get_tool_prefix_id_suffix_defaults_to_a_dash(self):
+ assert _Prefixed("pg")._get_tool_prefix_id_suffix() == "-pg"