This is an automated email from the ASF dual-hosted git repository.
potiuk 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 77609e83c22 Align Snowflake Cortex Agent hook with the Common AI
managed-agent contract (#74672)
77609e83c22 is described below
commit 77609e83c227ce65b0bb0e606ec73d87aca7bc9d
Author: SameerMesiah97 <[email protected]>
AuthorDate: Sun Nov 1 13:56:29 2026 +0000
Align Snowflake Cortex Agent hook with the Common AI managed-agent contract
(#74672)
* Add Common AI managed-agent support to Snowflake Cortex Agent hook
Implement agent resolution, capabilities and stateless invocation.
Test request conversion, validation, error handling and optional imports.
* Import AirflowOptionalProviderFeatureException from common.compat in the
Cortex agent hook
Generated-by: Claude Opus 5
---------
Co-authored-by: Sameer Mesiah <[email protected]>
Co-authored-by: Jarek Potiuk <[email protected]>
---
providers/snowflake/pyproject.toml | 2 +-
.../providers/snowflake/hooks/cortex_agent.py | 182 +++++++-
.../unit/snowflake/hooks/test_cortex_agent.py | 475 ++++++++++++++++++++-
uv.lock | 2 +-
4 files changed, 649 insertions(+), 12 deletions(-)
diff --git a/providers/snowflake/pyproject.toml
b/providers/snowflake/pyproject.toml
index 111b0c26702..43b1b826ade 100644
--- a/providers/snowflake/pyproject.toml
+++ b/providers/snowflake/pyproject.toml
@@ -93,7 +93,7 @@ dependencies = [
# Any change in the dependencies is preserved when the file is regenerated
[project.optional-dependencies]
"common.ai" = [
- "apache-airflow-providers-common-ai[openai]>=0.10.0",
+ "apache-airflow-providers-common-ai[openai]>=0.10.0", # use next version
]
"microsoft.azure" = [
"apache-airflow-providers-microsoft-azure>=12.8.0"
diff --git
a/providers/snowflake/src/airflow/providers/snowflake/hooks/cortex_agent.py
b/providers/snowflake/src/airflow/providers/snowflake/hooks/cortex_agent.py
index 23f056227c7..999ed6d0b23 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/hooks/cortex_agent.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/hooks/cortex_agent.py
@@ -17,20 +17,90 @@
from __future__ import annotations
+import re
from enum import Enum
-from typing import Any, Literal, overload
+from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload
from urllib.parse import quote
import requests
+from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
from airflow.providers.snowflake.utils._rest_auth import
SnowflakeRestTokenProvider, get_cortex_base_url
+if TYPE_CHECKING:
+ from airflow.providers.common.ai.exceptions import
ManagedAgentInvocationError
+ from airflow.providers.common.ai.managed_agents.base import (
+ BaseManagedAgentHook,
+ ManagedAgentCapabilities,
+ ManagedAgentRef,
+ ManagedAgentRequest,
+ ManagedAgentResponse,
+ )
+else:
+ try:
+ from airflow.providers.common.ai.exceptions import
ManagedAgentInvocationError
+ from airflow.providers.common.ai.managed_agents.base import (
+ BaseManagedAgentHook,
+ ManagedAgentCapabilities,
+ ManagedAgentRef,
+ ManagedAgentResponse,
+ )
+ except ImportError:
+
+ def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+ raise AirflowOptionalProviderFeatureException(
+ "Using a Cortex Agent through the managed-agent contract needs
the 'common.ai' "
+ "extra of the snowflake provider: "
+ "pip install 'apache-airflow-providers-snowflake[common.ai]'."
+ )
+
+ class BaseManagedAgentHook:
+ """Stand-in for the optional Common AI contract."""
+
+ agent = _needs_common_ai
+
+ ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse =
_needs_common_ai
+ ManagedAgentInvocationError = _needs_common_ai
+
+
+# Quoted identifiers can contain dots and escaped double quotes. Unquoted
identifiers
+# follow Snowflake's identifier rules and are normalized to uppercase.
+_IDENTIFIER = r'(?:"(?:[^"\x00]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)'
+_AGENT_NAME = re.compile(rf"({_IDENTIFIER})\.({_IDENTIFIER})\.({_IDENTIFIER})")
+
+_RESERVED_OPTIONS = frozenset(
+ {
+ "database",
+ "schema",
+ "agent_name",
+ "agent",
+ "snowflake_conn_id",
+ "connection",
+ "messages",
+ "prompt",
+ "session_id",
+ "timeout",
+ "thread_id",
+ "parent_message_id",
+ "stream",
+ }
+)
+
+# The named-agent run endpoint supports tool selection; configuration changes
belong
+# to update_agent(). Keep the adapter's option surface explicit.
+_SUPPORTED_OPTIONS = frozenset({"tool_choice"})
+_TERMINAL_HTTP_STATUSES = frozenset({400, 401, 403, 404, 405, 422})
+
JsonDict = dict[str, Any]
JsonList = list[JsonDict]
JsonResponse = JsonDict | JsonList
+class _UnexpectedResponseShape(TypeError):
+ """The decoded response does not have the expected JSON structure."""
+
+
class CreateMode(str, Enum):
"""Resource creation modes for Cortex Agents."""
@@ -39,7 +109,7 @@ class CreateMode(str, Enum):
IF_NOT_EXISTS = "ifNotExists"
-class SnowflakeCortexAgentHook(SnowflakeHook):
+class SnowflakeCortexAgentHook(SnowflakeHook, BaseManagedAgentHook):
"""
Hook for interacting with Snowflake Cortex Agents.
@@ -57,12 +127,104 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
The resolved token is cached and renewed the same way as
``SnowflakeSqlApiHook`` -- see
``SnowflakeRestTokenProvider``, one instance per hook, so a long-running
agent reuses the
same JWT within its renewal window.
+
+ With the ``common.ai`` extra installed,
``hook.agent("DATABASE.SCHEMA.NAME")``
+ supports the Common AI managed-agent contract, accepting prompts or message
+ history and returning answer text with the raw response. The only supported
+ vendor option is ``tool_choice``. Agent configuration changes remain
available
+ through ``update_agent``.
"""
+ agent_platform: ClassVar[str] = "snowflake.cortex"
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._rest_token_provider: SnowflakeRestTokenProvider | None = None
+ @staticmethod
+ def _parse_agent_name(agent: str) -> tuple[str, str, str]:
+ """Validate and normalize name components, preserving quoted
identifier syntax."""
+ match = _AGENT_NAME.fullmatch(agent)
+ if match is None:
+ raise ValueError(f"A Cortex agent must be a fully qualified
DATABASE.SCHEMA.NAME, got {agent!r}.")
+
+ database, schema, agent_name = (
+ part if part.startswith('"') else part.upper() for part in
match.groups()
+ )
+ return database, schema, agent_name
+
+ def resolve_agent(self, agent: str) -> ManagedAgentRef:
+ """Return the normalized agent reference without making a network
call."""
+ # Construct first so the optional-dependency error precedes validation.
+ ref = ManagedAgentRef(platform=self.agent_platform, name=agent)
+ parts = self._parse_agent_name(agent)
+ return ManagedAgentRef(platform=ref.platform, name=".".join(parts))
+
+ def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+ """Report the stateless adapter's capabilities without making a
network call."""
+ self.resolve_agent(agent)
+ return ManagedAgentCapabilities()
+
+ def invoke_agent(
+ self,
+ agent: str,
+ request: ManagedAgentRequest,
+ ) -> ManagedAgentResponse:
+ """Invoke a named Cortex Agent through the optional Common AI
contract."""
+ # Check the optional dependency before accessing the request or
validating the name.
+ ManagedAgentRef(platform=self.agent_platform, name=agent)
+
+ database, schema, agent_name = self._parse_agent_name(agent)
+
+ if request.session_id is not None:
+ raise ValueError("The Cortex managed-agent adapter does not
support session_id.")
+
+ if reserved := _RESERVED_OPTIONS.intersection(request.vendor_options):
+ raise ValueError(f"vendor_options cannot override contract fields:
{sorted(reserved)}")
+
+ if unsupported := request.vendor_options.keys() - _SUPPORTED_OPTIONS:
+ raise ValueError(f"Unsupported Cortex vendor_options:
{sorted(unsupported)}")
+
+ messages = []
+ for message in request.as_messages():
+ normalized_message = dict(message)
+ if isinstance(normalized_message.get("content"), str):
+ normalized_message["content"] = [{"type": "text", "text":
normalized_message["content"]}]
+ messages.append(normalized_message)
+
+ try:
+ response = self.run_agent(
+ database=database,
+ schema=schema,
+ agent_name=agent_name,
+ messages=messages,
+ timeout=request.timeout,
+ **request.vendor_options,
+ )
+ except requests.HTTPError as exc:
+ if exc.response is not None and exc.response.status_code in
_TERMINAL_HTTP_STATUSES:
+ raise ManagedAgentInvocationError(
+ f"Cortex agent {agent!r} invocation failed with HTTP
{exc.response.status_code}."
+ ) from exc
+ raise
+ except requests.exceptions.JSONDecodeError as exc:
+ raise ManagedAgentInvocationError(
+ f"Cortex agent {agent!r} returned a response that could not be
decoded as JSON."
+ ) from exc
+ except _UnexpectedResponseShape as exc:
+ raise ManagedAgentInvocationError(
+ f"Cortex agent {agent!r} returned an unexpected response
shape: {exc}"
+ ) from exc
+
+ try:
+ text = self.get_text_response(response)
+ except (AttributeError, TypeError) as exc:
+ raise ManagedAgentInvocationError(
+ f"Cortex agent {agent!r} returned invalid response content."
+ ) from exc
+
+ return ManagedAgentResponse(text=text, raw=response)
+
def _get_base_url(self) -> str:
return get_cortex_base_url(self._get_static_conn_params)
@@ -74,7 +236,7 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
endpoint: str,
payload: JsonDict | None = None,
params: JsonDict | None = None,
- timeout: int | None = None,
+ timeout: float | None = None,
response_type: Literal["dict"],
allow_empty: bool = False,
) -> JsonDict: ...
@@ -87,7 +249,7 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
endpoint: str,
payload: JsonDict | None = None,
params: JsonDict | None = None,
- timeout: int | None = None,
+ timeout: float | None = None,
response_type: Literal["list"],
allow_empty: Literal[False] = False,
) -> JsonList: ...
@@ -99,7 +261,7 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
endpoint: str,
payload: JsonDict | None = None,
params: JsonDict | None = None,
- timeout: int | None = None,
+ timeout: float | None = None,
response_type: Literal["dict", "list"],
allow_empty: bool = False,
) -> JsonResponse:
@@ -135,14 +297,16 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
if response_type == "dict":
if not isinstance(data, dict):
- raise TypeError(f"Expected dict response, got
{type(data).__name__}")
+ raise _UnexpectedResponseShape(f"Expected dict response, got
{type(data).__name__}")
return data
if not isinstance(data, list):
- raise TypeError(f"Expected list[dict] response, got
{type(data).__name__}")
+ raise _UnexpectedResponseShape(f"Expected list[dict] response, got
{type(data).__name__}")
if not all(isinstance(item, dict) for item in data):
- raise TypeError("Expected list[dict] response, got list containing
non-dict elements")
+ raise _UnexpectedResponseShape(
+ "Expected list[dict] response, got list containing non-dict
elements"
+ )
return data
@@ -198,7 +362,7 @@ class SnowflakeCortexAgentHook(SnowflakeHook):
orchestration: dict[str, Any] | None = None,
tools: list[dict[str, Any]] | None = None,
tool_resources: dict[str, Any] | None = None,
- timeout: int | None = 600,
+ timeout: float | None = 600,
) -> JsonDict:
"""
Execute a Snowflake Cortex Agent and return the response payload.
diff --git
a/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_agent.py
b/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_agent.py
index 5451ef9ab47..e9d35906ef3 100644
--- a/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_agent.py
+++ b/providers/snowflake/tests/unit/snowflake/hooks/test_cortex_agent.py
@@ -16,7 +16,10 @@
# under the License.
from __future__ import annotations
+import subprocess
+import sys
from importlib import import_module
+from textwrap import dedent
from unittest import mock
import pytest
@@ -37,6 +40,24 @@ deprecated_import_warning: type[Warning] = getattr(
DeprecationWarning,
)
+
+try:
+ from airflow.providers.common.ai.exceptions import
ManagedAgentInvocationError
+ from airflow.providers.common.ai.managed_agents import (
+ ManagedAgentCapabilities,
+ ManagedAgentRef,
+ ManagedAgentRequest,
+ )
+except ImportError:
+ HAS_COMMON_AI = False
+else:
+ HAS_COMMON_AI = True
+
+needs_common_ai = pytest.mark.skipif(
+ not HAS_COMMON_AI,
+ reason="requires apache-airflow-providers-common-ai",
+)
+
MODULE_PATH = "airflow.providers.snowflake.hooks.cortex_agent"
HOOK_PATH = f"{MODULE_PATH}.SnowflakeCortexAgentHook"
@@ -938,6 +959,458 @@ class TestSnowflakeCortexAgentHook:
)
def test_deprecated_import(name, expected):
old_module =
import_module("airflow.providers.snowflake.hooks.snowflake_cortex_agent")
-
with pytest.warns(deprecated_import_warning, match=name):
assert getattr(old_module, name) is expected
+
+
[email protected]
+def managed_agent_request():
+ with (
+ mock.patch(
+ f"{HOOK_PATH}._get_static_conn_params",
+ new_callable=mock.PropertyMock,
+ return_value=STATIC_CONN_PARAMS,
+ ),
+ mock.patch(
+ f"{HOOK_PATH}._get_conn_params",
+ return_value=CONN_PARAMS,
+ ),
+ mock.patch(f"{MODULE_PATH}.requests.request") as mock_request,
+ ):
+ mock_request.return_value = create_response(
+ json_body={"content": [{"type": "text", "text": "Answer"}]},
+ )
+ yield mock_request
+
+
+@needs_common_ai
+class TestSnowflakeCortexAgentManagedAgent:
+ @pytest.mark.parametrize(
+ ("agent", "expected_path"),
+ [
+ pytest.param(
+ "db.schema.agent",
+ "/api/v2/databases/DB/schemas/SCHEMA/agents/AGENT:run",
+ id="unquoted",
+ ),
+ pytest.param(
+ 'db."schema.part"."agent""name"',
+
"/api/v2/databases/DB/schemas/%22schema.part%22/agents/%22agent%22%22name%22:run",
+ id="quoted-dot-and-escaped-quote",
+ ),
+ pytest.param(
+ '"My/Db"."My Schema"."MyAgent"',
+
"/api/v2/databases/%22My%2FDb%22/schemas/%22My%20Schema%22/agents/%22MyAgent%22:run",
+ id="quoted-case-space-and-slash",
+ ),
+ ],
+ )
+ def test_prompt_and_options_reach_the_api(self, managed_agent_request,
agent, expected_path):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ response = hook.agent(agent).invoke(
+ ManagedAgentRequest(
+ prompt="Hello",
+ timeout=1.5,
+ vendor_options={"tool_choice": {"type": "auto"}},
+ )
+ )
+
+ managed_agent_request.assert_called_once_with(
+ method="POST",
+ url=f"https://{ACCOUNT}.snowflakecomputing.com{expected_path}",
+ headers={
+ "Authorization": f"Bearer {ACCESS_TOKEN}",
+ "X-Snowflake-Authorization-Token-Type": "OAUTH",
+ "Content-Type": "application/json",
+ },
+ json={
+ "messages": [
+ {
+ "role": "user",
+ "content": [{"type": "text", "text": "Hello"}],
+ }
+ ],
+ "stream": False,
+ "tool_choice": {"type": "auto"},
+ },
+ params=None,
+ timeout=1.5,
+ )
+ assert response.text == "Answer"
+ assert response.raw == {
+ "content": [{"type": "text", "text": "Answer"}],
+ }
+
+ @pytest.mark.parametrize(
+ ("agent", "expected_name"),
+ [
+ pytest.param(
+ "db.schema.agent",
+ "DB.SCHEMA.AGENT",
+ id="unquoted",
+ ),
+ pytest.param(
+ '"MyDb"."MySchema"."MyAgent"',
+ '"MyDb"."MySchema"."MyAgent"',
+ id="quoted-case",
+ ),
+ pytest.param(
+ 'db."schema.part"."agent""name"',
+ 'DB."schema.part"."agent""name"',
+ id="quoted-dot-and-escaped-quote",
+ ),
+ pytest.param(
+ 'DB."A.B".C',
+ 'DB."A.B".C',
+ id="dot-in-schema",
+ ),
+ pytest.param(
+ 'DB.A."B.C"',
+ 'DB.A."B.C"',
+ id="dot-in-agent",
+ ),
+ pytest.param(
+ 'DB.SCHEMA."agent"',
+ 'DB.SCHEMA."agent"',
+ id="quoted-lowercase-agent",
+ ),
+ ],
+ )
+ def test_resolve_agent(self, managed_agent_request, agent, expected_name):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ result = hook.resolve_agent(agent)
+
+ assert result == ManagedAgentRef(
+ platform="snowflake.cortex",
+ name=expected_name,
+ )
+ assert hook.resolve_agent(result.name) == result
+ managed_agent_request.assert_not_called()
+
+ @pytest.mark.parametrize(
+ "agent",
+ [
+ pytest.param("", id="empty"),
+ pytest.param("AGENT", id="missing-database-and-schema"),
+ pytest.param("DB.AGENT", id="missing-schema"),
+ pytest.param("DB..AGENT", id="empty-schema"),
+ pytest.param("DB.SCHEMA.AGENT.EXTRA", id="too-many-parts"),
+ pytest.param('DB.SCHEMA."agent', id="unclosed-quote"),
+ pytest.param('DB.SCHEMA.""', id="empty-quoted-name"),
+ ],
+ )
+ def test_resolve_agent_rejects_invalid_names(self, managed_agent_request,
agent):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ with pytest.raises(ValueError, match="fully qualified"):
+ hook.resolve_agent(agent)
+
+ managed_agent_request.assert_not_called()
+
+ def test_get_agent_capabilities(self, managed_agent_request):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ capabilities = hook.get_agent_capabilities("DB.SCHEMA.AGENT")
+
+ assert capabilities == ManagedAgentCapabilities(
+ sessions=False,
+ structured_output=False,
+ usage=False,
+ trace=False,
+ )
+ managed_agent_request.assert_not_called()
+
+ @pytest.mark.parametrize(
+ "string_content",
+ [
+ pytest.param(False, id="text-blocks"),
+ pytest.param(True, id="string-content"),
+ ],
+ )
+ def test_message_history_reaches_the_api(self, managed_agent_request,
string_content):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ history = [
+ ("user", "Hello"),
+ ("assistant", "Hi"),
+ ("user", "How are you?"),
+ ]
+ messages = [
+ {
+ "role": role,
+ "content": text if string_content else [{"type": "text",
"text": text}],
+ }
+ for role, text in history
+ ]
+ expected_messages = [
+ {
+ "role": role,
+ "content": [{"type": "text", "text": text}],
+ }
+ for role, text in history
+ ]
+
+ response =
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(messages=messages))
+
+ assert managed_agent_request.call_args.kwargs["json"] == {
+ "messages": expected_messages,
+ "stream": False,
+ }
+ assert response.text == "Answer"
+
+ # Converting string content must not mutate the caller's messages.
+ assert messages == [
+ {
+ "role": role,
+ "content": text if string_content else [{"type": "text",
"text": text}],
+ }
+ for role, text in history
+ ]
+
+ @pytest.mark.parametrize(
+ "bound",
+ [
+ pytest.param(False, id="direct"),
+ pytest.param(True, id="bound"),
+ ],
+ )
+ def test_session_id_is_rejected_before_the_call(self,
managed_agent_request, bound):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ request = ManagedAgentRequest(prompt="Hello", session_id="123")
+
+ invoke = (
+ hook.agent("DB.SCHEMA.AGENT").invoke
+ if bound
+ else lambda request: hook.invoke_agent("DB.SCHEMA.AGENT", request)
+ )
+
+ with pytest.raises(ValueError, match="session_id"):
+ invoke(request)
+
+ managed_agent_request.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("options", "expected_error"),
+ [
+ pytest.param(
+ {"database": "OTHER_DB"},
+ "cannot override contract fields",
+ id="database",
+ ),
+ pytest.param(
+ {"schema": "OTHER_SCHEMA"},
+ "cannot override contract fields",
+ id="schema",
+ ),
+ pytest.param(
+ {"agent_name": "OTHER_AGENT"},
+ "cannot override contract fields",
+ id="agent-name",
+ ),
+ pytest.param(
+ {"snowflake_conn_id": "other_conn"},
+ "cannot override contract fields",
+ id="connection",
+ ),
+ pytest.param(
+ {"messages": []},
+ "cannot override contract fields",
+ id="messages",
+ ),
+ pytest.param(
+ {"timeout": 30},
+ "cannot override contract fields",
+ id="timeout",
+ ),
+ pytest.param(
+ {"thread_id": 123},
+ "cannot override contract fields",
+ id="thread-id",
+ ),
+ pytest.param(
+ {"parent_message_id": 456},
+ "cannot override contract fields",
+ id="parent-message-id",
+ ),
+ pytest.param(
+ {"stream": True},
+ "cannot override contract fields",
+ id="stream",
+ ),
+ pytest.param(
+ {"unknown_option": True},
+ "Unsupported Cortex vendor_options",
+ id="unknown-option",
+ ),
+ ],
+ )
+ def test_vendor_options_are_rejected_before_the_call(
+ self, managed_agent_request, options, expected_error
+ ):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ with pytest.raises(ValueError, match=expected_error):
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello",
vendor_options=options))
+
+ managed_agent_request.assert_not_called()
+
+ @pytest.mark.parametrize(
+ ("status_code", "terminal"),
+ [
+ pytest.param(400, True, id="bad-request"),
+ pytest.param(401, True, id="unauthorized"),
+ pytest.param(403, True, id="forbidden"),
+ pytest.param(404, True, id="not-found"),
+ pytest.param(405, True, id="method-not-allowed"),
+ pytest.param(422, True, id="unprocessable"),
+ pytest.param(408, False, id="request-timeout"),
+ pytest.param(409, False, id="conflict"),
+ pytest.param(429, False, id="throttled"),
+ pytest.param(500, False, id="server-error"),
+ pytest.param(503, False, id="unavailable"),
+ ],
+ )
+ def test_http_errors_are_terminal_or_propagate(self,
managed_agent_request, status_code, terminal):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ http_response = create_response(status_code=status_code)
+ managed_agent_request.return_value = http_response
+ original_error = http_response.raise_for_status.side_effect
+
+ # Resolve the optional exception inside the test, not during
collection.
+ expected_exception = ManagedAgentInvocationError if terminal else
requests.exceptions.HTTPError
+
+ with pytest.raises(expected_exception) as raised:
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello"))
+
+ if terminal:
+ assert raised.value.__cause__ is original_error
+ else:
+ assert raised.value is original_error
+
+ managed_agent_request.assert_called_once()
+
+ @pytest.mark.parametrize(
+ "error_type",
+ [
+ requests.exceptions.ConnectionError,
+ requests.exceptions.ReadTimeout,
+ ],
+ )
+ def test_transport_errors_propagate(self, managed_agent_request,
error_type):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ error = error_type("Request failed")
+ managed_agent_request.side_effect = error
+
+ with pytest.raises(error_type) as raised:
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello"))
+
+ assert raised.value is error
+ managed_agent_request.assert_called_once()
+
+ def test_invalid_json_is_terminal(self, managed_agent_request):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ error = requests.exceptions.JSONDecodeError("Invalid JSON", "{", 1)
+ managed_agent_request.return_value.json.side_effect = error
+
+ with pytest.raises(
+ ManagedAgentInvocationError,
+ match="could not be decoded as JSON",
+ ) as raised:
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello"))
+
+ assert raised.value.__cause__ is error
+
+ @pytest.mark.parametrize(
+ "body",
+ [
+ pytest.param([], id="list"),
+ pytest.param("unexpected", id="string"),
+ ],
+ )
+ def test_invalid_response_shape_is_terminal(self, managed_agent_request,
body):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ managed_agent_request.return_value = create_response(json_body=body)
+
+ with pytest.raises(
+ ManagedAgentInvocationError,
+ match="unexpected response shape",
+ ) as raised:
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello"))
+
+ assert isinstance(raised.value.__cause__, TypeError)
+
+ @pytest.mark.parametrize(
+ "body",
+ [
+ pytest.param({"content": None}, id="null-content"),
+ pytest.param({"content": [1]}, id="invalid-block"),
+ pytest.param(
+ {"content": [{"type": "text", "text": None}]},
+ id="invalid-text",
+ ),
+ ],
+ )
+ def test_invalid_response_content_is_terminal(self, managed_agent_request,
body):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ managed_agent_request.return_value = create_response(json_body=body)
+
+ with pytest.raises(
+ ManagedAgentInvocationError,
+ match="invalid response content",
+ ):
+
hook.agent("DB.SCHEMA.AGENT").invoke(ManagedAgentRequest(prompt="Hello"))
+
+ def test_unrelated_type_error_propagates(self):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+ request = ManagedAgentRequest(prompt="Hello")
+ error = TypeError("Unrelated implementation error")
+
+ with mock.patch.object(hook, "run_agent", autospec=True,
side_effect=error):
+ with pytest.raises(TypeError) as raised:
+ hook.invoke_agent("DB.SCHEMA.AGENT", request)
+
+ assert raised.value is error
+
+
+def test_hook_imports_without_common_ai():
+ script = dedent(
+ """
+ import sys
+ from unittest import mock
+
+ for name in (
+ "airflow.providers.common.ai.exceptions",
+ "airflow.providers.common.ai.managed_agents.base",
+ ):
+ sys.modules[name] = None
+
+ from airflow.providers.snowflake.hooks.cortex_agent import
SnowflakeCortexAgentHook
+ from airflow.providers.common.compat.sdk import
AirflowOptionalProviderFeatureException
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ for method, args in (
+ ("agent", ("DB.SCHEMA.AGENT",)),
+ ("resolve_agent", ("DB.SCHEMA.AGENT",)),
+ ("get_agent_capabilities", ("DB.SCHEMA.AGENT",)),
+ ("invoke_agent", ("DB.SCHEMA.AGENT", None)),
+ ):
+ try:
+ getattr(hook, method)(*args)
+ except AirflowOptionalProviderFeatureException as exc:
+ assert "snowflake[common.ai]" in str(exc), exc
+ else:
+ raise AssertionError(f"{method} did not raise")
+
+ with mock.patch.object(hook, "_request", return_value={}) as request:
+ assert hook.run_agent(
+ database="DB",
+ schema="SCHEMA",
+ agent_name="AGENT",
+ messages=[],
+ ) == {}
+ request.assert_called_once()
+ """
+ )
+ subprocess.run([sys.executable, "-c", script], check=True)
diff --git a/uv.lock b/uv.lock
index fef622b1fe8..7b33c110852 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 5
+revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.15' and sys_platform == 'win32'",