kaxil commented on code in PR #74386:
URL: https://github.com/apache/airflow/pull/74386#discussion_r4237814801


##########
providers/databricks/src/airflow/providers/databricks/hooks/agent.py:
##########
@@ -0,0 +1,372 @@
+# 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.
+"""Invoke agents served by Databricks DurableAgentServer."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+from uuid import UUID, uuid4
+
+import aiohttp
+import requests
+from tenacity import RetryError, stop_before_delay
+
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.databricks_base import 
BaseDatabricksHook
+
+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 as e:
+        _common_ai_import_error = e
+
+        def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+            raise AirflowOptionalProviderFeatureException(
+                "Consulting a Databricks agent needs the 'common.ai' extra: "
+                "pip install 'apache-airflow-providers-databricks[common.ai]'."
+            ) from _common_ai_import_error
+
+        class BaseManagedAgentHook:
+            """Keep background invocation available without the Common AI 
extra."""
+
+            agent = _needs_common_ai
+
+        ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse = 
_needs_common_ai
+        ManagedAgentInvocationError = _needs_common_ai
+
+
+_AGENT_OPTIONS = frozenset({"input_key", "invocation_id"})
+
+
+class DatabricksAgentHook(BaseDatabricksHook, BaseManagedAgentHook):
+    """
+    Invoke a DurableAgentServer deployed on Databricks Apps.
+
+    With the ``common.ai`` extra installed, ``hook.agent(app_url)`` exposes 
the synchronous
+    invocation API through the managed-agent contract. Background invocations 
remain available
+    through ``create_invocation`` and ``get_invocation`` without that extra.
+
+    :param app_url: HTTPS base URL for background invocation methods. Defaults 
to ``None``;
+        managed-agent calls receive the app URL as their ``agent`` argument.
+    :param databricks_conn_id: Databricks workspace connection configured with
+        ``service_principal_oauth=true``, a client ID in login and a client 
secret in password.
+        Defaults to ``databricks_default``.
+    :param timeout_seconds: HTTP request timeout in seconds. Defaults to 
``180``.
+    :param retry_limit: Maximum number of attempts for transient HTTP 
failures. Defaults to ``3``.
+    :param retry_delay: Minimum delay between HTTP retries in seconds. 
Defaults to ``1``.
+    """
+
+    def __init__(
+        self,
+        app_url: str | None = None,
+        databricks_conn_id: str = "databricks_default",
+        timeout_seconds: int = 180,
+        retry_limit: int = 3,
+        retry_delay: float = 1,
+    ) -> None:
+        super().__init__(databricks_conn_id, timeout_seconds, retry_limit, 
retry_delay)
+        self.app_url = self._validate_app_url(app_url) if app_url is not None 
else None
+
+    agent_platform = "databricks.agent_runtime"
+
+    @staticmethod
+    def _validate_app_url(app_url: str | None) -> str:
+        if app_url is None:
+            raise ValueError("app_url is required for background invocation 
methods")
+        parsed = urlsplit(app_url)
+        if (
+            parsed.scheme != "https"
+            or not parsed.hostname
+            or parsed.username
+            or parsed.password
+            or parsed.path not in ("", "/")
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("app_url must be an HTTPS base URL without 
credentials, path, query or fragment")
+        return app_url.rstrip("/")
+
+    def resolve_agent(self, agent: str) -> ManagedAgentRef:
+        """
+        Resolve an app's HTTPS base URL without contacting Databricks.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Platform-qualified app URL with its trailing slash removed.
+        """
+        return ManagedAgentRef(platform=self.agent_platform, 
name=self._validate_app_url(agent))
+
+    def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+        """
+        Report session, structured-output and invocation-ID support.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Capabilities of the synchronous invocation API.
+        """
+        self.resolve_agent(agent)
+        return ManagedAgentCapabilities(sessions=True, structured_output=True, 
trace=True)
+
+    def invoke_agent(self, agent: str, request: ManagedAgentRequest) -> 
ManagedAgentResponse:
+        """
+        Invoke an agent synchronously through the Common AI contract.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :param request: Prompt or messages, optional session and request 
timeout. Vendor options
+            accept ``input_key`` to send a prompt under an agent-specific key 
and ``invocation_id``
+            to reuse a caller-provided UUID. By default, prompts are converted 
to messages and
+            a new UUID is generated for each call.
+        :return: Answer text, full invocation response, structured output, 
session and invocation ID.
+        """
+        app_url = self.resolve_agent(agent).name
+        unknown = set(request.vendor_options) - _AGENT_OPTIONS
+        if unknown:
+            raise ValueError(
+                f"vendor_options {sorted(unknown)} are not accepted; expected 
{sorted(_AGENT_OPTIONS)}"
+            )
+        input_key = request.vendor_options.get("input_key")
+        if input_key is not None and (not isinstance(input_key, str) or not 
input_key):
+            raise ValueError("vendor_options['input_key'] must be a nonempty 
string")
+        if input_key is not None and request.messages is not None:
+            raise ValueError("vendor_options['input_key'] only applies to a 
prompt")
+        invocation_id = (
+            str(UUID(request.vendor_options["invocation_id"]))
+            if "invocation_id" in request.vendor_options
+            else str(uuid4())
+        )
+        payload: dict[str, Any] = {
+            "id": invocation_id,
+            "input": {input_key: request.prompt}
+            if input_key is not None
+            else {"messages": request.as_messages()},
+        }
+        if request.session_id is not None:
+            payload["session_id"] = request.session_id
+        try:
+            raw = self._do_agent_api_call(
+                "POST",
+                "api/invocations",
+                payload,
+                request.session_id,
+                timeout_seconds=request.timeout,
+                app_url=app_url,
+            )
+        except DatabricksAgentInvocationTimeout:
+            raise
+        except AirflowException as exc:
+            cause = exc.__cause__ or exc.__context__
+            if isinstance(cause, RetryError):
+                transient = cause.last_attempt.exception()
+                if transient is not None:
+                    raise transient from None
+            if isinstance(cause, requests.HTTPError) and (
+                cause.response.status_code == 429 or 
cause.response.status_code >= 500
+            ):
+                raise cause from None
+            raise ManagedAgentInvocationError(
+                "Databricks managed-agent request failed; inspect the app logs"
+            ) from exc
+        if not isinstance(raw, dict) or raw.get("status") != "completed":

Review Comment:
   The server's top-level status is only ever queued/active/completed/failed 
(`InvocationStatus` in `runtime/types.py`). The CLI templates report a pause 
inside the output, `{"status": "completed", "output": {"status": "interrupted", 
...}}` (`agent-openai/runtime/adapter.py:117-120`, same in the langgraph 
adapter). So a paused agent passes this check and comes back as a normal 
answer, and `ManagedAgentToolset` hands the interrupt payload to the calling 
model. agent.rst:125-127 says interrupted invocations raise 
`ManagedAgentInvocationError`, and the test at test_agent.py:252 mocks an outer 
`"interrupted"` the server never sends.
   
   The same thing makes the `interrupted` branches in the operator and trigger 
dead code. The operator still returns the response, so its behaviour is fine, 
but the docs should point users at `output.status`.



##########
providers/databricks/src/airflow/providers/databricks/operators/agent.py:
##########
@@ -0,0 +1,190 @@
+# 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.
+"""Operators for invoking Databricks agents."""
+
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import Sequence
+from datetime import timedelta
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+from uuid import NAMESPACE_URL, UUID, uuid5
+
+from airflow.providers.common.compat.sdk import BaseOperator, conf
+from airflow.providers.databricks.exceptions import (
+    DatabricksAgentInvocationError,
+    DatabricksAgentInvocationTimeout,
+)
+from airflow.providers.databricks.hooks.agent import DatabricksAgentHook
+from airflow.providers.databricks.triggers.agent import 
DatabricksAgentInvocationTrigger
+
+if TYPE_CHECKING:
+    from airflow.providers.common.compat.sdk import Context
+
+
+class DatabricksAgentInvokeOperator(BaseOperator):
+    """
+    Invoke a DurableAgentServer on Databricks Apps and optionally wait for its 
result.
+
+    :param app_url: HTTPS base URL of the deployed app. (templated)
+    :param input: JSON-serializable agent input. (templated)
+    :param databricks_conn_id: Databricks connection using service principal 
OAuth.
+        Defaults to ``databricks_default``. (templated)
+    :param session_id: Conversation ID. CLI template agents require this field.
+        Defaults to ``None``. (templated)
+    :param invocation_id: Idempotency UUID. Defaults to a stable UUID for this 
task instance,
+        shared across retries and clears within the same Dag run. (templated)
+    :param wait_for_termination: Wait for completion or interruption. If 
false, return the submission response.
+        Defaults to ``True``.
+    :param polling_period_seconds: Seconds between status checks. Defaults to 
``10``.
+    :param timeout: Maximum seconds to wait after submission. Timing out does 
not cancel the remote invocation.
+        Defaults to ``3600``.
+    :param deferrable: Release the worker while waiting. Defaults to 
``[operators] default_deferrable``,
+        with a fallback of ``False``.
+    """
+
+    template_fields: Sequence[str] = ("app_url", "input", 
"databricks_conn_id", "session_id", "invocation_id")
+    template_fields_renderers = {"input": "json"}
+    ui_color = "#1CB1C2"
+    ui_fgcolor = "#fff"
+
+    def __init__(
+        self,
+        *,
+        app_url: str,
+        input: Any,
+        databricks_conn_id: str = "databricks_default",
+        session_id: str | None = None,
+        invocation_id: str | None = None,
+        wait_for_termination: bool = True,
+        polling_period_seconds: float = 10,
+        timeout: float = 3600,
+        deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+        if polling_period_seconds <= 0 or timeout <= 0:
+            raise ValueError("polling_period_seconds and timeout must be 
positive")
+        self.app_url = app_url
+        self.input = input
+        self.databricks_conn_id = databricks_conn_id
+        self.session_id = session_id
+        self.invocation_id = invocation_id
+        self.wait_for_termination = wait_for_termination
+        self.polling_period_seconds = polling_period_seconds
+        self.timeout = timeout
+        self.deferrable = deferrable
+
+    @cached_property
+    def hook(self) -> DatabricksAgentHook:
+        return DatabricksAgentHook(self.app_url, self.databricks_conn_id)
+
+    def _get_invocation_id(self, context: Context) -> str:
+        if self.invocation_id is not None:
+            return str(UUID(self.invocation_id))
+        ti = context["ti"]
+        identity = json.dumps([self.app_url, ti.dag_id, ti.task_id, ti.run_id, 
ti.map_index])

Review Comment:
   With this default every retry and every clear re-POSTs the same UUID, and 
`InMemoryRuntimeStore.accept` returns the stored record without re-running the 
handler (`ensure_scheduled` in `runtime/execution.py` only schedules queued 
records). So once the agent fails, `retries` can't recover it: each try gets 
`{"status": "failed"}` back and fails within seconds, and a clear does the 
same. If the clear changes the rendered `input` or `session_id`, every try gets 
a 409 for the rest of that Dag run.
   
   Could a `failed` record either raise `AirflowFailException` saying a new 
`invocation_id` is needed, or start a fresh per-try ID after a failure? Folding 
`input` and `session_id` into the hash would turn the changed-input clear into 
a new invocation instead of a permanent 409. Either way "Retries and results" 
in agent.rst should spell out the consequence.
   
   Also, this hashes the raw `self.app_url`, but the hook strips a trailing 
slash, so `https://app` and `https://app/` give different UUIDs for the same 
endpoint.



##########
providers/databricks/src/airflow/providers/databricks/hooks/agent.py:
##########
@@ -0,0 +1,372 @@
+# 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.
+"""Invoke agents served by Databricks DurableAgentServer."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+from uuid import UUID, uuid4
+
+import aiohttp
+import requests
+from tenacity import RetryError, stop_before_delay
+
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.databricks_base import 
BaseDatabricksHook
+
+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 as e:
+        _common_ai_import_error = e
+
+        def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+            raise AirflowOptionalProviderFeatureException(
+                "Consulting a Databricks agent needs the 'common.ai' extra: "
+                "pip install 'apache-airflow-providers-databricks[common.ai]'."
+            ) from _common_ai_import_error
+
+        class BaseManagedAgentHook:
+            """Keep background invocation available without the Common AI 
extra."""
+
+            agent = _needs_common_ai
+
+        ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse = 
_needs_common_ai
+        ManagedAgentInvocationError = _needs_common_ai
+
+
+_AGENT_OPTIONS = frozenset({"input_key", "invocation_id"})
+
+
+class DatabricksAgentHook(BaseDatabricksHook, BaseManagedAgentHook):
+    """
+    Invoke a DurableAgentServer deployed on Databricks Apps.
+
+    With the ``common.ai`` extra installed, ``hook.agent(app_url)`` exposes 
the synchronous
+    invocation API through the managed-agent contract. Background invocations 
remain available
+    through ``create_invocation`` and ``get_invocation`` without that extra.
+
+    :param app_url: HTTPS base URL for background invocation methods. Defaults 
to ``None``;
+        managed-agent calls receive the app URL as their ``agent`` argument.
+    :param databricks_conn_id: Databricks workspace connection configured with
+        ``service_principal_oauth=true``, a client ID in login and a client 
secret in password.
+        Defaults to ``databricks_default``.
+    :param timeout_seconds: HTTP request timeout in seconds. Defaults to 
``180``.
+    :param retry_limit: Maximum number of attempts for transient HTTP 
failures. Defaults to ``3``.
+    :param retry_delay: Minimum delay between HTTP retries in seconds. 
Defaults to ``1``.
+    """
+
+    def __init__(
+        self,
+        app_url: str | None = None,
+        databricks_conn_id: str = "databricks_default",
+        timeout_seconds: int = 180,
+        retry_limit: int = 3,
+        retry_delay: float = 1,
+    ) -> None:
+        super().__init__(databricks_conn_id, timeout_seconds, retry_limit, 
retry_delay)
+        self.app_url = self._validate_app_url(app_url) if app_url is not None 
else None
+
+    agent_platform = "databricks.agent_runtime"
+
+    @staticmethod
+    def _validate_app_url(app_url: str | None) -> str:
+        if app_url is None:
+            raise ValueError("app_url is required for background invocation 
methods")
+        parsed = urlsplit(app_url)
+        if (
+            parsed.scheme != "https"
+            or not parsed.hostname
+            or parsed.username
+            or parsed.password
+            or parsed.path not in ("", "/")
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("app_url must be an HTTPS base URL without 
credentials, path, query or fragment")
+        return app_url.rstrip("/")
+
+    def resolve_agent(self, agent: str) -> ManagedAgentRef:
+        """
+        Resolve an app's HTTPS base URL without contacting Databricks.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Platform-qualified app URL with its trailing slash removed.
+        """
+        return ManagedAgentRef(platform=self.agent_platform, 
name=self._validate_app_url(agent))
+
+    def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+        """
+        Report session, structured-output and invocation-ID support.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Capabilities of the synchronous invocation API.
+        """
+        self.resolve_agent(agent)
+        return ManagedAgentCapabilities(sessions=True, structured_output=True, 
trace=True)
+
+    def invoke_agent(self, agent: str, request: ManagedAgentRequest) -> 
ManagedAgentResponse:
+        """
+        Invoke an agent synchronously through the Common AI contract.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :param request: Prompt or messages, optional session and request 
timeout. Vendor options
+            accept ``input_key`` to send a prompt under an agent-specific key 
and ``invocation_id``
+            to reuse a caller-provided UUID. By default, prompts are converted 
to messages and
+            a new UUID is generated for each call.
+        :return: Answer text, full invocation response, structured output, 
session and invocation ID.
+        """
+        app_url = self.resolve_agent(agent).name
+        unknown = set(request.vendor_options) - _AGENT_OPTIONS
+        if unknown:
+            raise ValueError(
+                f"vendor_options {sorted(unknown)} are not accepted; expected 
{sorted(_AGENT_OPTIONS)}"
+            )
+        input_key = request.vendor_options.get("input_key")
+        if input_key is not None and (not isinstance(input_key, str) or not 
input_key):
+            raise ValueError("vendor_options['input_key'] must be a nonempty 
string")
+        if input_key is not None and request.messages is not None:
+            raise ValueError("vendor_options['input_key'] only applies to a 
prompt")
+        invocation_id = (
+            str(UUID(request.vendor_options["invocation_id"]))
+            if "invocation_id" in request.vendor_options
+            else str(uuid4())
+        )
+        payload: dict[str, Any] = {
+            "id": invocation_id,
+            "input": {input_key: request.prompt}
+            if input_key is not None
+            else {"messages": request.as_messages()},
+        }
+        if request.session_id is not None:

Review Comment:
   `ManagedAgentToolset.invoke_sync` builds `ManagedAgentRequest(prompt=..., 
timeout=..., vendor_options=...)` with no `session_id` 
(toolsets/managed_agent.py:332), and both CLI template adapters raise 
`session_id must be provided as a top-level invocation field` without one. So 
the toolset path the docs recommend can't drive CLI-template agents, and the 
user sees a retried 500 (see the comment on L196). Could the hook default to a 
one-shot session, e.g. the invocation UUID, when the request has none? A test 
that goes through `ManagedAgentToolset.invoke_sync` would have caught this.



##########
providers/databricks/src/airflow/providers/databricks/hooks/agent.py:
##########
@@ -0,0 +1,372 @@
+# 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.
+"""Invoke agents served by Databricks DurableAgentServer."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+from uuid import UUID, uuid4
+
+import aiohttp
+import requests
+from tenacity import RetryError, stop_before_delay
+
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.databricks_base import 
BaseDatabricksHook
+
+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 as e:
+        _common_ai_import_error = e
+
+        def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+            raise AirflowOptionalProviderFeatureException(
+                "Consulting a Databricks agent needs the 'common.ai' extra: "
+                "pip install 'apache-airflow-providers-databricks[common.ai]'."
+            ) from _common_ai_import_error
+
+        class BaseManagedAgentHook:
+            """Keep background invocation available without the Common AI 
extra."""
+
+            agent = _needs_common_ai
+
+        ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse = 
_needs_common_ai
+        ManagedAgentInvocationError = _needs_common_ai
+
+
+_AGENT_OPTIONS = frozenset({"input_key", "invocation_id"})
+
+
+class DatabricksAgentHook(BaseDatabricksHook, BaseManagedAgentHook):
+    """
+    Invoke a DurableAgentServer deployed on Databricks Apps.
+
+    With the ``common.ai`` extra installed, ``hook.agent(app_url)`` exposes 
the synchronous
+    invocation API through the managed-agent contract. Background invocations 
remain available
+    through ``create_invocation`` and ``get_invocation`` without that extra.
+
+    :param app_url: HTTPS base URL for background invocation methods. Defaults 
to ``None``;
+        managed-agent calls receive the app URL as their ``agent`` argument.
+    :param databricks_conn_id: Databricks workspace connection configured with
+        ``service_principal_oauth=true``, a client ID in login and a client 
secret in password.
+        Defaults to ``databricks_default``.
+    :param timeout_seconds: HTTP request timeout in seconds. Defaults to 
``180``.
+    :param retry_limit: Maximum number of attempts for transient HTTP 
failures. Defaults to ``3``.
+    :param retry_delay: Minimum delay between HTTP retries in seconds. 
Defaults to ``1``.
+    """
+
+    def __init__(
+        self,
+        app_url: str | None = None,
+        databricks_conn_id: str = "databricks_default",
+        timeout_seconds: int = 180,
+        retry_limit: int = 3,
+        retry_delay: float = 1,
+    ) -> None:
+        super().__init__(databricks_conn_id, timeout_seconds, retry_limit, 
retry_delay)
+        self.app_url = self._validate_app_url(app_url) if app_url is not None 
else None
+
+    agent_platform = "databricks.agent_runtime"
+
+    @staticmethod
+    def _validate_app_url(app_url: str | None) -> str:
+        if app_url is None:
+            raise ValueError("app_url is required for background invocation 
methods")
+        parsed = urlsplit(app_url)
+        if (
+            parsed.scheme != "https"
+            or not parsed.hostname
+            or parsed.username
+            or parsed.password
+            or parsed.path not in ("", "/")
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("app_url must be an HTTPS base URL without 
credentials, path, query or fragment")
+        return app_url.rstrip("/")
+
+    def resolve_agent(self, agent: str) -> ManagedAgentRef:
+        """
+        Resolve an app's HTTPS base URL without contacting Databricks.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Platform-qualified app URL with its trailing slash removed.
+        """
+        return ManagedAgentRef(platform=self.agent_platform, 
name=self._validate_app_url(agent))
+
+    def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+        """
+        Report session, structured-output and invocation-ID support.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Capabilities of the synchronous invocation API.
+        """
+        self.resolve_agent(agent)
+        return ManagedAgentCapabilities(sessions=True, structured_output=True, 
trace=True)
+
+    def invoke_agent(self, agent: str, request: ManagedAgentRequest) -> 
ManagedAgentResponse:
+        """
+        Invoke an agent synchronously through the Common AI contract.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :param request: Prompt or messages, optional session and request 
timeout. Vendor options
+            accept ``input_key`` to send a prompt under an agent-specific key 
and ``invocation_id``
+            to reuse a caller-provided UUID. By default, prompts are converted 
to messages and
+            a new UUID is generated for each call.
+        :return: Answer text, full invocation response, structured output, 
session and invocation ID.
+        """
+        app_url = self.resolve_agent(agent).name
+        unknown = set(request.vendor_options) - _AGENT_OPTIONS
+        if unknown:
+            raise ValueError(
+                f"vendor_options {sorted(unknown)} are not accepted; expected 
{sorted(_AGENT_OPTIONS)}"
+            )
+        input_key = request.vendor_options.get("input_key")
+        if input_key is not None and (not isinstance(input_key, str) or not 
input_key):
+            raise ValueError("vendor_options['input_key'] must be a nonempty 
string")
+        if input_key is not None and request.messages is not None:
+            raise ValueError("vendor_options['input_key'] only applies to a 
prompt")
+        invocation_id = (
+            str(UUID(request.vendor_options["invocation_id"]))
+            if "invocation_id" in request.vendor_options
+            else str(uuid4())
+        )
+        payload: dict[str, Any] = {
+            "id": invocation_id,
+            "input": {input_key: request.prompt}
+            if input_key is not None
+            else {"messages": request.as_messages()},
+        }
+        if request.session_id is not None:
+            payload["session_id"] = request.session_id
+        try:
+            raw = self._do_agent_api_call(
+                "POST",
+                "api/invocations",
+                payload,
+                request.session_id,
+                timeout_seconds=request.timeout,
+                app_url=app_url,
+            )
+        except DatabricksAgentInvocationTimeout:
+            raise
+        except AirflowException as exc:
+            cause = exc.__cause__ or exc.__context__
+            if isinstance(cause, RetryError):
+                transient = cause.last_attempt.exception()
+                if transient is not None:
+                    raise transient from None
+            if isinstance(cause, requests.HTTPError) and (
+                cause.response.status_code == 429 or 
cause.response.status_code >= 500
+            ):
+                raise cause from None
+            raise ManagedAgentInvocationError(
+                "Databricks managed-agent request failed; inspect the app logs"
+            ) from exc
+        if not isinstance(raw, dict) or raw.get("status") != "completed":
+            raise ManagedAgentInvocationError(
+                f"Databricks agent invocation {invocation_id} did not 
complete; inspect the app logs"
+            )
+        output = raw.get("output")
+        text = output if isinstance(output, str) else "" if output is None 
else json.dumps(output)
+        if isinstance(output, dict) and isinstance(output.get("output"), str):
+            text = output["output"]
+        return ManagedAgentResponse(
+            text=text,
+            raw=raw,
+            structured=None if isinstance(output, str) else output,
+            session_id=raw.get("session_id", request.session_id),
+            trace_ref=raw.get("id", invocation_id),
+        )
+
+    def _validate_oauth(self) -> None:
+        conn = self.databricks_conn
+        if not conn.extra_dejson.get("service_principal_oauth") or not 
conn.login or not conn.password:
+            raise ValueError("Databricks Apps requires service_principal_oauth 
with a client ID and secret")
+
+    def _get_headers(self, token: str, session_id: str | None) -> dict[str, 
str]:
+        headers = {**self.user_agent_header, "Authorization": f"Bearer 
{token}"}
+        if session_id is not None:
+            headers["X-Routing-Key"] = session_id
+        return headers
+
+    def create_invocation(
+        self,
+        invocation_id: str,
+        input: Any,
+        session_id: str | None = None,
+    ) -> dict[str, Any]:
+        """
+        Start a background invocation; retrying the same UUID and input is 
idempotent.
+
+        :param invocation_id: UUID identifying the invocation.
+        :param input: JSON-serializable input whose schema is defined by the 
agent.
+        :param session_id: Conversation ID, also sent as the routing key. 
Defaults to ``None``.
+        :return: Complete JSON submission response returned by the agent 
server.
+        """
+        invocation_id = str(UUID(invocation_id))
+        payload = {"id": invocation_id, "input": input, "background": True}
+        if session_id is not None:
+            payload["session_id"] = session_id
+        return self._do_agent_api_call("POST", "api/invocations", payload, 
session_id)
+
+    def get_invocation(
+        self, invocation_id: str, session_id: str | None = None, *, 
timeout_seconds: float | None = None
+    ) -> dict[str, Any]:
+        """
+        Get the status and output of an invocation.
+
+        :param invocation_id: UUID identifying the invocation.
+        :param session_id: Conversation ID sent as the routing key. Defaults 
to ``None``.
+        :param timeout_seconds: Optional polling request/retry budget in 
seconds. Defaults to ``None``,
+            which uses the hook's HTTP timeout and retry settings. OAuth 
refresh uses the base hook's
+            token timeout; if it exhausts this budget, no invocation request 
is sent.
+        :return: Complete JSON invocation response, including output when 
available.
+        """
+        invocation_id = str(UUID(invocation_id))
+        return self._do_agent_api_call(
+            "GET", f"api/invocations/{invocation_id}", None, session_id, 
timeout_seconds=timeout_seconds
+        )
+
+    def _do_agent_api_call(
+        self,
+        method: str,
+        endpoint: str,
+        payload: dict[str, Any] | None,
+        session_id: str | None,
+        *,
+        timeout_seconds: float | None = None,
+        app_url: str | None = None,
+    ) -> dict[str, Any]:
+        app_url = self._validate_app_url(app_url if app_url is not None else 
self.app_url)
+        self._validate_oauth()
+        if timeout_seconds is not None and timeout_seconds <= 0:
+            raise ValueError("timeout_seconds must be positive")
+        deadline = time.monotonic() + timeout_seconds if timeout_seconds is 
not None else None
+        retry = self._get_retry_object()
+        if timeout_seconds is not None:
+            original_stop = retry.stop
+            budget_stop = stop_before_delay(timeout_seconds)
+            retry.stop = lambda state: original_stop(state) or 
budget_stop(state)

Review Comment:
   `stop_before_delay` was added in tenacity 8.3.0, and this provider still 
allows Airflow 2.11, which accepts `tenacity>=8.0`. With 8.2.3 the import at 
L29 fails, which also breaks importing the operator. Constrained installs pin a 
newer tenacity, so most users won't hit it, but google declares 
`tenacity>=8.3.0` for the same reason. Separately, `retry = 
retry.copy(stop=retry.stop | stop_before_delay(timeout_seconds))` would do this 
without patching `stop` with a lambda.



##########
providers/databricks/src/airflow/providers/databricks/operators/agent.py:
##########
@@ -0,0 +1,190 @@
+# 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.
+"""Operators for invoking Databricks agents."""
+
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import Sequence
+from datetime import timedelta
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+from uuid import NAMESPACE_URL, UUID, uuid5
+
+from airflow.providers.common.compat.sdk import BaseOperator, conf
+from airflow.providers.databricks.exceptions import (
+    DatabricksAgentInvocationError,
+    DatabricksAgentInvocationTimeout,
+)
+from airflow.providers.databricks.hooks.agent import DatabricksAgentHook
+from airflow.providers.databricks.triggers.agent import 
DatabricksAgentInvocationTrigger
+
+if TYPE_CHECKING:
+    from airflow.providers.common.compat.sdk import Context
+
+
+class DatabricksAgentInvokeOperator(BaseOperator):
+    """
+    Invoke a DurableAgentServer on Databricks Apps and optionally wait for its 
result.
+
+    :param app_url: HTTPS base URL of the deployed app. (templated)
+    :param input: JSON-serializable agent input. (templated)
+    :param databricks_conn_id: Databricks connection using service principal 
OAuth.
+        Defaults to ``databricks_default``. (templated)
+    :param session_id: Conversation ID. CLI template agents require this field.
+        Defaults to ``None``. (templated)
+    :param invocation_id: Idempotency UUID. Defaults to a stable UUID for this 
task instance,
+        shared across retries and clears within the same Dag run. (templated)
+    :param wait_for_termination: Wait for completion or interruption. If 
false, return the submission response.
+        Defaults to ``True``.
+    :param polling_period_seconds: Seconds between status checks. Defaults to 
``10``.
+    :param timeout: Maximum seconds to wait after submission. Timing out does 
not cancel the remote invocation.
+        Defaults to ``3600``.
+    :param deferrable: Release the worker while waiting. Defaults to 
``[operators] default_deferrable``,
+        with a fallback of ``False``.
+    """
+
+    template_fields: Sequence[str] = ("app_url", "input", 
"databricks_conn_id", "session_id", "invocation_id")
+    template_fields_renderers = {"input": "json"}
+    ui_color = "#1CB1C2"
+    ui_fgcolor = "#fff"
+
+    def __init__(
+        self,
+        *,
+        app_url: str,
+        input: Any,
+        databricks_conn_id: str = "databricks_default",
+        session_id: str | None = None,
+        invocation_id: str | None = None,
+        wait_for_termination: bool = True,
+        polling_period_seconds: float = 10,
+        timeout: float = 3600,
+        deferrable: bool = conf.getboolean("operators", "default_deferrable", 
fallback=False),
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+        if polling_period_seconds <= 0 or timeout <= 0:
+            raise ValueError("polling_period_seconds and timeout must be 
positive")
+        self.app_url = app_url
+        self.input = input
+        self.databricks_conn_id = databricks_conn_id
+        self.session_id = session_id
+        self.invocation_id = invocation_id
+        self.wait_for_termination = wait_for_termination
+        self.polling_period_seconds = polling_period_seconds
+        self.timeout = timeout
+        self.deferrable = deferrable
+
+    @cached_property
+    def hook(self) -> DatabricksAgentHook:
+        return DatabricksAgentHook(self.app_url, self.databricks_conn_id)
+
+    def _get_invocation_id(self, context: Context) -> str:
+        if self.invocation_id is not None:
+            return str(UUID(self.invocation_id))
+        ti = context["ti"]
+        identity = json.dumps([self.app_url, ti.dag_id, ti.task_id, ti.run_id, 
ti.map_index])
+        return str(uuid5(NAMESPACE_URL, identity))
+
+    def _get_result(self, result: dict[str, Any], invocation_id: str) -> 
dict[str, Any] | None:
+        status = result.get("status")
+        if status == "failed":
+            raise DatabricksAgentInvocationError(
+                f"Databricks agent invocation {invocation_id} failed; inspect 
the app logs"
+            )
+        if status in ("completed", "interrupted"):
+            return result
+        if not status:
+            raise DatabricksAgentInvocationError(
+                f"Databricks agent invocation {invocation_id} response is 
missing its status"
+            )
+        return None
+
+    def execute(self, context: Context) -> dict[str, Any]:
+        invocation_id = self._get_invocation_id(context)
+        submitted = self.hook.create_invocation(invocation_id, self.input, 
self.session_id)
+        if not self.wait_for_termination:
+            return submitted
+        result = self._get_result(submitted, invocation_id) if 
submitted.get("status") else None
+        if result is not None:
+            return result
+        if self.deferrable:
+            self.defer(
+                trigger=DatabricksAgentInvocationTrigger(
+                    app_url=self.app_url,
+                    invocation_id=invocation_id,
+                    databricks_conn_id=self.databricks_conn_id,
+                    session_id=self.session_id,
+                    polling_period_seconds=self.polling_period_seconds,
+                ),
+                method_name="execute_complete",
+                timeout=timedelta(seconds=self.timeout),
+                kwargs={"invocation_id": invocation_id},
+            )
+        deadline = time.monotonic() + self.timeout
+        while True:
+            remaining = deadline - time.monotonic()
+            if remaining <= 0:
+                raise DatabricksAgentInvocationTimeout(
+                    f"Timed out waiting for Databricks agent invocation 
{invocation_id} after {self.timeout} seconds"
+                )
+            response = self.hook.get_invocation(invocation_id, 
self.session_id, timeout_seconds=remaining)
+            remaining = deadline - time.monotonic()
+            if remaining <= 0:
+                raise DatabricksAgentInvocationTimeout(
+                    f"Timed out waiting for Databricks agent invocation 
{invocation_id} after {self.timeout} seconds"
+                )
+            result = self._get_result(response, invocation_id)
+            if result is not None:
+                return result
+            time.sleep(min(self.polling_period_seconds, remaining))
+
+    def execute_complete(
+        self, context: Context, event: dict[str, Any] | None = None, *, 
invocation_id: str
+    ) -> dict[str, Any]:
+        if (
+            not event
+            or event.get("invocation_id") != invocation_id
+            or event.get("status") not in ("success", "error")
+        ):
+            raise DatabricksAgentInvocationError(
+                f"Databricks agent invocation {invocation_id} trigger returned 
an invalid event"
+            )
+        if event["status"] == "error":
+            error_type = event.get("error_type")
+            if error_type not in ("api_error", "invalid_response", 
"unexpected_error"):
+                error_type = "unexpected_error"
+            raise DatabricksAgentInvocationError(
+                f"Polling Databricks agent invocation {invocation_id} failed 
({error_type}); inspect trigger logs"
+            )
+        result = self.hook.get_invocation(invocation_id, self.session_id)

Review Comment:
   This repeats `_get_result`'s status mapping with the same messages, and the 
trigger has a third list of terminal statuses. `result = 
self._get_result(self.hook.get_invocation(invocation_id, self.session_id), 
invocation_id)` followed by raising when it's `None` would keep them in one 
place.



##########
providers/databricks/docs/operators/agent.rst:
##########
@@ -0,0 +1,131 @@
+ .. 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.
+
+
+
+.. _howto/operator:DatabricksAgentInvokeOperator:
+
+.. spelling:word-list::
+
+    MLflow
+
+Invoke a Databricks agent
+============================
+
+Use 
:class:`~airflow.providers.databricks.operators.agent.DatabricksAgentInvokeOperator`
+to invoke an agent deployed on Databricks Apps using ``DurableAgentServer``.
+The operator submits a background request to the invocation API and returns the
+invocation response, including its status and output, through XCom.
+
+Authentication
+--------------
+
+Configure a Databricks connection with the workspace URL in ``host``, the 
service
+principal's client ID in ``login``, its client secret in ``password``, and
+``{"service_principal_oauth": true}`` in ``extra``. The service principal needs
+permission to use the deployed app. Databricks Apps requires OAuth; personal
+access tokens are not supported. Supply the app's HTTPS base URL as 
``app_url``.
+
+Invoke an agent
+---------------
+
+.. exampleinclude:: 
/../../databricks/tests/system/databricks/example_databricks_agent.py
+    :language: python
+    :start-after: [START howto_operator_databricks_agent_invoke]
+    :end-before: [END howto_operator_databricks_agent_invoke]
+
+The agent defines the schema of ``input``. Agents generated by the Agent Bricks
+CLI templates require a ``session_id``. Reuse the session ID across invocations
+to continue a conversation. The hook sends it as ``X-Routing-Key`` when 
supplied.
+
+By default, the operator waits for the result. Set ``deferrable=True`` to 
release
+the worker while waiting, or ``wait_for_termination=False`` to return the 
initial
+submission response immediately. ``polling_period_seconds`` controls polling.
+The timeout limits the wait after submission; it does not cancel the remote 
run.
+Synchronous polling bounds each request and its retries by the remaining wait
+and raises 
:class:`~airflow.providers.databricks.exceptions.DatabricksAgentInvocationTimeout`
+when that budget expires. OAuth refresh has a separate HTTP timeout and can 
delay
+reporting that the wait expired. In deferrable mode, Airflow enforces the 
timeout on the deferred task.
+
+.. exampleinclude:: 
/../../databricks/tests/system/databricks/example_databricks_agent.py
+    :language: python
+    :start-after: [START howto_operator_databricks_agent_invoke_deferrable]
+    :end-before: [END howto_operator_databricks_agent_invoke_deferrable]
+
+Retries and results
+-------------------
+
+An invocation requires a UUID. Unless ``invocation_id`` is provided, the 
operator
+generates a stable UUID from the app URL, Dag ID, task ID, run ID and map 
index.
+Retries and cleared tasks in the same Dag run therefore reuse the invocation.
+To deliberately start a new invocation in that run, supply a new UUID.
+Databricks retains idempotency while it retains the invocation record. Reusing
+an ID with different input returns an HTTP 409 error.
+
+A ``failed`` invocation fails the task. An ``interrupted`` invocation is 
returned
+successfully so downstream tasks can handle human input. To resume it, submit a
+new invocation with the same session ID and the appropriate ``resume`` input.
+The returned response preserves the agent's output without assuming its schema.
+
+The operator targets ``DurableAgentServer``'s ``/api/invocations`` API. Legacy
+MLflow agent servers using ``/responses`` and Model Serving endpoints are not
+supported by this operator.
+
+For direct calls, use
+:class:`~airflow.providers.databricks.hooks.agent.DatabricksAgentHook` and its
+``create_invocation`` and ``get_invocation`` methods.
+See `Query agents deployed on Databricks
+<https://docs.databricks.com/aws/en/agents/query-llms>`_ for the API contract.
+
+Common AI managed-agent interface
+---------------------------------
+
+Install ``apache-airflow-providers-databricks[common.ai]`` to use the hook with
+Common AI consumers such as ``ManagedAgentToolset``. Bind the deployed app's 
URL
+to a hook configured with the same OAuth connection:
+
+Import ``ManagedAgentRequest`` from 
``airflow.providers.common.ai.managed_agents``
+and ``DatabricksAgentHook`` from ``airflow.providers.databricks.hooks.agent``:
+
+.. exampleinclude:: 
/../../databricks/tests/system/databricks/example_databricks_agent.py
+    :language: python
+    :start-after: [START howto_databricks_managed_agent]
+    :end-before: [END howto_databricks_managed_agent]
+
+This interface uses the synchronous invocation API. It converts a prompt to a
+user message, or passes supplied messages under ``input.messages``. For an 
agent
+that expects a prompt under another key, set ``vendor_options={"input_key": 
"question"}``.
+Other input schemas remain available through ``create_invocation`` and the 
operator.
+
+Each call generates a new invocation UUID. To reuse an invocation across task
+retries, supply a stable UUID with ``vendor_options={"invocation_id": 
"YOUR_UUID"}``.

Review Comment:
   `ManagedAgentToolset` sends the same `vendor_options` on every tool call. 
Someone who follows this together with the toolset suggestion at L97-98 gets a 
409 on the second call (new prompt, same ID), which the hook raises as 
terminal, and a repeated prompt silently gets the first answer back. Could this 
say it's for one direct `agent.invoke(...)` per task and shouldn't go in 
toolset `vendor_options`?



##########
providers/databricks/src/airflow/providers/databricks/hooks/agent.py:
##########
@@ -0,0 +1,372 @@
+# 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.
+"""Invoke agents served by Databricks DurableAgentServer."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+from uuid import UUID, uuid4
+
+import aiohttp
+import requests
+from tenacity import RetryError, stop_before_delay
+
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.databricks_base import 
BaseDatabricksHook
+
+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 as e:
+        _common_ai_import_error = e
+
+        def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+            raise AirflowOptionalProviderFeatureException(
+                "Consulting a Databricks agent needs the 'common.ai' extra: "
+                "pip install 'apache-airflow-providers-databricks[common.ai]'."
+            ) from _common_ai_import_error
+
+        class BaseManagedAgentHook:
+            """Keep background invocation available without the Common AI 
extra."""
+
+            agent = _needs_common_ai
+
+        ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse = 
_needs_common_ai
+        ManagedAgentInvocationError = _needs_common_ai
+
+
+_AGENT_OPTIONS = frozenset({"input_key", "invocation_id"})
+
+
+class DatabricksAgentHook(BaseDatabricksHook, BaseManagedAgentHook):
+    """
+    Invoke a DurableAgentServer deployed on Databricks Apps.
+
+    With the ``common.ai`` extra installed, ``hook.agent(app_url)`` exposes 
the synchronous
+    invocation API through the managed-agent contract. Background invocations 
remain available
+    through ``create_invocation`` and ``get_invocation`` without that extra.
+
+    :param app_url: HTTPS base URL for background invocation methods. Defaults 
to ``None``;
+        managed-agent calls receive the app URL as their ``agent`` argument.
+    :param databricks_conn_id: Databricks workspace connection configured with
+        ``service_principal_oauth=true``, a client ID in login and a client 
secret in password.
+        Defaults to ``databricks_default``.
+    :param timeout_seconds: HTTP request timeout in seconds. Defaults to 
``180``.
+    :param retry_limit: Maximum number of attempts for transient HTTP 
failures. Defaults to ``3``.
+    :param retry_delay: Minimum delay between HTTP retries in seconds. 
Defaults to ``1``.
+    """
+
+    def __init__(
+        self,
+        app_url: str | None = None,
+        databricks_conn_id: str = "databricks_default",
+        timeout_seconds: int = 180,
+        retry_limit: int = 3,
+        retry_delay: float = 1,
+    ) -> None:
+        super().__init__(databricks_conn_id, timeout_seconds, retry_limit, 
retry_delay)
+        self.app_url = self._validate_app_url(app_url) if app_url is not None 
else None
+
+    agent_platform = "databricks.agent_runtime"
+
+    @staticmethod
+    def _validate_app_url(app_url: str | None) -> str:
+        if app_url is None:
+            raise ValueError("app_url is required for background invocation 
methods")
+        parsed = urlsplit(app_url)
+        if (
+            parsed.scheme != "https"
+            or not parsed.hostname
+            or parsed.username
+            or parsed.password
+            or parsed.path not in ("", "/")
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("app_url must be an HTTPS base URL without 
credentials, path, query or fragment")
+        return app_url.rstrip("/")
+
+    def resolve_agent(self, agent: str) -> ManagedAgentRef:
+        """
+        Resolve an app's HTTPS base URL without contacting Databricks.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Platform-qualified app URL with its trailing slash removed.
+        """
+        return ManagedAgentRef(platform=self.agent_platform, 
name=self._validate_app_url(agent))
+
+    def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+        """
+        Report session, structured-output and invocation-ID support.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Capabilities of the synchronous invocation API.
+        """
+        self.resolve_agent(agent)
+        return ManagedAgentCapabilities(sessions=True, structured_output=True, 
trace=True)
+
+    def invoke_agent(self, agent: str, request: ManagedAgentRequest) -> 
ManagedAgentResponse:
+        """
+        Invoke an agent synchronously through the Common AI contract.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :param request: Prompt or messages, optional session and request 
timeout. Vendor options
+            accept ``input_key`` to send a prompt under an agent-specific key 
and ``invocation_id``
+            to reuse a caller-provided UUID. By default, prompts are converted 
to messages and
+            a new UUID is generated for each call.
+        :return: Answer text, full invocation response, structured output, 
session and invocation ID.
+        """
+        app_url = self.resolve_agent(agent).name
+        unknown = set(request.vendor_options) - _AGENT_OPTIONS
+        if unknown:
+            raise ValueError(
+                f"vendor_options {sorted(unknown)} are not accepted; expected 
{sorted(_AGENT_OPTIONS)}"
+            )
+        input_key = request.vendor_options.get("input_key")
+        if input_key is not None and (not isinstance(input_key, str) or not 
input_key):
+            raise ValueError("vendor_options['input_key'] must be a nonempty 
string")
+        if input_key is not None and request.messages is not None:
+            raise ValueError("vendor_options['input_key'] only applies to a 
prompt")
+        invocation_id = (
+            str(UUID(request.vendor_options["invocation_id"]))
+            if "invocation_id" in request.vendor_options
+            else str(uuid4())
+        )
+        payload: dict[str, Any] = {
+            "id": invocation_id,
+            "input": {input_key: request.prompt}
+            if input_key is not None
+            else {"messages": request.as_messages()},
+        }
+        if request.session_id is not None:
+            payload["session_id"] = request.session_id
+        try:
+            raw = self._do_agent_api_call(
+                "POST",
+                "api/invocations",
+                payload,
+                request.session_id,
+                timeout_seconds=request.timeout,
+                app_url=app_url,
+            )
+        except DatabricksAgentInvocationTimeout:
+            raise
+        except AirflowException as exc:
+            cause = exc.__cause__ or exc.__context__
+            if isinstance(cause, RetryError):
+                transient = cause.last_attempt.exception()
+                if transient is not None:
+                    raise transient from None
+            if isinstance(cause, requests.HTTPError) and (
+                cause.response.status_code == 429 or 
cause.response.status_code >= 500
+            ):
+                raise cause from None
+            raise ManagedAgentInvocationError(
+                "Databricks managed-agent request failed; inspect the app logs"
+            ) from exc
+        if not isinstance(raw, dict) or raw.get("status") != "completed":
+            raise ManagedAgentInvocationError(
+                f"Databricks agent invocation {invocation_id} did not 
complete; inspect the app logs"
+            )
+        output = raw.get("output")
+        text = output if isinstance(output, str) else "" if output is None 
else json.dumps(output)
+        if isinstance(output, dict) and isinstance(output.get("output"), str):
+            text = output["output"]
+        return ManagedAgentResponse(
+            text=text,
+            raw=raw,
+            structured=None if isinstance(output, str) else output,
+            session_id=raw.get("session_id", request.session_id),
+            trace_ref=raw.get("id", invocation_id),
+        )
+
+    def _validate_oauth(self) -> None:
+        conn = self.databricks_conn
+        if not conn.extra_dejson.get("service_principal_oauth") or not 
conn.login or not conn.password:
+            raise ValueError("Databricks Apps requires service_principal_oauth 
with a client ID and secret")
+
+    def _get_headers(self, token: str, session_id: str | None) -> dict[str, 
str]:
+        headers = {**self.user_agent_header, "Authorization": f"Bearer 
{token}"}
+        if session_id is not None:
+            headers["X-Routing-Key"] = session_id
+        return headers
+
+    def create_invocation(
+        self,
+        invocation_id: str,
+        input: Any,
+        session_id: str | None = None,
+    ) -> dict[str, Any]:
+        """
+        Start a background invocation; retrying the same UUID and input is 
idempotent.
+
+        :param invocation_id: UUID identifying the invocation.
+        :param input: JSON-serializable input whose schema is defined by the 
agent.
+        :param session_id: Conversation ID, also sent as the routing key. 
Defaults to ``None``.
+        :return: Complete JSON submission response returned by the agent 
server.
+        """
+        invocation_id = str(UUID(invocation_id))
+        payload = {"id": invocation_id, "input": input, "background": True}
+        if session_id is not None:
+            payload["session_id"] = session_id
+        return self._do_agent_api_call("POST", "api/invocations", payload, 
session_id)
+
+    def get_invocation(
+        self, invocation_id: str, session_id: str | None = None, *, 
timeout_seconds: float | None = None
+    ) -> dict[str, Any]:
+        """
+        Get the status and output of an invocation.
+
+        :param invocation_id: UUID identifying the invocation.
+        :param session_id: Conversation ID sent as the routing key. Defaults 
to ``None``.
+        :param timeout_seconds: Optional polling request/retry budget in 
seconds. Defaults to ``None``,
+            which uses the hook's HTTP timeout and retry settings. OAuth 
refresh uses the base hook's
+            token timeout; if it exhausts this budget, no invocation request 
is sent.
+        :return: Complete JSON invocation response, including output when 
available.
+        """
+        invocation_id = str(UUID(invocation_id))
+        return self._do_agent_api_call(
+            "GET", f"api/invocations/{invocation_id}", None, session_id, 
timeout_seconds=timeout_seconds
+        )
+
+    def _do_agent_api_call(
+        self,
+        method: str,
+        endpoint: str,
+        payload: dict[str, Any] | None,
+        session_id: str | None,
+        *,
+        timeout_seconds: float | None = None,
+        app_url: str | None = None,
+    ) -> dict[str, Any]:
+        app_url = self._validate_app_url(app_url if app_url is not None else 
self.app_url)
+        self._validate_oauth()
+        if timeout_seconds is not None and timeout_seconds <= 0:
+            raise ValueError("timeout_seconds must be positive")
+        deadline = time.monotonic() + timeout_seconds if timeout_seconds is 
not None else None
+        retry = self._get_retry_object()
+        if timeout_seconds is not None:
+            original_stop = retry.stop
+            budget_stop = stop_before_delay(timeout_seconds)
+            retry.stop = lambda state: original_stop(state) or 
budget_stop(state)
+        try:
+            for attempt in retry:
+                with attempt:
+                    if deadline is not None and time.monotonic() >= deadline:
+                        raise DatabricksAgentInvocationTimeout(
+                            f"Timed out polling Databricks agent {endpoint}"
+                        )
+                    token = 
self._get_sp_token(self._get_oidc_token_service_url())
+                    request_timeout: float = self.timeout_seconds
+                    if deadline is not None:
+                        remaining = deadline - time.monotonic()
+                        if remaining <= 0:
+                            raise DatabricksAgentInvocationTimeout(
+                                f"Timed out polling Databricks agent 
{endpoint}"
+                            )
+                        request_timeout = min(request_timeout, remaining)
+                    response = requests.request(
+                        method,
+                        f"{app_url}/{endpoint}",
+                        json=payload,
+                        headers=self._get_headers(token, session_id),
+                        timeout=request_timeout,
+                        allow_redirects=False,
+                        **self._get_requests_kwargs(),
+                    )
+                    if 300 <= response.status_code < 400:
+                        raise DatabricksApiError("Databricks agent returned an 
unexpected redirect")
+                    response.raise_for_status()
+                    if deadline is not None and time.monotonic() >= deadline:

Review Comment:
   This deadline check and its message appear four times in 
`_do_agent_api_call`, and the operator's poll loop repeats its own pair. A 
small helper would make this easier to read, plus a one-line comment here, 
since this is the check that throws away a terminal response that arrives after 
the deadline and that isn't obvious.



##########
providers/databricks/tests/unit/databricks/operators/test_agent.py:
##########
@@ -0,0 +1,254 @@
+# 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
+from uuid import UUID
+
+import pytest
+
+from airflow.providers.common.compat.sdk import TaskDeferred
+from airflow.providers.databricks.exceptions import (
+    DatabricksAgentInvocationError,
+    DatabricksAgentInvocationTimeout,
+)
+from airflow.providers.databricks.hooks.agent import DatabricksAgentHook
+from airflow.providers.databricks.operators.agent import 
DatabricksAgentInvokeOperator
+from airflow.providers.databricks.triggers.agent import 
DatabricksAgentInvocationTrigger
+
+APP_URL = "https://agent.databricksapps.com";
+INVOCATION_ID = "550e8400-e29b-41d4-a716-446655440000"
+
+
[email protected]
+def operator():
+    op = DatabricksAgentInvokeOperator(
+        task_id="invoke",
+        app_url=APP_URL,
+        input={"messages": []},
+        session_id="conversation",
+        invocation_id=INVOCATION_ID,
+    )
+    op.hook = mock.create_autospec(DatabricksAgentHook, instance=True)
+    op.hook.create_invocation.return_value = {"id": INVOCATION_ID, 
"status_url": "ignored"}
+    return op
+
+
[email protected]("field", ["polling_period_seconds", "timeout"])
[email protected]("value", [0, -1])
+def test_invalid_wait_options(field, value):
+    with pytest.raises(ValueError, match="must be positive"):
+        DatabricksAgentInvokeOperator(task_id="invoke", app_url=APP_URL, 
input={}, **{field: value})
+
+
[email protected](
+    ("field", "value"),
+    [
+        ("map_index", 0),
+        ("run_id", "next"),
+        ("task_id", "other"),
+        ("dag_id", "other"),
+        ("app_url", "https://other.databricksapps.com";),
+    ],
+)
+def test_idempotency_identity(operator, field, value):
+    operator.invocation_id = None
+    ti = mock.Mock(spec=["dag_id", "task_id", "run_id", "map_index", 
"try_number"])
+    ti.dag_id, ti.task_id, ti.run_id, ti.map_index, ti.try_number = "dag", 
"task", "run", -1, 1
+    first = operator._get_invocation_id({"ti": ti})
+    assert str(UUID(first)) == first
+    ti.try_number = 2
+    assert operator._get_invocation_id({"ti": ti}) == first
+    if field == "app_url":
+        operator.app_url = value
+    else:
+        setattr(ti, field, value)
+    assert operator._get_invocation_id({"ti": ti}) != first
+
+
+def test_template_fields():
+    operator = DatabricksAgentInvokeOperator(
+        task_id="invoke",
+        app_url="https://{{ app }}.databricksapps.com",
+        input={"messages": [{"role": "user", "content": "{{ prompt }}"}]},
+        databricks_conn_id="{{ connection }}",
+        session_id="{{ run_id }}",
+        invocation_id="{{ invocation_id }}",
+    )
+    operator.render_template_fields(
+        {
+            "app": "agent",
+            "prompt": "hello",
+            "connection": "oauth",
+            "run_id": "conversation",
+            "invocation_id": INVOCATION_ID,
+        }
+    )
+    assert operator.app_url == APP_URL
+    assert operator.input == {"messages": [{"role": "user", "content": 
"hello"}]}
+    assert operator.databricks_conn_id == "oauth"
+    assert operator.session_id == "conversation"
+    assert operator.invocation_id == INVOCATION_ID
+
+
+def test_submit_without_waiting(operator):
+    operator.wait_for_termination = False
+    operator.deferrable = True
+    assert operator.execute({}) == operator.hook.create_invocation.return_value
+    operator.hook.create_invocation.assert_called_once_with(INVOCATION_ID, 
operator.input, "conversation")
+    operator.hook.get_invocation.assert_not_called()
+
+
[email protected]("status", ["completed", "interrupted"])
+def test_immediate_result(operator, status):
+    result = {"status": status, "output": {"answer": "hello"}}
+    operator.hook.create_invocation.return_value = result
+    assert operator.execute({}) == result
+    operator.hook.get_invocation.assert_not_called()
+
+
[email protected]("airflow.providers.databricks.operators.agent.time.sleep", 
autospec=True)
[email protected]("airflow.providers.databricks.operators.agent.time.monotonic", 
autospec=True, return_value=0)
+def test_waits_for_result(monotonic, sleep, operator):
+    result = {"status": "completed", "output": "answer"}
+    operator.hook.get_invocation.side_effect = [{"status": "running"}, result]
+    assert operator.execute({}) == result
+    assert (
+        operator.hook.get_invocation.call_args_list
+        == [mock.call(INVOCATION_ID, "conversation", timeout_seconds=3600)] * 2
+    )
+    sleep.assert_called_once_with(10)
+
+
[email protected]("result", [{"status": "failed"}, {}])
+def test_polling_failure(operator, result):
+    operator.hook.get_invocation.return_value = result
+    with pytest.raises(DatabricksAgentInvocationError, match="failed|missing"):
+        operator.execute({})
+
+
[email protected]("airflow.providers.databricks.operators.agent.time.monotonic", 
autospec=True)
[email protected]("elapsed", [3600, 3601])
+def test_timeout_before_poll(monotonic, operator, elapsed):
+    monotonic.side_effect = [0, elapsed]
+    operator.hook.get_invocation.return_value = {"status": "running"}
+    with pytest.raises(DatabricksAgentInvocationTimeout, match=INVOCATION_ID):
+        operator.execute({})
+    operator.hook.get_invocation.assert_not_called()
+
+
[email protected]("airflow.providers.databricks.operators.agent.time.monotonic", 
autospec=True)
[email protected]("elapsed", [3600, 3601])
+def test_rejects_late_terminal_result(monotonic, operator, elapsed):
+    monotonic.side_effect = [0, 0, elapsed]
+    operator.hook.get_invocation.return_value = {"status": "completed", 
"output": "late"}
+    with pytest.raises(DatabricksAgentInvocationTimeout, match=INVOCATION_ID):
+        operator.execute({})
+    operator.hook.get_invocation.assert_called_once_with(INVOCATION_ID, 
"conversation", timeout_seconds=3600)
+
+
[email protected]("airflow.providers.databricks.operators.agent.time.sleep", 
autospec=True)
[email protected](
+    "airflow.providers.databricks.operators.agent.time.monotonic",
+    autospec=True,
+    side_effect=[0, 3599, 3599, 3600],
+)
+def test_timeout_after_sleep(monotonic, sleep, operator):
+    operator.hook.get_invocation.return_value = {"status": "running"}
+    with pytest.raises(DatabricksAgentInvocationTimeout, match=INVOCATION_ID):
+        operator.execute({})
+    operator.hook.get_invocation.assert_called_once_with(INVOCATION_ID, 
"conversation", timeout_seconds=1)
+    sleep.assert_called_once_with(1)
+
+
+def test_defers(operator):
+    operator.deferrable = True
+    with pytest.raises(TaskDeferred) as exc:
+        operator.execute({})
+    trigger = exc.value.trigger
+    assert isinstance(trigger, DatabricksAgentInvocationTrigger)
+    assert trigger.app_url == APP_URL
+    assert trigger.invocation_id == INVOCATION_ID
+    assert trigger.session_id == "conversation"

Review Comment:
   The fixture uses the default `databricks_conn_id` and 
`polling_period_seconds`, which match the trigger's defaults, so dropping 
either from the `defer()` call keeps this green. No `execute_complete` test 
asserts the `get_invocation` arguments either, so losing the `session_id` 
routing key there would pass too. Could these use non-default values and assert 
them, and `test_run` in the trigger tests pass a non-default poll period?



##########
providers/databricks/tests/unit/databricks/hooks/test_agent.py:
##########
@@ -0,0 +1,521 @@
+# 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 subprocess
+import sys
+from unittest import mock
+
+import aiohttp
+import pytest
+import requests
+
+from airflow.providers.common.compat.sdk import Connection
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.agent import DatabricksAgentHook
+
+try:
+    from airflow.providers.common.ai.exceptions import 
ManagedAgentInvocationError
+    from airflow.providers.common.ai.managed_agents.base import 
ManagedAgentRef, ManagedAgentRequest
+
+    HAS_COMMON_AI = True
+except ImportError:
+    HAS_COMMON_AI = False
+
+APP_URL = "https://agent.databricksapps.com";
+INVOCATION_ID = "550e8400-e29b-41d4-a716-446655440000"
+
+
[email protected]
+def hook():
+    hook = DatabricksAgentHook(APP_URL, retry_limit=2, retry_delay=0)
+    hook.databricks_conn = Connection(
+        conn_id="databricks_default",
+        conn_type="databricks",
+        host="workspace.cloud.databricks.com",
+        login="client-id",
+        password="client-secret",
+        extra='{"service_principal_oauth": true}',
+    )
+    hook.user_agent_header = {"user-agent": "test"}
+    return hook
+
+
[email protected](
+    "url",
+    [
+        "http://app";,
+        "https://user:secret@app";,
+        "https://app/path";,
+        "https://app?token=x";,
+        "https://app#fragment";,
+        "https://";,
+    ],
+)
+def test_invalid_app_url(url):
+    with pytest.raises(ValueError, match="HTTPS base URL"):
+        DatabricksAgentHook(url)
+
+
[email protected](
+    ("extra", "login", "password"),
+    [
+        ("{}", "", "pat"),
+        ('{"token":"pat","service_principal_oauth":true}', "", ""),
+        ('{"service_principal_oauth":true}', "client", ""),
+    ],
+)
+def test_requires_oauth(hook, extra, login, password):
+    hook.databricks_conn = Connection(
+        conn_id="databricks_default", conn_type="databricks", extra=extra, 
login=login, password=password
+    )
+    with pytest.raises(ValueError, match="service_principal_oauth"):
+        hook.create_invocation(INVOCATION_ID, {})
+
+
[email protected](DatabricksAgentHook, "_get_sp_token", autospec=True, 
return_value="oauth")
[email protected]("airflow.providers.databricks.hooks.agent.requests.request", 
autospec=True)
[email protected]("session_id", [None, "conversation"])
+def test_invoke(request, token, hook, session_id):
+    response = mock.Mock(spec=requests.Response)
+    response.status_code = 202
+    response.json.return_value = {"id": INVOCATION_ID, "status_url": "ignored"}
+    request.return_value = response
+    assert hook.create_invocation(INVOCATION_ID, {"messages": []}, session_id) 
== response.json.return_value
+    payload = {"id": INVOCATION_ID, "input": {"messages": []}, "background": 
True}
+    headers = {"user-agent": "test", "Authorization": "Bearer oauth"}
+    if session_id:
+        payload["session_id"] = session_id
+        headers["X-Routing-Key"] = session_id
+    request.assert_called_once_with(
+        "POST",
+        f"{APP_URL}/api/invocations",
+        json=payload,
+        headers=headers,
+        timeout=180,
+        allow_redirects=False,
+    )
+    token.assert_called_once_with(hook, 
"https://workspace.cloud.databricks.com/oidc/v1/token";)
+
+
[email protected](DatabricksAgentHook, "_get_sp_token", autospec=True, 
return_value="oauth")
[email protected]("airflow.providers.databricks.hooks.agent.requests.request", 
autospec=True)
+def test_get_retries_transient_error(request, token, hook):
+    response = mock.Mock(spec=requests.Response)
+    response.status_code = 200
+    response.json.return_value = {"status": "completed", "output": "answer"}
+    request.side_effect = [requests.ConnectionError("connection dropped"), 
response]
+    assert hook.get_invocation(INVOCATION_ID) == response.json.return_value
+    assert request.call_count == 2
+    assert request.call_args.args == ("GET", 
f"{APP_URL}/api/invocations/{INVOCATION_ID}")
+    assert request.call_args.kwargs["json"] is None
+
+
[email protected](DatabricksAgentHook, "_get_sp_token", autospec=True, 
return_value="oauth")
[email protected]("airflow.providers.databricks.hooks.agent.requests.request", 
autospec=True)
[email protected]("status", [302, 403, 409, 429, 500])
+def test_http_errors(request, token, hook, status):
+    response = mock.Mock(spec=requests.Response)
+    response.status_code = status
+    response.raise_for_status.side_effect = 
requests.HTTPError(response=response)
+    request.return_value = response
+    with pytest.raises(DatabricksApiError):
+        hook.get_invocation(INVOCATION_ID)
+    assert request.call_count == (2 if status in (429, 500) else 1)
+
+
[email protected]("method", ["create_invocation", "get_invocation"])
+def test_invalid_invocation_id(hook, method):
+    with pytest.raises(ValueError, match="badly formed hexadecimal UUID"):
+        getattr(hook, method)("../../other", **({"input": {}} if method == 
"create_invocation" else {}))
+
+
[email protected](DatabricksAgentHook, "_a_get_sp_token", autospec=True, 
return_value="oauth")
[email protected]("airflow.providers.databricks.hooks.databricks_base.aiohttp.ClientSession",
 autospec=True)
[email protected]("airflow.providers.databricks.hooks.databricks_base.get_async_connection",
 autospec=True)
[email protected]("cached_connection", [True, False])
[email protected]("status", [200, 302, 403, 429, 500])
[email protected]
+async def test_async_get(get_connection, session, token, hook, status, 
cached_connection):

Review Comment:
   This one crosses five statuses with two cache modes and branches on both in 
the arrange and assert steps, so it's hard to tell what a single case pins. 
`test_expired_poll_budget`, `test_http_error_classification` and 
`test_async_transport_failure` have the same shape. Splitting by outcome 
(success, retryable, non-retryable) would make each case's assertions 
unconditional.



##########
providers/databricks/tests/system/databricks/example_databricks_agent.py:
##########
@@ -0,0 +1,112 @@
+# 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.
+"""
+Invoke the deterministic agent in resources/agent_server on Databricks Apps.
+
+Deploy the bundled app and configure DATABRICKS_AGENT_APP_URL and the
+databricks_oauth connection before running this system test.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import datetime
+
+from airflow.providers.common.ai.managed_agents import ManagedAgentRequest
+from airflow.providers.common.compat.sdk import DAG, task
+from airflow.providers.databricks.hooks.agent import DatabricksAgentHook
+from airflow.providers.databricks.operators.agent import 
DatabricksAgentInvokeOperator
+
+DAG_ID = "example_databricks_agent"
+APP_URL = os.environ.get("DATABRICKS_AGENT_APP_URL", 
"https://my-agent.databricksapps.com";)
+CONN_ID = os.environ.get("DATABRICKS_AGENT_CONN_ID", "databricks_oauth")
+INPUT = {"messages": [{"role": "user", "content": "Hello from Airflow"}]}
+SESSION_ID = "{{ dag.dag_id }}-{{ run_id }}"
+
+
+# [START howto_databricks_managed_agent]
+@task
+def invoke_managed_agent(app_url: str, conn_id: str, session_id: str) -> dict:
+    agent = DatabricksAgentHook(databricks_conn_id=conn_id).agent(app_url)
+    result = agent.invoke(
+        ManagedAgentRequest(
+            messages=[{"role": "user", "content": "Hello from Airflow"}],

Review Comment:
   nit: `messages=INPUT["messages"]`, since `verify_result` asserts against 
`INPUT` and the two literals have to stay in sync.



##########
providers/databricks/src/airflow/providers/databricks/hooks/agent.py:
##########
@@ -0,0 +1,372 @@
+# 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.
+"""Invoke agents served by Databricks DurableAgentServer."""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import TYPE_CHECKING, Any
+from urllib.parse import urlsplit
+from uuid import UUID, uuid4
+
+import aiohttp
+import requests
+from tenacity import RetryError, stop_before_delay
+
+from airflow.providers.common.compat.sdk import AirflowException, 
AirflowOptionalProviderFeatureException
+from airflow.providers.databricks.exceptions import 
DatabricksAgentInvocationTimeout, DatabricksApiError
+from airflow.providers.databricks.hooks.databricks_base import 
BaseDatabricksHook
+
+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 as e:
+        _common_ai_import_error = e
+
+        def _needs_common_ai(*args: Any, **kwargs: Any) -> Any:
+            raise AirflowOptionalProviderFeatureException(
+                "Consulting a Databricks agent needs the 'common.ai' extra: "
+                "pip install 'apache-airflow-providers-databricks[common.ai]'."
+            ) from _common_ai_import_error
+
+        class BaseManagedAgentHook:
+            """Keep background invocation available without the Common AI 
extra."""
+
+            agent = _needs_common_ai
+
+        ManagedAgentCapabilities = ManagedAgentRef = ManagedAgentResponse = 
_needs_common_ai
+        ManagedAgentInvocationError = _needs_common_ai
+
+
+_AGENT_OPTIONS = frozenset({"input_key", "invocation_id"})
+
+
+class DatabricksAgentHook(BaseDatabricksHook, BaseManagedAgentHook):
+    """
+    Invoke a DurableAgentServer deployed on Databricks Apps.
+
+    With the ``common.ai`` extra installed, ``hook.agent(app_url)`` exposes 
the synchronous
+    invocation API through the managed-agent contract. Background invocations 
remain available
+    through ``create_invocation`` and ``get_invocation`` without that extra.
+
+    :param app_url: HTTPS base URL for background invocation methods. Defaults 
to ``None``;
+        managed-agent calls receive the app URL as their ``agent`` argument.
+    :param databricks_conn_id: Databricks workspace connection configured with
+        ``service_principal_oauth=true``, a client ID in login and a client 
secret in password.
+        Defaults to ``databricks_default``.
+    :param timeout_seconds: HTTP request timeout in seconds. Defaults to 
``180``.
+    :param retry_limit: Maximum number of attempts for transient HTTP 
failures. Defaults to ``3``.
+    :param retry_delay: Minimum delay between HTTP retries in seconds. 
Defaults to ``1``.
+    """
+
+    def __init__(
+        self,
+        app_url: str | None = None,
+        databricks_conn_id: str = "databricks_default",
+        timeout_seconds: int = 180,
+        retry_limit: int = 3,
+        retry_delay: float = 1,
+    ) -> None:
+        super().__init__(databricks_conn_id, timeout_seconds, retry_limit, 
retry_delay)
+        self.app_url = self._validate_app_url(app_url) if app_url is not None 
else None
+
+    agent_platform = "databricks.agent_runtime"
+
+    @staticmethod
+    def _validate_app_url(app_url: str | None) -> str:
+        if app_url is None:
+            raise ValueError("app_url is required for background invocation 
methods")
+        parsed = urlsplit(app_url)
+        if (
+            parsed.scheme != "https"
+            or not parsed.hostname
+            or parsed.username
+            or parsed.password
+            or parsed.path not in ("", "/")
+            or parsed.query
+            or parsed.fragment
+        ):
+            raise ValueError("app_url must be an HTTPS base URL without 
credentials, path, query or fragment")
+        return app_url.rstrip("/")
+
+    def resolve_agent(self, agent: str) -> ManagedAgentRef:
+        """
+        Resolve an app's HTTPS base URL without contacting Databricks.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Platform-qualified app URL with its trailing slash removed.
+        """
+        return ManagedAgentRef(platform=self.agent_platform, 
name=self._validate_app_url(agent))
+
+    def get_agent_capabilities(self, agent: str) -> ManagedAgentCapabilities:
+        """
+        Report session, structured-output and invocation-ID support.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :return: Capabilities of the synchronous invocation API.
+        """
+        self.resolve_agent(agent)
+        return ManagedAgentCapabilities(sessions=True, structured_output=True, 
trace=True)
+
+    def invoke_agent(self, agent: str, request: ManagedAgentRequest) -> 
ManagedAgentResponse:
+        """
+        Invoke an agent synchronously through the Common AI contract.
+
+        :param agent: HTTPS base URL of the deployed app.
+        :param request: Prompt or messages, optional session and request 
timeout. Vendor options
+            accept ``input_key`` to send a prompt under an agent-specific key 
and ``invocation_id``
+            to reuse a caller-provided UUID. By default, prompts are converted 
to messages and
+            a new UUID is generated for each call.
+        :return: Answer text, full invocation response, structured output, 
session and invocation ID.
+        """
+        app_url = self.resolve_agent(agent).name
+        unknown = set(request.vendor_options) - _AGENT_OPTIONS
+        if unknown:
+            raise ValueError(
+                f"vendor_options {sorted(unknown)} are not accepted; expected 
{sorted(_AGENT_OPTIONS)}"
+            )
+        input_key = request.vendor_options.get("input_key")
+        if input_key is not None and (not isinstance(input_key, str) or not 
input_key):
+            raise ValueError("vendor_options['input_key'] must be a nonempty 
string")
+        if input_key is not None and request.messages is not None:
+            raise ValueError("vendor_options['input_key'] only applies to a 
prompt")
+        invocation_id = (
+            str(UUID(request.vendor_options["invocation_id"]))
+            if "invocation_id" in request.vendor_options
+            else str(uuid4())
+        )
+        payload: dict[str, Any] = {
+            "id": invocation_id,
+            "input": {input_key: request.prompt}
+            if input_key is not None
+            else {"messages": request.as_messages()},
+        }
+        if request.session_id is not None:
+            payload["session_id"] = request.session_id
+        try:
+            raw = self._do_agent_api_call(
+                "POST",
+                "api/invocations",
+                payload,
+                request.session_id,
+                timeout_seconds=request.timeout,
+                app_url=app_url,
+            )
+        except DatabricksAgentInvocationTimeout:
+            raise
+        except AirflowException as exc:
+            cause = exc.__cause__ or exc.__context__
+            if isinstance(cause, RetryError):
+                transient = cause.last_attempt.exception()
+                if transient is not None:
+                    raise transient from None
+            if isinstance(cause, requests.HTTPError) and (
+                cause.response.status_code == 429 or 
cause.response.status_code >= 500
+            ):
+                raise cause from None

Review Comment:
   On a sync POST the server never returns 200 with `failed`: `_invoke_request` 
turns a failed run into `HTTPException(500, "agent invocation failed")` 
(`runtime/app.py:230-239`). That 500 is retryable in the base hook, so the same 
ID is re-POSTed `retry_limit` times (each one hits the stored failure), and 
then this line re-raises it as a bare `HTTPError`, which is the contract's 
transient class. The `ManagedAgentInvocationError` at L200 is never reached for 
a failed run, so a retry policy or failover filter keyed on it never fires.
   
   The ID is known here, so after a 5xx a GET on `api/invocations/{id}` could 
tell a failed run from a transient error. The `{"status": "failed"}` case in 
the test at L252 should become a 500 followed by that GET.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to