This is an automated email from the ASF dual-hosted git repository.
vincbeck 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 f6acde5e058 Add SnowflakeCortexAgentHook (#68942)
f6acde5e058 is described below
commit f6acde5e058d18bbd0da7aa4fa12f9a681dcd3d0
Author: SameerMesiah97 <[email protected]>
AuthorDate: Mon Jul 6 17:36:52 2026 +0100
Add SnowflakeCortexAgentHook (#68942)
Add SnowflakeCortexAgentHook for executing Cortex Agents via
the Snowflake REST API and extracting text responses.
Includes unit tests covering successful execution, threaded
conversations, optional parameters, and error handling.
---------
Co-authored-by: Sameer Mesiah <[email protected]>
---
providers/snowflake/provider.yaml | 1 +
.../providers/snowflake/get_provider_info.py | 1 +
.../snowflake/hooks/snowflake_cortex_agent.py | 171 +++++++++++
.../snowflake/hooks/test_snowflake_cortex_agent.py | 315 +++++++++++++++++++++
4 files changed, 488 insertions(+)
diff --git a/providers/snowflake/provider.yaml
b/providers/snowflake/provider.yaml
index af2de596afd..bfacfee2492 100644
--- a/providers/snowflake/provider.yaml
+++ b/providers/snowflake/provider.yaml
@@ -152,6 +152,7 @@ hooks:
python-modules:
- airflow.providers.snowflake.hooks.snowflake
- airflow.providers.snowflake.hooks.snowflake_sql_api
+ - airflow.providers.snowflake.hooks.snowflake_cortex_agent
transfers:
- source-integration-name: Amazon Simple Storage Service (S3)
diff --git
a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
index d60e1d9eee0..e5b52603876 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
@@ -77,6 +77,7 @@ def get_provider_info():
"python-modules": [
"airflow.providers.snowflake.hooks.snowflake",
"airflow.providers.snowflake.hooks.snowflake_sql_api",
+ "airflow.providers.snowflake.hooks.snowflake_cortex_agent",
],
}
],
diff --git
a/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py
b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py
new file mode 100644
index 00000000000..7b7f37a2497
--- /dev/null
+++
b/providers/snowflake/src/airflow/providers/snowflake/hooks/snowflake_cortex_agent.py
@@ -0,0 +1,171 @@
+# 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
+
+from typing import Any
+
+import requests
+
+from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
+
+
+class SnowflakeCortexAgentHook(SnowflakeHook):
+ """Hook for interacting with Snowflake Cortex Agents."""
+
+ def _get_base_url(self) -> str:
+ conn_config = self._get_static_conn_params
+
+ host = conn_config.get("host")
+ if host:
+ return f"https://{host}"
+
+ return f"https://{conn_config['account']}.snowflakecomputing.com"
+
+ def _get_access_token(self) -> str:
+ conn_config = self._get_conn_params()
+
+ token = conn_config.get("token")
+ if not token:
+ raise ValueError(
+ "Snowflake connection does not provide an OAuth access token. "
+ "This hook currently requires an OAuth access token."
+ )
+
+ return token
+
+ def _request(
+ self,
+ *,
+ method: str,
+ endpoint: str,
+ payload: dict[str, Any] | None = None,
+ timeout: int | None = None,
+ ) -> dict[str, Any]:
+
+ response = requests.request(
+ method=method,
+ url=f"{self._get_base_url()}{endpoint}",
+ headers={
+ "Authorization": f"Bearer {self._get_access_token()}",
+ "Content-Type": "application/json",
+ },
+ json=payload,
+ timeout=timeout,
+ )
+
+ if response.status_code >= 400:
+ self.log.error(
+ "Snowflake Cortex Agent request failed with status %s: %s",
+ response.status_code,
+ response.text,
+ )
+
+ response.raise_for_status()
+
+ return response.json()
+
+ def run_agent(
+ self,
+ *,
+ database: str,
+ schema: str,
+ agent_name: str,
+ messages: list[dict[str, Any]],
+ thread_id: int | None = None,
+ parent_message_id: int | None = None,
+ tool_choice: dict[str, Any] | None = None,
+ models: dict[str, Any] | None = None,
+ instructions: dict[str, Any] | None = None,
+ orchestration: dict[str, Any] | None = None,
+ tools: list[dict[str, Any]] | None = None,
+ tool_resources: dict[str, Any] | None = None,
+ timeout: int | None = 600,
+ ) -> dict[str, Any]:
+ """
+ Execute a Snowflake Cortex Agent and return the response payload.
+
+ :param database: Database containing the Cortex Agent.
+ :param schema: Schema containing the Cortex Agent.
+ :param agent_name: Name of the Cortex Agent to execute.
+ :param messages: Conversation messages to send to the agent. For a new
+ conversation, this should contain the conversation history and the
+ current user message. When ``thread_id`` and ``parent_message_id``
+ are provided, this should contain only the current user message.
+ :param thread_id: Existing conversation thread identifier. Optional.
+ When provided, ``parent_message_id`` must also be supplied.
+ Defaults to ``None``.
+ :param parent_message_id: Parent message identifier within the
specified
+ thread. Required when ``thread_id`` is provided. Defaults to
``None``.
+ :param tool_choice: Tool selection configuration for the agent.
Optional.
+ Defaults to ``None``.
+ :param models: Model configuration for the agent. Optional. Defaults to
+ ``None``.
+ :param instructions: Agent instruction overrides. Optional. Defaults to
+ ``None``.
+ :param orchestration: Orchestration configuration for the agent.
+ Optional. Defaults to ``None``.
+ :param tools: Additional tools available to the agent. Optional.
+ Defaults to ``None``.
+ :param tool_resources: Configuration for tools specified in ``tools``.
+ Optional. Defaults to ``None``.
+ :param timeout: Maximum time in seconds to wait for the Cortex Agent
request
+ to complete. Defaults to ``600``.
+ :return: JSON response returned by the Cortex Agent.
+ """
+ if thread_id is not None and parent_message_id is None:
+ raise ValueError("parent_message_id must be provided when
thread_id is specified.")
+
+ payload: dict[str, Any] = {"messages": messages}
+
+ if thread_id is not None:
+ payload["thread_id"] = thread_id
+ payload["parent_message_id"] = parent_message_id
+
+ if tool_choice is not None:
+ payload["tool_choice"] = tool_choice
+
+ if models is not None:
+ payload["models"] = models
+
+ if instructions is not None:
+ payload["instructions"] = instructions
+
+ if orchestration is not None:
+ payload["orchestration"] = orchestration
+
+ if tools is not None:
+ payload["tools"] = tools
+
+ if tool_resources is not None:
+ payload["tool_resources"] = tool_resources
+
+ endpoint =
f"/api/v2/databases/{database}/schemas/{schema}/agents/{agent_name}:run"
+
+ return self._request(
+ method="POST",
+ endpoint=endpoint,
+ payload=payload,
+ timeout=timeout,
+ )
+
+ @staticmethod
+ def get_text_response(response: dict[str, Any]) -> str:
+ """Extract text blocks from a Cortex Agent response."""
+ return "".join(
+ block.get("text", "") for block in response.get("content", []) if
block.get("type") == "text"
+ )
diff --git
a/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py
b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py
new file mode 100644
index 00000000000..8ec86d532f9
--- /dev/null
+++
b/providers/snowflake/tests/unit/snowflake/hooks/test_snowflake_cortex_agent.py
@@ -0,0 +1,315 @@
+# 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
+
+from unittest import mock
+
+import pytest
+import requests
+
+from airflow.providers.snowflake.hooks.snowflake_cortex_agent import (
+ SnowflakeCortexAgentHook,
+)
+
+MODULE_PATH = "airflow.providers.snowflake.hooks.snowflake_cortex_agent"
+HOOK_PATH = f"{MODULE_PATH}.SnowflakeCortexAgentHook"
+
+ACCOUNT = "test-account"
+ACCESS_TOKEN = "test-token"
+DATABASE = "TEST_DATABASE"
+SCHEMA = "TEST_SCHEMA"
+AGENT_NAME = "TEST_AGENT"
+
+CONN_PARAMS = {
+ "account": ACCOUNT,
+ "token": ACCESS_TOKEN,
+}
+
+STATIC_CONN_PARAMS = {
+ "account": ACCOUNT,
+}
+
+REQUEST_TIMEOUT = 600
+
+
+def create_response(
+ status_code: int = 200,
+ *,
+ json_body: dict | None = None,
+):
+ response = mock.MagicMock()
+ response.status_code = status_code
+ response.json.return_value = json_body or {}
+
+ if status_code >= 400:
+ response.raise_for_status.side_effect =
requests.exceptions.HTTPError(response=response)
+ else:
+ response.raise_for_status.return_value = None
+
+ return response
+
+
+class TestSnowflakeCortexAgentHook:
+ @mock.patch(f"{MODULE_PATH}.requests.request")
+ @mock.patch(f"{HOOK_PATH}._get_conn_params")
+ @mock.patch(
+ f"{HOOK_PATH}._get_static_conn_params",
+ new_callable=mock.PropertyMock,
+ )
+ def test_run_agent(
+ self,
+ mock_static_conn_params,
+ mock_conn_params,
+ mock_request,
+ ):
+ mock_conn_params.return_value = CONN_PARAMS
+ mock_static_conn_params.return_value = STATIC_CONN_PARAMS
+ mock_request.return_value = create_response(json_body={"status":
"completed"})
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ result = hook.run_agent(
+ database=DATABASE,
+ schema=SCHEMA,
+ agent_name=AGENT_NAME,
+ messages=[
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello",
+ }
+ ],
+ }
+ ],
+ )
+
+ assert result == {"status": "completed"}
+
+ mock_request.assert_called_once_with(
+ method="POST",
+ url=(
+ f"https://{ACCOUNT}.snowflakecomputing.com"
+ f"/api/v2/databases/{DATABASE}"
+ f"/schemas/{SCHEMA}"
+ f"/agents/{AGENT_NAME}:run"
+ ),
+ headers={
+ "Authorization": f"Bearer {ACCESS_TOKEN}",
+ "Content-Type": "application/json",
+ },
+ json={
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello",
+ }
+ ],
+ }
+ ],
+ },
+ timeout=REQUEST_TIMEOUT,
+ )
+
+ def
test_run_agent_requires_parent_message_id_when_thread_id_provided(self):
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ with pytest.raises(
+ ValueError,
+ match="parent_message_id must be provided",
+ ):
+ hook.run_agent(
+ database=DATABASE,
+ schema=SCHEMA,
+ agent_name=AGENT_NAME,
+ messages=[],
+ thread_id=123,
+ )
+
+ @mock.patch(f"{MODULE_PATH}.requests.request")
+ @mock.patch(f"{HOOK_PATH}._get_conn_params")
+ @mock.patch(
+ f"{HOOK_PATH}._get_static_conn_params",
+ new_callable=mock.PropertyMock,
+ )
+ def test_run_agent_includes_thread_fields(
+ self,
+ mock_static_conn_params,
+ mock_conn_params,
+ mock_request,
+ ):
+ mock_conn_params.return_value = CONN_PARAMS
+ mock_static_conn_params.return_value = STATIC_CONN_PARAMS
+ mock_request.return_value = create_response()
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ hook.run_agent(
+ database=DATABASE,
+ schema=SCHEMA,
+ agent_name=AGENT_NAME,
+ messages=[],
+ thread_id=123,
+ parent_message_id=456,
+ )
+
+ payload = mock_request.call_args.kwargs["json"]
+
+ assert payload["thread_id"] == 123
+ assert payload["parent_message_id"] == 456
+
+ @mock.patch(f"{MODULE_PATH}.requests.request")
+ @mock.patch(f"{HOOK_PATH}._get_conn_params")
+ @mock.patch(
+ f"{HOOK_PATH}._get_static_conn_params",
+ new_callable=mock.PropertyMock,
+ )
+ def test_run_agent_includes_optional_fields(
+ self,
+ mock_static_conn_params,
+ mock_conn_params,
+ mock_request,
+ ):
+ mock_conn_params.return_value = CONN_PARAMS
+ mock_static_conn_params.return_value = STATIC_CONN_PARAMS
+ mock_request.return_value = create_response()
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ hook.run_agent(
+ database=DATABASE,
+ schema=SCHEMA,
+ agent_name=AGENT_NAME,
+ messages=[],
+ tool_choice={"type": "auto"},
+ models={"orchestration": "claude-4-sonnet"},
+ instructions={"response": "be concise"},
+ orchestration={"max_tokens": 1000},
+ tools=[{"name": "search_tool"}],
+ tool_resources={"search_tool": {"config": "value"}},
+ )
+
+ payload = mock_request.call_args.kwargs["json"]
+
+ assert payload["tool_choice"] == {"type": "auto"}
+ assert payload["models"] == {"orchestration": "claude-4-sonnet"}
+ assert payload["instructions"] == {"response": "be concise"}
+ assert payload["orchestration"] == {"max_tokens": 1000}
+ assert payload["tools"] == [{"name": "search_tool"}]
+ assert payload["tool_resources"] == {"search_tool": {"config":
"value"}}
+
+ @mock.patch(f"{MODULE_PATH}.requests.request")
+ @mock.patch(f"{HOOK_PATH}._get_conn_params")
+ @mock.patch(
+ f"{HOOK_PATH}._get_static_conn_params",
+ new_callable=mock.PropertyMock,
+ )
+ def test_run_agent_http_error(
+ self,
+ mock_static_conn_params,
+ mock_conn_params,
+ mock_request,
+ ):
+ mock_conn_params.return_value = CONN_PARAMS
+ mock_static_conn_params.return_value = STATIC_CONN_PARAMS
+ mock_request.return_value = create_response(
+ status_code=400,
+ json_body={"error": "boom"},
+ )
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ with pytest.raises(requests.exceptions.HTTPError):
+ hook.run_agent(
+ database=DATABASE,
+ schema=SCHEMA,
+ agent_name=AGENT_NAME,
+ messages=[],
+ )
+
+ @mock.patch(f"{HOOK_PATH}._get_conn_params")
+ def test_get_access_token_raises_when_token_missing(
+ self,
+ mock_conn_params,
+ ):
+ mock_conn_params.return_value = {}
+
+ hook = SnowflakeCortexAgentHook(snowflake_conn_id="mock_conn_id")
+
+ with pytest.raises(
+ ValueError,
+ match="access token",
+ ):
+ hook._get_access_token()
+
+ @pytest.mark.parametrize(
+ ("response", "expected"),
+ [
+ (
+ {
+ "content": [
+ {
+ "type": "text",
+ "text": "Hello ",
+ },
+ {
+ "type": "thinking",
+ "thinking": {
+ "text": "internal reasoning",
+ },
+ },
+ {
+ "type": "text",
+ "text": "world",
+ },
+ ]
+ },
+ "Hello world",
+ ),
+ (
+ {},
+ "",
+ ),
+ (
+ {
+ "content": [
+ {
+ "type": "thinking",
+ "thinking": {
+ "text": "internal reasoning",
+ },
+ },
+ {
+ "type": "tool_use",
+ "tool": "search_tool",
+ },
+ ]
+ },
+ "",
+ ),
+ ],
+ )
+ def test_get_text_response(
+ self,
+ response,
+ expected,
+ ):
+ assert SnowflakeCortexAgentHook.get_text_response(response) == expected