This is an automated email from the ASF dual-hosted git repository.
gopidesupavan 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 eddaa67f5bc Add AWS services toolset for agents to access 1000+ APIs
(#70087)
eddaa67f5bc is described below
commit eddaa67f5bc77e8ba63e645ae73948f25bbde8f6
Author: GPK <[email protected]>
AuthorDate: Mon Jul 20 18:42:13 2026 +0100
Add AWS services toolset for agents to access 1000+ APIs (#70087)
* Add AWS services toolset for agents to access 1000+ APIs
* Fix basic tests
* Fix basic tests
* Move to helpers
* resolve comments
* Add more tests
* Update description
* parametrize tests
---
dev/breeze/tests/test_selective_checks.py | 16 +-
providers/common/ai/docs/examples.rst | 4 +-
providers/common/ai/docs/index.rst | 5 +-
providers/common/ai/docs/toolsets.rst | 74 ++-
providers/common/ai/pyproject.toml | 9 +
.../common/ai/example_dags/example_aws_toolset.py | 54 +++
.../providers/common/ai/toolsets/__init__.py | 10 +-
.../airflow/providers/common/ai/toolsets/aws.py | 509 +++++++++++++++++++++
.../ai/tests/unit/common/ai/toolsets/test_aws.py | 359 +++++++++++++++
uv.lock | 12 +-
10 files changed, 1036 insertions(+), 16 deletions(-)
diff --git a/dev/breeze/tests/test_selective_checks.py
b/dev/breeze/tests/test_selective_checks.py
index 158bef04a31..196744ea2cd 100644
--- a/dev/breeze/tests/test_selective_checks.py
+++ b/dev/breeze/tests/test_selective_checks.py
@@ -1109,7 +1109,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
pytest.param(
("providers/amazon/src/airflow/providers/amazon/provider.yaml",),
{
- "selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes "
+ "selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes common.ai "
"common.compat common.messaging common.sql databricks exasol
ftp google http imap microsoft.azure "
"mongo mysql openlineage postgres salesforce ssh teradata",
"all-python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
@@ -1136,9 +1136,9 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
{
"description": "amazon...google",
"test_types": "Providers[amazon]
Providers[apache.hive,cncf.kubernetes,"
-
"common.compat,common.messaging,common.sql,databricks,exasol,ftp,http,imap,"
-
"microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,teradata] "
- "Providers[google]",
+
"common.ai,common.compat,common.messaging,common.sql,databricks,exasol,ftp,"
+
"http,imap,microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,"
+ "teradata] Providers[google]",
}
]
),
@@ -1181,7 +1181,7 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
pytest.param(
("providers/amazon/src/airflow/providers/amazon/file.py",),
{
- "selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes "
+ "selected-providers-list-as-string": "amazon apache.hive
cncf.kubernetes common.ai "
"common.compat common.messaging common.sql databricks exasol
ftp google http imap microsoft.azure "
"mongo mysql openlineage postgres salesforce ssh teradata",
"all-python-versions":
f"['{DEFAULT_PYTHON_MAJOR_MINOR_VERSION}']",
@@ -1205,9 +1205,9 @@ def assert_outputs_are_printed(expected_outputs:
dict[str, str], stderr: str):
{
"description": "amazon...google",
"test_types": "Providers[amazon]
Providers[apache.hive,cncf.kubernetes,"
-
"common.compat,common.messaging,common.sql,databricks,exasol,ftp,http,imap,"
-
"microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,teradata] "
- "Providers[google]",
+
"common.ai,common.compat,common.messaging,common.sql,databricks,exasol,ftp,"
+
"http,imap,microsoft.azure,mongo,mysql,openlineage,postgres,salesforce,ssh,"
+ "teradata] Providers[google]",
}
]
),
diff --git a/providers/common/ai/docs/examples.rst
b/providers/common/ai/docs/examples.rst
index a74550d5e60..973a4642d2f 100644
--- a/providers/common/ai/docs/examples.rst
+++ b/providers/common/ai/docs/examples.rst
@@ -69,7 +69,9 @@ Agents & tools
`example_agent_capabilities.py
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_capabilities.py>`__).
* - :ref:`Toolsets <howto/toolsets>`
- Loading ``SKILL.md`` Agent Skills
- (`example_agent_skills.py
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py>`__)
+ (`example_agent_skills.py
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_skills.py>`__),
+ allow-listed AWS API access via ``AWSToolset``
+ (`example_aws_toolset.py
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_aws_toolset.py>`__),
and exposing an Airflow toolset to a LangChain agent, the reverse bridge
(`example_langchain_toolset_bridge.py
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_langchain_toolset_bridge.py>`__).
* - :doc:`connections/mcp`
diff --git a/providers/common/ai/docs/index.rst
b/providers/common/ai/docs/index.rst
index 9fce0fd08e0..51081846490 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -217,12 +217,13 @@ You can install such cross-provider dependencies when
installing from PyPI. For
.. code-block:: bash
- pip install apache-airflow-providers-common-ai[common.sql]
+ pip install apache-airflow-providers-common-ai[amazon]
============================================================================================================
==============
Dependent package
Extra
============================================================================================================
==============
+`apache-airflow-providers-amazon
<https://airflow.apache.org/docs/apache-airflow-providers-amazon>`_
``amazon``
`apache-airflow-providers-common-sql
<https://airflow.apache.org/docs/apache-airflow-providers-common-sql>`_
``common.sql``
`apache-airflow-providers-git
<https://airflow.apache.org/docs/apache-airflow-providers-git>`_
``git``
============================================================================================================
==============
@@ -252,12 +253,14 @@ Extra Dependencies
``avro`` ``fastavro>=1.10.0; python_version < "3.14"``,
``fastavro>=1.12.1; python_version >= "3.14"``
``parquet`` ``pyarrow>=18.0.0; python_version < '3.14'``,
``pyarrow>=22.0.0; python_version >= '3.14'``
``sql`` ``apache-airflow-providers-common-sql``, ``sqlglot>=30.0.0``
+``aws`` ``apache-airflow-providers-amazon>=9.0.0``
``common.sql`` ``apache-airflow-providers-common-sql``
``langchain`` ``langchain>=1.0.0``
``llamaindex`` ``dataclasses-json>=0.6.7``, ``llama-index-core>=0.13.0``,
``llama-index-embeddings-openai>=0.6.0``, ``llama-index-llms-openai>=0.6.0``
``pdf`` ``pypdf>=4.0.0``
``docx`` ``python-docx>=1.0.0``
``git`` ``apache-airflow-providers-git``
+``amazon`` ``apache-airflow-providers-amazon``
==============
=======================================================================================================================================
Downloading official packages
diff --git a/providers/common/ai/docs/toolsets.rst
b/providers/common/ai/docs/toolsets.rst
index 5c46ee45616..8d19e4547b6 100644
--- a/providers/common/ai/docs/toolsets.rst
+++ b/providers/common/ai/docs/toolsets.rst
@@ -24,17 +24,20 @@ Airflow's 350+ provider hooks already have typed methods,
rich docstrings,
and managed credentials. Toolsets expose them as pydantic-ai tools so that
LLM agents can call them during multi-turn reasoning.
-Three toolsets are included:
+Four toolsets are included:
+- :class:`~airflow.providers.common.ai.toolsets.aws.AWSToolset` — configured
+ AWS services toolset for agent access to AWS APIs through Airflow-managed
+ AWS connections.
- :class:`~airflow.providers.common.ai.toolsets.hook.HookToolset` — generic
adapter for any Airflow Hook.
-- :class:`~airflow.providers.common.ai.toolsets.sql.SQLToolset` — curated
- 4-tool database toolset.
- :class:`~airflow.providers.common.ai.toolsets.mcp.MCPToolset` — connect to
`MCP servers <https://modelcontextprotocol.io/>`__ configured via Airflow
connections.
+- :class:`~airflow.providers.common.ai.toolsets.sql.SQLToolset` — curated
+ 4-tool database toolset.
-All three implement pydantic-ai's
+All four implement pydantic-ai's
`AbstractToolset <https://ai.pydantic.dev/toolsets/>`__ interface and can be
passed to any pydantic-ai ``Agent``, including via
:class:`~airflow.providers.common.ai.operators.agent.AgentOperator`.
@@ -263,6 +266,69 @@ Parameters
support DDL for in-memory tables; this guard blocks those by default.
- ``max_rows``: Maximum rows returned from the ``query`` tool. Default ``50``.
+``AWSToolset``
+--------------
+
+Curated toolset that gives an agent allow-listed access to AWS APIs with
+three tools:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 20 50
+
+ * - Tool
+ - Description
+ * - ``list_aws_operations``
+ - Lists the operations the toolset allows, grouped by service
+ * - ``describe_aws_operation``
+ - Returns an operation's parameter and response shapes (from the
+ botocore service model), so the agent can check what a call expects
+ before making it
+ * - ``call_aws``
+ - Executes an allowed operation and returns the response as JSON,
+ aggregating paginated results
+
+Credentials, region, and session configuration come from the Airflow
+connection (``aws_conn_id``) via the amazon provider — the model cannot
+override them through tool arguments. Requires the ``aws`` extra::
+
+ pip install "apache-airflow-providers-common-ai[aws]"
+
+.. exampleinclude::
/../../ai/src/airflow/providers/common/ai/example_dags/example_aws_toolset.py
+ :language: python
+ :start-after: [START howto_operator_agent_aws]
+ :end-before: [END howto_operator_agent_aws]
+
+Access is deny-by-default: ``allowed_actions`` is required and supports ``*``
+wildcards in the operation part only (``"athena:*"``, ``"s3:List*"``).
+Operations that return credentials or decrypted secrets — ``sts:AssumeRole``,
+``secretsmanager:GetSecretValue``, ``kms:Decrypt``, and similar — are never
+matched by a wildcard and must be listed verbatim to be callable.
+
+.. warning::
+ ``allowed_actions`` bounds what the agent can ask for, not what the
+ credentials can do. Point ``aws_conn_id`` at a least-privilege IAM role
+ scoped to the same operations.
+
+Parameters
+^^^^^^^^^^
+
+- ``aws_conn_id``: Airflow connection ID for AWS credentials. Default
+ ``aws_default``.
+- ``allowed_actions``: Operations the agent may call, in
+ ``"<service>:<Operation>"`` form using boto3 service names and API
+ operation names. Required — there is no auto-discovery. Matching is case-
+ and underscore-insensitive, so ``"s3:list_buckets"`` equals
+ ``"s3:ListBuckets"``. Validated against botocore's bundled service
+ definitions at instantiation time (local metadata only — no network, no
+ credentials).
+- ``region_name``: AWS region for API calls. Default ``None`` — use the
+ region configured on the connection.
+- ``max_items``: Upper bound on items aggregated from paginated operations.
+ Default ``1000``.
+- ``max_output_bytes``: Upper bound on the serialized response returned to
+ the agent; larger payloads are clipped and flagged. Default ``65536``.
+
``LoggingToolset``
------------------
diff --git a/providers/common/ai/pyproject.toml
b/providers/common/ai/pyproject.toml
index 53ba8065819..2c1f13eec2a 100644
--- a/providers/common/ai/pyproject.toml
+++ b/providers/common/ai/pyproject.toml
@@ -108,6 +108,11 @@ dependencies = [
"apache-airflow-providers-common-sql",
"sqlglot>=30.0.0",
]
+# AWSToolset: allow-listed AWS API access for agents. The amazon provider
+# supplies credential resolution (AwsBaseHook) and brings boto3/botocore.
+"aws" = [
+ "apache-airflow-providers-amazon>=9.0.0",
+]
"common.sql" = [
"apache-airflow-providers-common-sql"
]
@@ -125,12 +130,16 @@ dependencies = [
"git" = [
"apache-airflow-providers-git"
]
+"amazon" = [
+ "apache-airflow-providers-amazon"
+]
[dependency-groups]
dev = [
"apache-airflow",
"apache-airflow-task-sdk",
"apache-airflow-devel-common",
+ "apache-airflow-providers-amazon",
"apache-airflow-providers-common-compat",
"apache-airflow-providers-common-sql",
"apache-airflow-providers-git",
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_aws_toolset.py
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_aws_toolset.py
new file mode 100644
index 00000000000..682208447ef
--- /dev/null
+++
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_aws_toolset.py
@@ -0,0 +1,54 @@
+# 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.
+"""Example Dag: agent with allow-listed AWS access via AWSToolset."""
+
+from __future__ import annotations
+
+from airflow.providers.common.ai.operators.agent import AgentOperator
+from airflow.providers.common.ai.toolsets.aws import AWSToolset
+from airflow.providers.common.compat.sdk import dag
+
+
+# [START howto_operator_agent_aws]
+@dag(tags=["example"])
+def example_agent_aws_toolset():
+ AgentOperator(
+ task_id="s3_auditor",
+ prompt="Which buckets exist, and roughly how much data is in
'data-lake-raw'?",
+ llm_conn_id="pydanticai_default",
+ system_prompt=(
+ "You are an AWS operations assistant. Discover what you are
allowed "
+ "to call, check parameter shapes before calling, and answer with "
+ "concrete numbers."
+ ),
+ toolsets=[
+ AWSToolset(
+ aws_conn_id="aws_default",
+ allowed_actions=[
+ "s3:ListBuckets",
+ "s3:ListObjectsV2",
+ "s3:GetBucketLocation",
+ ],
+ region_name="us-east-1",
+ )
+ ],
+ )
+
+
+# [END howto_operator_agent_aws]
+
+example_agent_aws_toolset()
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/__init__.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/__init__.py
index 6c30fa4a733..e36690b036d 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/__init__.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/__init__.py
@@ -20,7 +20,7 @@ from __future__ import annotations
from airflow.providers.common.ai.toolsets.hook import HookToolset
-__all__ = ["HookToolset", "MCPToolset", "SQLToolset",
"airflow_toolset_to_langchain_tools"]
+__all__ = ["AWSToolset", "HookToolset", "MCPToolset", "SQLToolset",
"airflow_toolset_to_langchain_tools"]
def __getattr__(name: str):
@@ -46,4 +46,12 @@ def __getattr__(name: str):
raise AirflowOptionalProviderFeatureException(e)
return MCPToolset
+ if name == "AWSToolset":
+ try:
+ from airflow.providers.common.ai.toolsets.aws import AWSToolset
+ except ImportError as e:
+ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+ raise AirflowOptionalProviderFeatureException() from e
+ return AWSToolset
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/aws.py
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/aws.py
new file mode 100644
index 00000000000..b37adec7497
--- /dev/null
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/aws.py
@@ -0,0 +1,509 @@
+# 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.
+"""Curated AWS toolset exposing allow-listed boto3 operations as pydantic-ai
tools."""
+
+from __future__ import annotations
+
+import json
+import re
+from fnmatch import fnmatchcase
+from functools import cache
+from typing import TYPE_CHECKING, Any
+
+from pydantic_ai.exceptions import ModelRetry
+from pydantic_ai.tools import ToolDefinition
+from pydantic_ai.toolsets.abstract import AbstractToolset, ToolsetTool
+from pydantic_core import SchemaValidator, core_schema
+
+from airflow.providers.common.ai.utils.tool_definition import
return_schema_kwargs
+
+try:
+ import botocore.session
+ from botocore import xform_name
+ from botocore.response import StreamingBody
+
+ from airflow.providers.amazon.aws.hooks.base_aws import AwsBaseHook
+except ImportError as e:
+ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+ raise AirflowOptionalProviderFeatureException() from e
+
+if TYPE_CHECKING:
+ from botocore.client import BaseClient
+ from botocore.model import ServiceModel, Shape
+ from pydantic_ai._run_context import RunContext
+
+_PASSTHROUGH_VALIDATOR = SchemaValidator(core_schema.any_schema())
+
+
+def _normalize_action(action: str) -> str:
+ """Comparison key for actions: case- and underscore-insensitive."""
+ return action.replace("_", "").casefold()
+
+
+# High-confidence operations that can put credentials, tokens, passwords,
+# decrypted secret material, or plaintext key material into the agent's
context.
+# A wildcard in ``allowed_actions`` never matches these -- each must be listed
+# verbatim to be callable. This is a defense-in-depth guard, not an exhaustive
+# AWS security boundary; least-privilege IAM on the connection's role remains
+# the real boundary.
+_CREDENTIAL_RETURNING_ACTIONS = frozenset(
+ _normalize_action(action)
+ for action in (
+ "sts:AssumeRole",
+ "sts:AssumeRoleWithSAML",
+ "sts:AssumeRoleWithWebIdentity",
+ "sts:GetFederationToken",
+ "sts:GetSessionToken",
+ "secretsmanager:GetSecretValue",
+ "secretsmanager:BatchGetSecretValue",
+ "ssm:GetParameter",
+ "ssm:GetParameters",
+ "ssm:GetParametersByPath",
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ "kms:GenerateDataKeyPair",
+ "ecr:GetAuthorizationToken",
+ "ecr-public:GetAuthorizationToken",
+ "iam:CreateAccessKey",
+ "iam:CreateLoginProfile",
+ "iam:UpdateLoginProfile",
+ "iam:CreateServiceSpecificCredential",
+ "iam:ResetServiceSpecificCredential",
+ "cognito-identity:GetCredentialsForIdentity",
+ "cognito-identity:GetOpenIdToken",
+ "cognito-identity:GetOpenIdTokenForDeveloperIdentity",
+ "redshift:GetClusterCredentials",
+ "redshift:GetClusterCredentialsWithIAM",
+ "redshift-serverless:GetCredentials",
+ "lightsail:GetInstanceAccessDetails",
+ "lightsail:GetRelationalDatabaseMasterUserPassword",
+ )
+)
+
+_SHAPE_DEPTH = 3
+
+# JSON Schemas for the three AWS tools.
+_LIST_OPERATIONS_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "service": {
+ "type": "string",
+ "description": "Optional service name filter, e.g. 's3'.",
+ },
+ },
+}
+
+_DESCRIBE_OPERATION_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "service": {"type": "string", "description": "AWS service name, e.g.
's3'."},
+ "operation": {
+ "type": "string",
+ "description": "API operation name, e.g. 'ListObjectsV2'.",
+ },
+ },
+ "required": ["service", "operation"],
+}
+
+_CALL_AWS_SCHEMA: dict[str, Any] = {
+ "type": "object",
+ "properties": {
+ "service": {"type": "string", "description": "AWS service name, e.g.
's3'."},
+ "operation": {
+ "type": "string",
+ "description": "API operation name, e.g. 'ListObjectsV2'.",
+ },
+ "parameters": {
+ "type": "object",
+ "description": (
+ "Operation parameters exactly as the API expects them (see
describe_aws_operation)."
+ ),
+ },
+ },
+ "required": ["service", "operation"],
+}
+
+
+class AWSToolset(AbstractToolset[Any]):
+ """
+ Curated toolset that gives an LLM agent allow-listed access to AWS APIs.
+
+ Exposes three tools:
+
+ - ``list_aws_operations`` -- the operations this toolset allows, grouped by
+ service (the allow-list intersected with the botocore service model).
+ - ``describe_aws_operation`` -- an operation's parameter and response
shapes,
+ derived from the botocore service model, so the agent can check what a
+ call expects *before* making it.
+ - ``call_aws`` -- execute an allowed operation and return the response as
+ JSON (paginated operations are aggregated up to ``max_items``).
+
+ Credentials, region, and session configuration come exclusively from the
+ Airflow connection (resolved lazily through the amazon provider's
+ ``AwsBaseHook``); none of them are tool arguments, so the model cannot
+ steer them.
+
+ When a call fails, AWS's own error message is returned to the agent as a
+ retry (:class:`pydantic_ai.ModelRetry`) so the model can correct its
+ parameters within the run. pydantic-ai bounds this by the tool's
+ ``max_retries``, so an unrecoverable error -- bad credentials, missing IAM
+ permissions -- exhausts the retries and fails the task for Airflow to
retry.
+
+ :param aws_conn_id: Airflow connection ID for AWS credentials.
+ :param allowed_actions: Operations the agent may call, in
+ ``"<service>:<Operation>"`` form using boto3 service names and API
+ operation names -- e.g. ``["s3:ListBuckets", "s3:GetObject",
+ "athena:*"]``. Required -- access is deny-by-default and there is
+ deliberately no auto-discovery. The operation part accepts ``*``/``?``
+ wildcards; the service part must be explicit. Matching is case- and
+ underscore-insensitive, so ``"s3:list_buckets"`` and
+ ``"s3:ListBuckets"`` are equivalent.
+
+ Operations that return credentials or decrypted secrets (for example
+ ``sts:AssumeRole``, ``secretsmanager:GetSecretValue``, ``kms:Decrypt``)
+ are never matched by a wildcard -- each must be listed verbatim to be
+ callable.
+
+ .. note::
+ This is an application-level guardrail: it bounds what the agent
+ can *ask for*, not what the credentials can *do*. Pair it with a
+ least-privilege IAM role on ``aws_conn_id`` for a hard guarantee.
+
+ :param region_name: AWS region for API calls. ``None`` (default) uses the
+ region configured on the connection. Deliberately not exposed as a tool
+ argument.
+ :param max_items: Upper bound on items aggregated from paginated operations
+ (applied via the paginator's ``MaxItems``). Default ``1000``.
+ :param max_output_bytes: Upper bound on the serialized response returned to
+ the agent. Larger payloads are clipped and flagged with
+ ``"truncated": true``. Default ``65536``.
+ """
+
+ def __init__(
+ self,
+ aws_conn_id: str = "aws_default",
+ *,
+ allowed_actions: list[str],
+ region_name: str | None = None,
+ max_items: int = 1000,
+ max_output_bytes: int = 65536,
+ ) -> None:
+ if not allowed_actions:
+ raise ValueError("allowed_actions must be a non-empty list.")
+
+ # Validation below uses botocore's bundled service definitions only --
+ # local file reads, no credentials, no network -- so it is safe to run
+ # at Dag parse time.
+ patterns: dict[str, list[str]] = {}
+ literal_actions: set[str] = set()
+ for action in allowed_actions:
+ service, sep, operation = action.partition(":")
+ if not sep or not service or not operation:
+ raise ValueError(
+ f"Invalid action {action!r}: expected
'<service>:<Operation>', e.g. 's3:ListBuckets'."
+ )
+ if any(ch in service for ch in "*?["):
+ raise ValueError(f"Invalid action {action!r}: wildcards are
not allowed in the service part.")
+ if service not in _get_available_services():
+ raise ValueError(
+ f"Unknown AWS service {service!r} in action {action!r}. "
+ "Use the boto3 service name, e.g. 's3' or
'secretsmanager'."
+ )
+ key = _normalize_action(action)
+ if any(ch in operation for ch in "*?["):
+ model = _load_service_model(service)
+ matched_operations = [
+ name
+ for name in model.operation_names
+ if self._is_pattern_match_allowed(key, service, name)
+ ]
+ if not matched_operations:
+ raise ValueError(
+ f"Action pattern {action!r} does not match any
non-sensitive operation "
+ f"for service {service!r}."
+ )
+ patterns.setdefault(service, []).append(key)
+ else:
+ if _resolve_operation_name(_load_service_model(service),
operation) is None:
+ raise ValueError(
+ f"Unknown operation {operation!r} for service
{service!r} in action "
+ f"{action!r}. Use the API operation name, e.g.
'ListBuckets'."
+ )
+ patterns.setdefault(service, []).append(key)
+ literal_actions.add(key)
+
+ self._aws_conn_id = aws_conn_id
+ self._region_name = region_name
+ self._max_items = max_items
+ self._max_output_bytes = max_output_bytes
+ self._patterns = patterns
+ self._literal_actions = frozenset(literal_actions)
+ # Populated lazily at call time, so Dag parsing never creates AWS
clients.
+ self._clients: dict[str, BaseClient] = {}
+
+ @property
+ def id(self) -> str:
+ return f"aws-{self._aws_conn_id}"
+
+ # ------------------------------------------------------------------
+ # AbstractToolset interface
+ # ------------------------------------------------------------------
+
+ async def get_tools(self, ctx: RunContext[Any]) -> dict[str,
ToolsetTool[Any]]:
+ tools: dict[str, ToolsetTool[Any]] = {}
+
+ for name, description, schema in (
+ (
+ "list_aws_operations",
+ "List the AWS operations this toolset allows, grouped by
service.",
+ _LIST_OPERATIONS_SCHEMA,
+ ),
+ (
+ "describe_aws_operation",
+ "Show an operation's parameters and response shape before
calling it.",
+ _DESCRIBE_OPERATION_SCHEMA,
+ ),
+ (
+ "call_aws",
+ "Execute an allowed AWS operation and return the response as
JSON.",
+ _CALL_AWS_SCHEMA,
+ ),
+ ):
+ # sequential=True because all tools share per-service boto3 clients
+ # with synchronous I/O -- they must not run concurrently.
+ # return_schema is "string": every tool returns a JSON-encoded
string,
+ # so code mode renders `-> str` instead of `-> Any`.
+ tool_def = ToolDefinition(
+ name=name,
+ description=description,
+ parameters_json_schema=schema,
+ sequential=True,
+ **return_schema_kwargs({"type": "string"}),
+ )
+ tools[name] = ToolsetTool(
+ toolset=self,
+ tool_def=tool_def,
+ max_retries=1,
+ args_validator=_PASSTHROUGH_VALIDATOR,
+ )
+ return tools
+
+ async def call_tool(
+ self,
+ name: str,
+ tool_args: dict[str, Any],
+ ctx: RunContext[Any],
+ tool: ToolsetTool[Any],
+ ) -> Any:
+ if name not in ("list_aws_operations", "describe_aws_operation",
"call_aws"):
+ raise ValueError(f"Unknown tool: {name!r}")
+ try:
+ if name == "list_aws_operations":
+ return self._list_operations(tool_args.get("service"))
+ if name == "describe_aws_operation":
+ return self._describe_operation(tool_args["service"],
tool_args["operation"])
+ return self._call_aws(
+ tool_args["service"], tool_args["operation"],
tool_args.get("parameters") or {}
+ )
+ except Exception as e:
+ # Hand AWS's own error back to the agent as a retry so it can
correct
+ # its parameters within the run. pydantic-ai bounds this by the
tool's
+ # max_retries, so an unrecoverable error (bad credentials, missing
IAM
+ # permissions) exhausts the budget and fails the task for Airflow
to
+ # retry, rather than being silently worked around.
+ raise ModelRetry(
+ f"The {name} tool failed: {e}\n"
+ "Use list_aws_operations to see what you may call and "
+ "describe_aws_operation to check the expected parameters, then
try again."
+ ) from e
+
+ # ------------------------------------------------------------------
+ # Authorization
+ # ------------------------------------------------------------------
+
+ def _is_action_allowed(self, *, service: str, operation: str) -> bool:
+ key = _normalize_action(f"{service}:{operation}")
+ if key in _CREDENTIAL_RETURNING_ACTIONS:
+ return key in self._literal_actions
+ return any(fnmatchcase(key, pattern) for pattern in
self._patterns.get(service, ()))
+
+ def _is_pattern_match_allowed(self, pattern: str, service: str, operation:
str) -> bool:
+ key = _normalize_action(f"{service}:{operation}")
+ return key not in _CREDENTIAL_RETURNING_ACTIONS and fnmatchcase(key,
pattern)
+
+ def _resolve_allowed_service(self, service: str) -> str:
+ """Validate a tool-supplied service name against the allow-list."""
+ if service not in self._patterns:
+ raise ValueError(
+ f"Service {service!r} is not in this toolset's allowed
actions. "
+ f"Allowed services: {', '.join(sorted(self._patterns))}."
+ )
+ return service
+
+ def _resolve_allowed_operation(self, service: str, operation: str) ->
tuple[str, ServiceModel]:
+ model = _load_service_model(self._resolve_allowed_service(service))
+ canonical = _resolve_operation_name(model, operation)
+ if canonical is None:
+ raise ValueError(f"Unknown operation {operation!r} for service
{service!r}.")
+ if not self._is_action_allowed(service=service, operation=canonical):
+ raise ValueError(f"Operation {service}:{canonical} is not in this
toolset's allowed actions.")
+ return canonical, model
+
+ # ------------------------------------------------------------------
+ # Tool implementations
+ # ------------------------------------------------------------------
+
+ def _list_operations(self, service: str | None) -> str:
+ services = [self._resolve_allowed_service(service)] if service else
sorted(self._patterns)
+ listing = {
+ svc: sorted(
+ name
+ for name in _load_service_model(svc).operation_names
+ if self._is_action_allowed(service=svc, operation=name)
+ )
+ for svc in services
+ }
+ return json.dumps(listing)
+
+ def _describe_operation(self, service: str, operation: str) -> str:
+ canonical, model = self._resolve_allowed_operation(service, operation)
+ op = model.operation_model(canonical)
+ return json.dumps(
+ {
+ "service": service,
+ "operation": canonical,
+ "documentation": _strip_html(op.documentation)[:600],
+ "input": _describe_shape(op.input_shape),
+ "output": _describe_shape(op.output_shape),
+ }
+ )
+
+ def _call_aws(self, service: str, operation: str, parameters: dict[str,
Any]) -> str:
+ canonical, _ = self._resolve_allowed_operation(service, operation)
+ client = self._get_client(service)
+ method_name = xform_name(canonical)
+ if client.can_paginate(method_name):
+ pages = client.get_paginator(method_name).paginate(
+ **parameters, PaginationConfig={"MaxItems": self._max_items}
+ )
+ response = pages.build_full_result()
+ else:
+ response = getattr(client, method_name)(**parameters)
+ return self._serialize_response(response)
+
+ # ------------------------------------------------------------------
+ # Lazy client resolution and serialization
+ # ------------------------------------------------------------------
+
+ def _get_client(self, service: str) -> BaseClient:
+ if service not in self._clients:
+ hook = AwsBaseHook(
+ aws_conn_id=self._aws_conn_id, client_type=service,
region_name=self._region_name
+ )
+ self._clients[service] = hook.get_conn()
+ return self._clients[service]
+
+ def _serialize_response(self, response: Any) -> str:
+ if isinstance(response, dict):
+ response.pop("ResponseMetadata", None)
+ payload = json.dumps(_to_jsonable(response,
max_bytes=self._max_output_bytes), default=str)
+ if len(payload) > self._max_output_bytes:
+ return json.dumps(
+ {
+ "truncated": True,
+ "max_output_bytes": self._max_output_bytes,
+ "data": payload[: self._max_output_bytes],
+ }
+ )
+ return payload
+
+
+# ---------------------------------------------------------------------------
+# Private botocore helpers
+# ---------------------------------------------------------------------------
+
+
+@cache
+def _get_available_services() -> frozenset[str]:
+ return frozenset(botocore.session.get_session().get_available_services())
+
+
+@cache
+def _load_service_model(service: str) -> ServiceModel:
+ return botocore.session.get_session().get_service_model(service)
+
+
+def _resolve_operation_name(model: ServiceModel, operation: str) -> str | None:
+ """Resolve a case/underscore-insensitive operation name to its canonical
API name."""
+ wanted = _normalize_action(operation)
+ for name in model.operation_names:
+ if _normalize_action(name) == wanted:
+ return name
+ return None
+
+
+def _describe_shape(shape: Shape | None, depth: int = _SHAPE_DEPTH) -> Any:
+ """Render a botocore shape as a compact JSON-friendly summary."""
+ if shape is None:
+ return None
+ type_name = shape.type_name
+ if type_name == "structure":
+ if depth <= 0:
+ return type_name
+ required = set(shape.required_members)
+ return {
+ name: (
+ {"type": _describe_shape(member, depth - 1), "required": True}
+ if name in required
+ else {"type": _describe_shape(member, depth - 1)}
+ )
+ for name, member in shape.members.items()
+ }
+ if type_name == "list":
+ return [_describe_shape(shape.member, depth - 1)] if depth > 0 else
type_name
+ if type_name == "map":
+ return {"<key>": _describe_shape(shape.value, depth - 1)} if depth > 0
else type_name
+ enum = getattr(shape, "enum", None)
+ if enum:
+ return f"{type_name} (one of: {', '.join(enum)})"
+ return type_name
+
+
+_HTML_TAG_RE = re.compile(r"<[^>]+>")
+
+
+def _strip_html(text: str | None) -> str:
+ return " ".join(_HTML_TAG_RE.sub(" ", text or "").split())
+
+
+def _to_jsonable(value: Any, *, max_bytes: int) -> Any:
+ """Make a boto3 response JSON-serializable; datetimes are handled by
``default=str``."""
+ if isinstance(value, StreamingBody):
+ # Read one byte past the cap so truncation is detectable.
+ raw = value.read(max_bytes + 1)
+ text = raw[:max_bytes].decode("utf-8", errors="replace")
+ if len(raw) > max_bytes:
+ return {"truncated": True, "data": text}
+ return text
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace")
+ if isinstance(value, dict):
+ return {k: _to_jsonable(v, max_bytes=max_bytes) for k, v in
value.items()}
+ if isinstance(value, list):
+ return [_to_jsonable(v, max_bytes=max_bytes) for v in value]
+ return value
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_aws.py
b/providers/common/ai/tests/unit/common/ai/toolsets/test_aws.py
new file mode 100644
index 00000000000..08d29f12b90
--- /dev/null
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_aws.py
@@ -0,0 +1,359 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import asyncio
+import json
+from datetime import datetime, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+from botocore.exceptions import ClientError
+from pydantic_ai.exceptions import ModelRetry
+
+from airflow.providers.common.ai.toolsets.aws import (
+ AWSToolset,
+ _get_available_services,
+ _load_service_model,
+ _resolve_operation_name,
+)
+from airflow.providers.common.ai.utils.tool_definition import
_SUPPORTS_RETURN_SCHEMA
+
+
+def _make_toolset(**kwargs):
+ kwargs.setdefault("allowed_actions", ["s3:List*", "s3:GetBucketLocation"])
+ return AWSToolset("aws_test", **kwargs)
+
+
+def _make_mock_client():
+ client = MagicMock()
+ client.can_paginate.return_value = False
+ return client
+
+
+def _call(ts, name, args):
+ return asyncio.run(ts.call_tool(name, args, ctx=MagicMock(),
tool=MagicMock()))
+
+
+class TestAWSToolsetInit:
+ def test_id_includes_conn_id(self):
+ ts = _make_toolset()
+ assert ts.id == "aws-aws_test"
+
+ def test_rejects_empty_allowed_actions(self):
+ with pytest.raises(ValueError, match="non-empty"):
+ AWSToolset("aws_test", allowed_actions=[])
+
+ @pytest.mark.parametrize(
+ ("action", "match"),
+ [
+ ("s3", "expected"),
+ ("s3:", "expected"),
+ (":ListBuckets", "expected"),
+ ("*:GetObject", "wildcards are not allowed in the service part"),
+ ("nosuchservice:DoThing", "Unknown AWS service"),
+ ("s3:NoSuchOperation", "Unknown operation"),
+ ],
+ )
+ def test_rejects_invalid_action(self, action, match):
+ with pytest.raises(ValueError, match=match):
+ AWSToolset("aws_test", allowed_actions=[action])
+
+ def test_accepts_snake_case_operation(self):
+ AWSToolset("aws_test", allowed_actions=["s3:list_buckets"])
+
+ def test_rejects_wildcard_action_that_matches_no_operations(self):
+ with pytest.raises(ValueError, match="does not match any"):
+ AWSToolset("aws_test", allowed_actions=["s3:ListObjectz*"])
+
+ def
test_rejects_wildcard_action_that_only_matches_sensitive_operations(self):
+ with pytest.raises(ValueError, match="does not match any"):
+ AWSToolset("aws_test", allowed_actions=["kms:Decrypt*"])
+
+ @patch("airflow.providers.common.ai.toolsets.aws._get_available_services",
autospec=True)
+ def test_rejects_service_missing_from_available_services(self,
mock_get_available_services):
+ mock_get_available_services.return_value = frozenset({"ec2"})
+
+ with pytest.raises(ValueError, match="Unknown AWS service 's3'"):
+ AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+
+class TestAWSToolsetGetTools:
+ def test_returns_three_tools(self):
+ ts = _make_toolset()
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock()))
+ assert set(tools.keys()) == {"list_aws_operations",
"describe_aws_operation", "call_aws"}
+
+ def test_tool_definitions_have_descriptions(self):
+ ts = _make_toolset()
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock()))
+ for tool in tools.values():
+ assert tool.tool_def.description
+
+ @pytest.mark.skipif(
+ not _SUPPORTS_RETURN_SCHEMA, reason="pydantic-ai too old for
ToolDefinition.return_schema"
+ )
+ def test_tools_declare_string_return_schema(self):
+ ts = _make_toolset()
+ tools = asyncio.run(ts.get_tools(ctx=MagicMock()))
+ for tool in tools.values():
+ assert tool.tool_def.return_schema == {"type": "string"}
+
+
+class TestAWSToolsetActionMatching:
+ @pytest.mark.parametrize(
+ ("actions", "service", "operation", "allowed"),
+ [
+ (["s3:ListBuckets"], "s3", "ListBuckets", True),
+ (["s3:list_buckets"], "s3", "ListBuckets", True),
+ (["s3:List*"], "s3", "ListObjectsV2", True),
+ (["s3:List*"], "s3", "GetObject", False),
+ (["s3:*"], "ec2", "DescribeInstances", False),
+ (["secretsmanager:*"], "secretsmanager", "GetSecretValue", False),
+ (["secretsmanager:GetSecretValue"], "secretsmanager",
"GetSecretValue", True),
+ (["kms:*"], "kms", "Decrypt", False),
+ (["kms:Decrypt"], "kms", "Decrypt", True),
+ ],
+ )
+ def test_allow_list_matching(self, actions, service, operation, allowed):
+ ts = AWSToolset("aws_test", allowed_actions=actions)
+ assert ts._is_action_allowed(service=service, operation=operation) is
allowed
+
+
+class TestAWSToolsetPrivateHelpers:
+ def test_is_action_allowed_requires_keyword_arguments(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(TypeError, match="positional"):
+ ts._is_action_allowed("s3", "ListBuckets") # type: ignore[misc]
+
+ def
test_pattern_match_allows_normal_operations_but_not_sensitive_operations(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:List*", "kms:List*"])
+
+ assert ts._is_pattern_match_allowed("s3:list*", "s3", "ListBuckets")
is True
+ assert ts._is_pattern_match_allowed("kms:decrypt*", "kms", "Decrypt")
is False
+
+ def test_resolve_allowed_service_returns_configured_service(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ assert ts._resolve_allowed_service("s3") == "s3"
+
+ def test_resolve_allowed_service_rejects_service_outside_allow_list(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(ValueError, match="Service 'ec2' is not in this
toolset's allowed actions"):
+ ts._resolve_allowed_service("ec2")
+
+ def test_resolve_allowed_operation_returns_canonical_name_and_model(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ operation, model = ts._resolve_allowed_operation("s3", "list_buckets")
+
+ assert operation == "ListBuckets"
+ assert "ListBuckets" in model.operation_names
+
+ def test_resolve_allowed_operation_rejects_unknown_operation(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(ValueError, match="Unknown operation
'NoSuchOperation' for service 's3'"):
+ ts._resolve_allowed_operation("s3", "NoSuchOperation")
+
+ def test_resolve_allowed_operation_rejects_disallowed_operation(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(
+ ValueError, match="Operation s3:GetObject is not in this toolset's
allowed actions"
+ ):
+ ts._resolve_allowed_operation("s3", "GetObject")
+
+ def
test_resolve_allowed_operation_rejects_service_outside_allow_list(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(ValueError, match="Service 'ec2' is not in this
toolset's allowed actions"):
+ ts._resolve_allowed_operation("ec2", "DescribeInstances")
+
+ def test_get_available_services_reads_botocore_catalog(self):
+ assert "s3" in _get_available_services()
+
+ def test_load_service_model_and_resolve_operation_name(self):
+ model = _load_service_model("s3")
+
+ assert _resolve_operation_name(model, "list_buckets") == "ListBuckets"
+ assert _resolve_operation_name(model, "NoSuchOperation") is None
+
+
+class TestAWSToolsetListOperations:
+ def test_lists_only_allowed_operations(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:List*"])
+ listing = json.loads(_call(ts, "list_aws_operations", {}))
+ assert "ListBuckets" in listing["s3"]
+ assert "GetObject" not in listing["s3"]
+
+ def test_sensitive_operations_hidden_behind_wildcard(self):
+ ts = AWSToolset("aws_test", allowed_actions=["secretsmanager:*"])
+ listing = json.loads(_call(ts, "list_aws_operations", {}))
+ assert "ListSecrets" in listing["secretsmanager"]
+ assert "GetSecretValue" not in listing["secretsmanager"]
+
+ def test_verbatim_sensitive_operation_is_listed(self):
+ ts = AWSToolset("aws_test", allowed_actions=["secretsmanager:*",
"secretsmanager:GetSecretValue"])
+ listing = json.loads(_call(ts, "list_aws_operations", {}))
+ assert "GetSecretValue" in listing["secretsmanager"]
+
+ def test_service_outside_allow_list_raises_model_retry(self):
+ ts = _make_toolset()
+ with pytest.raises(ModelRetry, match="not in this toolset's allowed
actions"):
+ _call(ts, "list_aws_operations", {"service": "ec2"})
+
+
+class TestAWSToolsetDescribeOperation:
+ def test_returns_input_shape_with_required_members(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListObjectsV2"])
+ described = json.loads(
+ _call(ts, "describe_aws_operation", {"service": "s3", "operation":
"ListObjectsV2"})
+ )
+ assert described["operation"] == "ListObjectsV2"
+ assert described["input"]["Bucket"]["required"] is True
+ assert "Contents" in described["output"]
+
+ def test_resolves_snake_case_operation_name(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListObjectsV2"])
+ described = json.loads(
+ _call(ts, "describe_aws_operation", {"service": "s3", "operation":
"list_objects_v2"})
+ )
+ assert described["operation"] == "ListObjectsV2"
+
+ @pytest.mark.parametrize(
+ ("service", "operation", "match"),
+ [
+ ("s3", "GetObject", "not in this toolset's allowed actions"),
+ ("ec2", "DescribeInstances", "Service 'ec2' is not in this
toolset's allowed actions"),
+ ("s3", "NoSuchOperation", "Unknown operation 'NoSuchOperation' for
service 's3'"),
+ ],
+ ids=["disallowed_operation", "service_outside_allow_list",
"unknown_operation"],
+ )
+ def test_model_supplied_invalid_operation_raises_model_retry(self,
service, operation, match):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(ModelRetry, match=match):
+ _call(ts, "describe_aws_operation", {"service": service,
"operation": operation})
+
+
+class TestAWSToolsetCallAws:
+ def test_executes_operation_and_returns_json(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+ client = _make_mock_client()
+ client.list_buckets.return_value = {
+ "Buckets": [
+ {
+ "Name": "data-lake-raw",
+ "CreationDate": datetime(2026, 1, 1, tzinfo=timezone.utc),
+ }
+ ],
+ "ResponseMetadata": {"RequestId": "abc123"},
+ }
+ ts._clients["s3"] = client
+
+ result = json.loads(_call(ts, "call_aws", {"service": "s3",
"operation": "ListBuckets"}))
+ client.list_buckets.assert_called_once_with()
+ assert result["Buckets"][0]["Name"] == "data-lake-raw"
+ # datetime serialized via default=str, ResponseMetadata stripped.
+ assert "2026-01-01" in result["Buckets"][0]["CreationDate"]
+ assert "ResponseMetadata" not in result
+
+ def test_snake_case_operation_resolves(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+ client = _make_mock_client()
+ client.list_buckets.return_value = {"Buckets": []}
+ ts._clients["s3"] = client
+
+ result = json.loads(_call(ts, "call_aws", {"service": "s3",
"operation": "list_buckets"}))
+ assert result == {"Buckets": []}
+
+ def test_uses_paginator_when_available(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListObjectsV2"],
max_items=250)
+ client = _make_mock_client()
+ client.can_paginate.return_value = True
+ paginator = client.get_paginator.return_value
+ paginator.paginate.return_value.build_full_result.return_value =
{"Contents": [{"Key": "a.parquet"}]}
+ ts._clients["s3"] = client
+
+ result = json.loads(
+ _call(
+ ts,
+ "call_aws",
+ {"service": "s3", "operation": "ListObjectsV2", "parameters":
{"Bucket": "b"}},
+ )
+ )
+ paginator.paginate.assert_called_once_with(Bucket="b",
PaginationConfig={"MaxItems": 250})
+ assert result["Contents"] == [{"Key": "a.parquet"}]
+
+ @pytest.mark.parametrize(
+ ("service", "operation", "match"),
+ [
+ ("s3", "DeleteBucket", "not in this toolset's allowed actions"),
+ ("ec2", "DescribeInstances", "Service 'ec2' is not in this
toolset's allowed actions"),
+ ("s3", "NoSuchOperation", "Unknown operation 'NoSuchOperation' for
service 's3'"),
+ ],
+ ids=["disallowed_operation", "service_outside_allow_list",
"unknown_operation"],
+ )
+ def
test_model_supplied_invalid_operation_raises_model_retry_without_client(
+ self, service, operation, match
+ ):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+
+ with pytest.raises(ModelRetry, match=match):
+ _call(ts, "call_aws", {"service": service, "operation": operation})
+ assert ts._clients == {}
+
+ def test_client_error_raises_model_retry(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"])
+ client = _make_mock_client()
+ client.list_buckets.side_effect = ClientError(
+ {"Error": {"Code": "AccessDenied", "Message": "not authorized"}},
"ListBuckets"
+ )
+ ts._clients["s3"] = client
+
+ with pytest.raises(ModelRetry, match="AccessDenied"):
+ _call(ts, "call_aws", {"service": "s3", "operation":
"ListBuckets"})
+
+ def test_large_response_is_truncated(self):
+ ts = AWSToolset("aws_test", allowed_actions=["s3:ListBuckets"],
max_output_bytes=200)
+ client = _make_mock_client()
+ client.list_buckets.return_value = {"Buckets": [{"Name": "x" * 50}] *
50}
+ ts._clients["s3"] = client
+
+ result = json.loads(_call(ts, "call_aws", {"service": "s3",
"operation": "ListBuckets"}))
+ assert result["truncated"] is True
+ assert len(result["data"]) == 200
+
+ @patch("airflow.providers.common.ai.toolsets.aws.AwsBaseHook",
autospec=True)
+ def test_client_resolved_via_amazon_hook(self, mock_hook_cls):
+ client = _make_mock_client()
+ client.list_buckets.return_value = {"Buckets": []}
+ mock_hook_cls.return_value.get_conn.return_value = client
+
+ ts = AWSToolset("aws_prod", allowed_actions=["s3:ListBuckets"],
region_name="eu-west-1")
+ _call(ts, "call_aws", {"service": "s3", "operation": "ListBuckets"})
+ mock_hook_cls.assert_called_once_with(
+ aws_conn_id="aws_prod", client_type="s3", region_name="eu-west-1"
+ )
+
+ def test_unknown_tool_raises_value_error(self):
+ ts = _make_toolset()
+ with pytest.raises(ValueError, match="Unknown tool"):
+ _call(ts, "use_aws", {})
diff --git a/uv.lock b/uv.lock
index 976c0d847e1..ea072289887 100644
--- a/uv.lock
+++ b/uv.lock
@@ -4369,12 +4369,18 @@ dependencies = [
]
[package.optional-dependencies]
+amazon = [
+ { name = "apache-airflow-providers-amazon" },
+]
anthropic = [
{ name = "pydantic-ai-slim", extra = ["anthropic"] },
]
avro = [
{ name = "fastavro" },
]
+aws = [
+ { name = "apache-airflow-providers-amazon" },
+]
bedrock = [
{ name = "pydantic-ai-slim", extra = ["bedrock"] },
]
@@ -4430,6 +4436,7 @@ sql = [
dev = [
{ name = "apache-airflow" },
{ name = "apache-airflow-devel-common" },
+ { name = "apache-airflow-providers-amazon" },
{ name = "apache-airflow-providers-common-compat" },
{ name = "apache-airflow-providers-common-sql", extra = ["datafusion"] },
{ name = "apache-airflow-providers-git" },
@@ -4450,6 +4457,8 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "apache-airflow", editable = "." },
+ { name = "apache-airflow-providers-amazon", marker = "extra == 'amazon'",
editable = "providers/amazon" },
+ { name = "apache-airflow-providers-amazon", marker = "extra == 'aws'",
editable = "providers/amazon" },
{ name = "apache-airflow-providers-common-compat", editable =
"providers/common/compat" },
{ name = "apache-airflow-providers-common-sql", marker = "extra ==
'common-sql'", editable = "providers/common/sql" },
{ name = "apache-airflow-providers-common-sql", marker = "extra == 'sql'",
editable = "providers/common/sql" },
@@ -4478,12 +4487,13 @@ requires-dist = [
{ name = "python-docx", marker = "extra == 'docx'", specifier = ">=1.0.0"
},
{ name = "sqlglot", marker = "extra == 'sql'", specifier = ">=30.0.0" },
]
-provides-extras = ["anthropic", "bedrock", "google", "openai", "mcp",
"code-mode", "shields", "skills", "avro", "parquet", "sql", "common-sql",
"langchain", "llamaindex", "pdf", "docx", "git"]
+provides-extras = ["anthropic", "bedrock", "google", "openai", "mcp",
"code-mode", "shields", "skills", "avro", "parquet", "sql", "aws",
"common-sql", "langchain", "llamaindex", "pdf", "docx", "git", "amazon"]
[package.metadata.requires-dev]
dev = [
{ name = "apache-airflow", editable = "." },
{ name = "apache-airflow-devel-common", editable = "devel-common" },
+ { name = "apache-airflow-providers-amazon", editable = "providers/amazon"
},
{ name = "apache-airflow-providers-common-compat", editable =
"providers/common/compat" },
{ name = "apache-airflow-providers-common-sql", editable =
"providers/common/sql" },
{ name = "apache-airflow-providers-common-sql", extras = ["datafusion"],
editable = "providers/common/sql" },