This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch anthropic-session-budget-reached
in repository https://gitbox.apache.org/repos/asf/airflow.git

commit a1c4cb0b288cbc6ee60dbb707c3894a1a62f1bf1
Author: Kaxil Naik <[email protected]>
AuthorDate: Tue Aug 11 13:26:01 2026 +0530

    Fix misleading error when an Anthropic agent session stops on its budget
    
    Anthropic SDK 0.121.0 adds Managed Agents session budgets: a spend ceiling
    passed to ``sessions.create``, after which the session stops issuing new
    model requests and goes idle with a new ``budget_reached`` stop reason.
    
    The provider treated every non-``end_turn`` idle reason the same way, so
    a budget stop surfaced as "configure an autonomous agent or use an
    outcome run" -- advice that has nothing to do with what happened. It also
    raised the generic ``AnthropicAgentSessionError``, giving Dag authors no
    way to tell a spend decision apart from a fault.
    
    ``budget_reached`` now gets its own branch and a new
    ``AnthropicSessionBudgetExceeded`` (a subclass of the existing session
    error, so current handlers keep working). The message names both causes,
    because a session also stops this way when its usage includes a model
    with no list price that a budget cannot measure -- and raising the
    ceiling does not unblock that case. It also says the operator archives
    the session on this path, so the ceiling has to be raised for the next
    run rather than on a session that no longer exists.
    
    ``poll_session_completion`` returns the SDK's ``stop_reason`` alongside
    the message, and the trigger carries it in its error event, so the
    deferrable path raises the same class as the synchronous one without
    matching on message text. A trigger serialized before this field existed
    omits it and falls back to the generic error.
    
    Verified against the live API, not only against mocks: ``sessions.create``
    accepts the documented budget payload, the server really emits
    ``budget_reached``, and the hook classifies a genuinely budget-stopped
    session and raises ``AnthropicSessionBudgetExceeded``.
    
    That testing also showed a budget is a stop trigger, not a spend cap. The
    ceiling is checked between model requests, so an in-flight request runs
    to completion: a $0.01 ceiling admitted between $0.32 and $0.61 of usage
    across four runs. The docs now say so, since "hard spend ceiling" invites
    the wrong expectation.
    
    The ``anthropic`` floor moves to 0.121.0, the first release whose
    ``sessions.create`` accepts ``budget`` -- which is how the guide tells
    users to set a ceiling. Reading a ``budget_reached`` stop reason happens
    to work further back, because older clients mis-build the discriminated
    union into the wrong variant class while preserving ``.type``, but only
    with response validation left non-strict, so it is not something to
    depend on.
---
 providers/anthropic/README.rst                     |  2 +-
 providers/anthropic/docs/index.rst                 |  8 +-
 providers/anthropic/docs/operators/anthropic.rst   | 67 ++++++++++++++-
 providers/anthropic/pyproject.toml                 | 15 ++--
 .../src/airflow/providers/anthropic/exceptions.py  | 11 +++
 .../airflow/providers/anthropic/hooks/anthropic.py | 89 ++++++++++++++++----
 .../airflow/providers/anthropic/operators/agent.py | 25 ++++--
 .../airflow/providers/anthropic/triggers/agent.py  | 18 +++-
 .../tests/unit/anthropic/hooks/test_anthropic.py   | 95 ++++++++++++++++++----
 .../tests/unit/anthropic/operators/test_agent.py   | 30 +++++++
 .../tests/unit/anthropic/test_exceptions.py        |  2 +
 .../tests/unit/anthropic/triggers/test_agent.py    | 37 +++++++--
 uv.lock                                            | 14 ++--
 13 files changed, 345 insertions(+), 68 deletions(-)

diff --git a/providers/anthropic/README.rst b/providers/anthropic/README.rst
index c895946d7c9..265ea60f94c 100644
--- a/providers/anthropic/README.rst
+++ b/providers/anthropic/README.rst
@@ -57,7 +57,7 @@ PIP package                                 Version required
 ==========================================  ==================
 ``apache-airflow``                          ``>=3.0.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``anthropic``                               ``>=0.101.0``
+``anthropic``                               ``>=0.121.0``
 ==========================================  ==================
 
 Optional dependencies
diff --git a/providers/anthropic/docs/index.rst 
b/providers/anthropic/docs/index.rst
index cab3cc67c30..c6b97c9716c 100644
--- a/providers/anthropic/docs/index.rst
+++ b/providers/anthropic/docs/index.rst
@@ -138,7 +138,7 @@ PIP package                                 Version required
 ==========================================  ==================
 ``apache-airflow``                          ``>=3.0.0``
 ``apache-airflow-providers-common-compat``  ``>=1.12.0``
-``anthropic``                               ``>=0.101.0``
+``anthropic``                               ``>=0.121.0``
 ==========================================  ==================
 
 Optional dependencies
@@ -155,9 +155,9 @@ Install them when installing from PyPI. For example:
 ===========  ===============================
 Extra        Dependencies
 ===========  ===============================
-``bedrock``  ``anthropic[bedrock]>=0.101.0``
-``vertex``   ``anthropic[vertex]>=0.101.0``
-``aws``      ``anthropic[aws]>=0.101.0``
+``bedrock``  ``anthropic[bedrock]>=0.121.0``
+``vertex``   ``anthropic[vertex]>=0.121.0``
+``aws``      ``anthropic[aws]>=0.121.0``
 ===========  ===============================
 
 Downloading official packages
diff --git a/providers/anthropic/docs/operators/anthropic.rst 
b/providers/anthropic/docs/operators/anthropic.rst
index 90b288a0c82..a904e687549 100644
--- a/providers/anthropic/docs/operators/anthropic.rst
+++ b/providers/anthropic/docs/operators/anthropic.rst
@@ -155,10 +155,69 @@ Parameters
 
     Completion is detected accurately for both modes. A ``message`` run 
inspects the
     terminal ``session.status_idle`` event's ``stop_reason`` (correlated 
against the
-    kickoff event): ``end_turn`` succeeds; ``requires_action`` and 
``retries_exhausted``
-    raise an error. An ``outcome`` run is judged from the 
``outcome_evaluations`` verdict.
-    The agent must still be configured for autonomous operation (no 
client-side custom
-    tools / ``always_ask``).
+    kickoff event): ``end_turn`` succeeds; ``requires_action``, 
``retries_exhausted`` and
+    ``budget_reached`` raise an error. An ``outcome`` run is judged from the
+    ``outcome_evaluations`` verdict. The agent must still be configured for 
autonomous
+    operation (no client-side custom tools / ``always_ask``).
+
+Session budgets
+"""""""""""""""
+
+Pass a `session budget
+<https://platform.claude.com/docs/en/managed-agents/overview>`__ through
+``session_kwargs`` to cap what a single session may spend. The session stops 
issuing new
+model requests once its tracked list cost reaches the ceiling:
+
+.. code-block:: python
+
+    AnthropicAgentSessionOperator(
+        task_id="research",
+        agent_id="agt_...",
+        environment_id="env_...",
+        message="Summarise yesterday's incidents.",
+        session_kwargs={
+            "budget": {
+                "type": "limit",
+                # Minor units as an integer string: "2500" is $25.00.
+                "max_list_cost": {"amount": "2500", "currency": "USD"},
+            }
+        },
+    )
+
+On a ``message`` run, a session that stops this way raises
+:class:`~airflow.providers.anthropic.exceptions.AnthropicSessionBudgetExceeded`,
 a subclass
+of ``AnthropicAgentSessionError``, so it can be caught on its own and routed 
to review
+rather than treated as a fault.
+
+.. warning::
+
+    On an ``outcome`` run, completion is judged from ``outcome_evaluations`` 
before the idle
+    event is read, so a budget stop raises nothing. The session stays 
non-terminal, polling
+    continues until ``timeout`` (24 hours by default), and the task then fails 
with
+    ``AnthropicAgentSessionTimeout`` -- a misleading error for a session that 
stopped
+    deliberately. Set a shorter ``timeout`` when combining ``outcome`` with a 
budget.
+
+.. warning::
+
+    **A budget is a stop trigger, not a spend cap.** The ceiling is checked 
*between*
+    model requests, so a request already in flight runs to completion and the 
session can
+    finish well above the limit -- in testing, by a large multiple of a very 
small
+    ceiling, because a single long generation overshoots before the next 
request can be
+    blocked. Size it as a circuit breaker rather than a guarantee, and read 
the session's
+    ``usage.list_cost`` for what was actually spent.
+
+.. warning::
+
+    A session also stops with ``budget_reached`` when its usage includes a 
model with **no
+    list price**, because a budget cannot measure that spend. Raising the 
ceiling does not
+    unblock that case; remove the budget instead.
+
+.. warning::
+
+    Airflow ``retries`` multiply spend. Each retry starts a **new** session 
with a **fresh**
+    budget, so ``retries=2`` with a $25 ceiling can spend $75. Prefer 
``retries=0`` on
+    budgeted sessions: the operator archives a budget-stopped session, so 
there is no
+    running session left to raise the ceiling on.
 
 .. exampleinclude:: /../tests/system/anthropic/example_anthropic_agent.py
     :language: python
diff --git a/providers/anthropic/pyproject.toml 
b/providers/anthropic/pyproject.toml
index 6affe34490d..da81825cce7 100644
--- a/providers/anthropic/pyproject.toml
+++ b/providers/anthropic/pyproject.toml
@@ -61,17 +61,20 @@ requires-python = ">=3.10"
 dependencies = [
     "apache-airflow>=3.0.0",
     "apache-airflow-providers-common-compat>=1.12.0",
-    # 0.101.0 is the first release that ships AnthropicAWS, the newest of the 
platform
-    # client classes the hook imports at module top.
-    "anthropic>=0.101.0",
+    # 0.121.0 is the first release whose ``sessions.create`` accepts 
``budget``, which is
+    # how the guide tells users to set a session ceiling. Reading a 
``budget_reached`` stop
+    # reason happens to work further back -- older clients mis-build the 
discriminated
+    # union into the wrong variant class while preserving ``.type`` -- but 
only with
+    # response validation left non-strict, so it is not something to depend on.
+    "anthropic>=0.121.0",
 ]
 
 # The optional dependencies should be modified in place in the generated file
 # Any change in the dependencies is preserved when the file is regenerated
 [project.optional-dependencies]
-"bedrock" = ["anthropic[bedrock]>=0.101.0"]
-"vertex" = ["anthropic[vertex]>=0.101.0"]
-"aws" = ["anthropic[aws]>=0.101.0"]
+"bedrock" = ["anthropic[bedrock]>=0.121.0"]
+"vertex" = ["anthropic[vertex]>=0.121.0"]
+"aws" = ["anthropic[aws]>=0.121.0"]
 
 [dependency-groups]
 dev = [
diff --git a/providers/anthropic/src/airflow/providers/anthropic/exceptions.py 
b/providers/anthropic/src/airflow/providers/anthropic/exceptions.py
index 7250d1e848c..2cf58b8a709 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/exceptions.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/exceptions.py
@@ -37,5 +37,16 @@ class AnthropicAgentSessionError(AnthropicError):
     """Raised when a Managed Agents session terminates or fails."""
 
 
+class AnthropicSessionBudgetExceeded(AnthropicAgentSessionError):
+    """
+    Raised when a Managed Agents session stops against its configured budget.
+
+    A subclass of :class:`AnthropicAgentSessionError` so existing handlers 
keep working,
+    but it can be caught on its own: a budget stop is a spend decision, not a 
fault, and
+    usually wants different handling (alert, raise the ceiling, route to 
review) than a
+    failed run.
+    """
+
+
 class AnthropicAgentSessionTimeout(AnthropicError):
     """Raised when a Managed Agents session does not reach a terminal status 
in time."""
diff --git 
a/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py 
b/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
index 5f0cbd40886..932b9215b35 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/hooks/anthropic.py
@@ -20,7 +20,7 @@ import logging
 import time
 from enum import Enum
 from functools import cached_property
-from typing import TYPE_CHECKING, Any, cast
+from typing import TYPE_CHECKING, Any, NamedTuple, cast
 
 from anthropic import (
     Anthropic,
@@ -38,6 +38,7 @@ from airflow.providers.anthropic.exceptions import (
     AnthropicBatchJobError,
     AnthropicBatchTimeout,
     AnthropicError,
+    AnthropicSessionBudgetExceeded,
     AnthropicTriggerEventError,
 )
 from airflow.providers.common.compat.sdk import AirflowSkipException, BaseHook
@@ -119,6 +120,35 @@ class SessionStatus(str, Enum):
 #: ``outcome_evaluations[].result`` values that mean the outcome did NOT 
succeed.
 OUTCOME_FAILURE_RESULTS = frozenset({"failed", "max_iterations_reached", 
"interrupted"})
 
+# ``session.status_idle`` stop reason emitted when a session stops against its 
budget.
+BUDGET_REACHED = "budget_reached"
+
+
+def _create_session_error(message: str, stop_reason: str | None) -> 
AnthropicAgentSessionError:
+    """
+    Return the session error class matching an idle ``stop_reason``.
+
+    Keyed on the SDK's own ``stop_reason`` value rather than on the message 
text, so the
+    synchronous path and the deferrable path (which only carries the reason as 
a string
+    through the trigger event) raise the same type for the same cause.
+    """
+    if stop_reason == BUDGET_REACHED:
+        return AnthropicSessionBudgetExceeded(message)
+    return AnthropicAgentSessionError(message)
+
+
+class SessionPollResult(NamedTuple):
+    """
+    Verdict from one poll of a session; see 
:meth:`AnthropicHook.poll_session_completion`.
+
+    Named rather than a bare tuple because ``error_message`` and 
``stop_reason`` are both
+    ``str | None``, so transposing them at a call site would still type-check.
+    """
+
+    done: bool
+    error_message: str | None
+    stop_reason: str | None
+
 
 def evaluate_session_state(
     session: BetaManagedAgentsSession, *, expect_outcome: bool
@@ -587,13 +617,24 @@ class AnthropicHook(BaseHook):
 
     def poll_session_completion(
         self, session_id: str, *, expect_outcome: bool = False, 
kickoff_event_id: str | None = None
-    ) -> tuple[bool, str | None]:
+    ) -> SessionPollResult:
         """
-        Return ``(done, error_message)`` for one poll of a session.
+        Return the :class:`SessionPollResult` for one poll of a session.
 
         Combines the session object (status / outcome verdict) with the event 
log
         (``stop_reason`` of the latest idle) so a ``message`` run 
distinguishes genuine
-        ``end_turn`` completion from ``requires_action`` / 
``retries_exhausted``.
+        ``end_turn`` completion from ``requires_action`` / 
``retries_exhausted`` /
+        ``budget_reached``.
+
+        ``stop_reason`` is the SDK's own idle stop reason, or ``None`` when 
the verdict did
+        not come from an idle event (a ``terminated`` session, or an outcome 
verdict). It
+        exists so callers can pick an error class without matching on the 
message text; pass
+        it to :func:`_create_session_error`.
+
+        .. note::
+            A budget stop is classified on ``message`` runs only. An 
``outcome`` run is
+            judged from ``outcome_evaluations`` before the event log is 
consulted, so a
+            budget stop there surfaces as whatever verdict the outcome 
recorded.
         """
         session = self.get_session(session_id)
         done, error_message, needs_event_check = evaluate_session_state(
@@ -607,15 +648,34 @@ class AnthropicHook(BaseHook):
             needs_event_check,
         )
         if not needs_event_check:
-            return done, error_message
+            return SessionPollResult(done=done, error_message=error_message, 
stop_reason=None)
         reason = self._latest_idle_reason(session_id, kickoff_event_id)
         if reason is None:
-            return False, None
+            return SessionPollResult(done=False, error_message=None, 
stop_reason=None)
         if reason == "end_turn":
-            return True, None
-        return True, (
-            f"Session {session_id} is idle but did not complete ({reason}); "
-            "configure an autonomous agent or use an outcome run."
+            return SessionPollResult(done=True, error_message=None, 
stop_reason=reason)
+        if reason == BUDGET_REACHED:
+            # Both causes are worth naming: a session also stops with 
``budget_reached``
+            # when its usage includes a model with no list price, because the 
budget cannot
+            # measure that spend -- and then raising the ceiling does not 
unblock it.
+            return SessionPollResult(
+                done=True,
+                error_message=(
+                    f"Session {session_id} stopped against its budget: the 
tracked list cost "
+                    "reached the configured ceiling, or its usage included a 
model with no "
+                    "list price (which a budget cannot measure). The operator 
archives the "
+                    "session on this path, so it cannot be resumed -- raise 
the ceiling for "
+                    "the next run, or drop the budget if a model has no list 
price."
+                ),
+                stop_reason=reason,
+            )
+        return SessionPollResult(
+            done=True,
+            error_message=(
+                f"Session {session_id} is idle but did not complete 
({reason}); "
+                "configure an autonomous agent or use an outcome run."
+            ),
+            stop_reason=reason,
         )
 
     def wait_for_session(
@@ -636,12 +696,13 @@ class AnthropicHook(BaseHook):
             idle event on a ``message`` run (defeats the start race).
         :param poll_interval: Seconds to sleep between polls.
         :param timeout: Maximum seconds to wait before raising 
:class:`AnthropicAgentSessionTimeout`.
+        :raises AnthropicSessionBudgetExceeded: If the session stopped against 
its budget.
         """
         start = time.monotonic()
         consecutive_failures = 0
         while True:
             try:
-                done, error_message = self.poll_session_completion(
+                poll_result = self.poll_session_completion(
                     session_id, expect_outcome=expect_outcome, 
kickoff_event_id=kickoff_event_id
                 )
             except Exception as e:
@@ -657,9 +718,9 @@ class AnthropicHook(BaseHook):
                 time.sleep(poll_interval)
                 continue
             consecutive_failures = 0
-            if done:
-                if error_message:
-                    raise AnthropicAgentSessionError(error_message)
+            if poll_result.done:
+                if poll_result.error_message:
+                    raise _create_session_error(poll_result.error_message, 
poll_result.stop_reason)
                 return
             if time.monotonic() - start > timeout:
                 raise AnthropicAgentSessionTimeout(
diff --git 
a/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py 
b/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
index e829ab0de6a..a8599a618e4 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/operators/agent.py
@@ -22,8 +22,12 @@ from datetime import timedelta
 from functools import cached_property
 from typing import TYPE_CHECKING, Any
 
-from airflow.providers.anthropic.exceptions import AnthropicAgentSessionError, 
AnthropicAgentSessionTimeout
-from airflow.providers.anthropic.hooks.anthropic import AnthropicHook, 
validate_execute_complete_event
+from airflow.providers.anthropic.exceptions import AnthropicAgentSessionTimeout
+from airflow.providers.anthropic.hooks.anthropic import (
+    AnthropicHook,
+    _create_session_error,
+    validate_execute_complete_event,
+)
 from airflow.providers.anthropic.triggers.agent import 
AnthropicAgentSessionTrigger
 from airflow.providers.common.compat.sdk import BaseOperator, conf
 
@@ -49,9 +53,9 @@ class AnthropicAgentSessionOperator(BaseOperator):
         Completion is detected accurately for both modes. A ``message`` run 
reads the
         terminal ``session.status_idle`` event's ``stop_reason`` (correlated 
against the
         kickoff event to avoid a start-race false positive): ``end_turn`` 
succeeds, while
-        ``requires_action`` (the agent is blocked on input) and 
``retries_exhausted``
-        raise an error rather than silently passing. An ``outcome`` run is 
judged from the
-        session's ``outcome_evaluations`` verdict (``satisfied`` vs.
+        ``requires_action`` (the agent is blocked on input), 
``retries_exhausted`` and
+        ``budget_reached`` raise an error rather than silently passing. An 
``outcome`` run
+        is judged from the session's ``outcome_evaluations`` verdict 
(``satisfied`` vs.
         ``failed``/``max_iterations_reached``/``interrupted``).
 
         Agents and environments are created once (see
@@ -81,7 +85,14 @@ class AnthropicAgentSessionOperator(BaseOperator):
     :param session_resources: Session resources (files, GitHub repos, memory 
stores). Named
         ``session_resources`` to avoid colliding with the reserved 
``BaseOperator.resources``;
         forwarded to ``sessions.create`` as ``resources``.
-    :param session_kwargs: Extra keyword arguments forwarded to 
``sessions.create``.
+    :param session_kwargs: Extra keyword arguments forwarded to 
``sessions.create``, such as
+        ``budget`` (a spend ceiling for the session). A session that stops 
against its
+        budget raises
+        
:class:`~airflow.providers.anthropic.exceptions.AnthropicSessionBudgetExceeded`.
+        The ceiling is a stop trigger rather than a cap -- it is checked 
between model
+        requests, so an in-flight request can carry the session past it. 
Airflow
+        ``retries`` also each start a new session with a fresh budget, so 
prefer
+        ``retries=0`` when a budget is set.
     """
 
     template_fields: Sequence[str] = ("agent_id", "environment_id", "message", 
"outcome")
@@ -202,7 +213,7 @@ class AnthropicAgentSessionOperator(BaseOperator):
             # The trigger yields "error" when polling gives up while the 
session may still
             # be running; archive it best-effort so its container does not 
linger.
             self._archive_session(self.session_id)
-            raise AnthropicAgentSessionError(event["message"])
+            raise _create_session_error(event["message"], 
event.get("stop_reason"))
         self.log.info("Session %s completed.", self.session_id)
         return self.session_id
 
diff --git 
a/providers/anthropic/src/airflow/providers/anthropic/triggers/agent.py 
b/providers/anthropic/src/airflow/providers/anthropic/triggers/agent.py
index 2045f4cc2d0..875f0c26538 100644
--- a/providers/anthropic/src/airflow/providers/anthropic/triggers/agent.py
+++ b/providers/anthropic/src/airflow/providers/anthropic/triggers/agent.py
@@ -94,7 +94,7 @@ class AnthropicAgentSessionTrigger(BaseTrigger):
         while True:
             try:
                 # poll_session_completion does blocking SDK HTTP calls; run 
off the event loop.
-                done, error_message = await asyncio.to_thread(
+                poll_result = await asyncio.to_thread(
                     hook.poll_session_completion,
                     self.session_id,
                     expect_outcome=self.expect_outcome,
@@ -111,10 +111,20 @@ class AnthropicAgentSessionTrigger(BaseTrigger):
                 continue
 
             consecutive_failures = 0
-            if done:
-                if error_message:
+            if poll_result.done:
+                if poll_result.error_message:
+                    # ``stop_reason`` is the SDK's idle stop reason; it lets 
the resuming
+                    # worker raise the same exception class the synchronous 
path would,
+                    # without matching on the message text. A trigger 
serialized before this
+                    # field existed simply omits it, and the operator falls 
back to the
+                    # generic session error.
                     yield TriggerEvent(
-                        {"status": "error", "session_id": self.session_id, 
"message": error_message}
+                        {
+                            "status": "error",
+                            "session_id": self.session_id,
+                            "message": poll_result.error_message,
+                            "stop_reason": poll_result.stop_reason,
+                        }
                     )
                 else:
                     yield TriggerEvent(
diff --git a/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py 
b/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
index 2fe3f6fe1f7..30ad61170b7 100644
--- a/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
+++ b/providers/anthropic/tests/unit/anthropic/hooks/test_anthropic.py
@@ -25,6 +25,7 @@ from airflow.providers.anthropic.exceptions import (
     AnthropicAgentSessionTimeout,
     AnthropicBatchTimeout,
     AnthropicError,
+    AnthropicSessionBudgetExceeded,
     AnthropicTriggerEventError,
 )
 from airflow.providers.anthropic.hooks.anthropic import (
@@ -32,7 +33,9 @@ from airflow.providers.anthropic.hooks.anthropic import (
     MAX_CONSECUTIVE_POLL_FAILURES,
     AnthropicHook,
     BatchStatus,
+    SessionPollResult,
     SessionStatus,
+    _create_session_error,
     evaluate_session_state,
     validate_execute_complete_event,
 )
@@ -40,6 +43,9 @@ from airflow.providers.anthropic.hooks.anthropic import (
 pytest.importorskip("anthropic")
 
 HOOK_PATH = "airflow.providers.anthropic.hooks.anthropic"
+# One id for both the mocked session object and the id passed to the hook, so 
an assertion
+# on a message that interpolates the session id cannot pass against the wrong 
one.
+SESSION_ID = "sess_1"
 
 
 def _conn(password="sk-ant-test", host=None, extra=None):
@@ -108,7 +114,7 @@ class TestValidateTriggerEvent:
 def _session(status, outcome_results=None):
     s = mock.MagicMock()
     s.status = status
-    s.id = "sess_1"
+    s.id = SESSION_ID
     s.outcome_evaluations = [mock.MagicMock(result=r) for r in 
(outcome_results or [])]
     return s
 
@@ -156,39 +162,80 @@ class TestPollSessionCompletion:
     def test_terminated_is_error(self):
         hook, client = _make_hook()
         client.beta.sessions.retrieve.return_value = _session("terminated")
-        done, err = hook.poll_session_completion("s")
-        assert done is True
-        assert err is not None
+        poll_result = hook.poll_session_completion(SESSION_ID)
+        assert poll_result.done is True
+        assert poll_result.error_message is not None
+        assert poll_result.stop_reason is None
 
     def test_message_end_turn_success(self):
         hook, client = _make_hook()
         client.beta.sessions.retrieve.return_value = _session("idle")
         client.beta.sessions.events.list.return_value = 
[_idle_event("end_turn")]
-        assert hook.poll_session_completion("s", kickoff_event_id="evt_kick") 
== (True, None)
+        assert hook.poll_session_completion(SESSION_ID, 
kickoff_event_id="evt_kick") == SessionPollResult(
+            done=True, error_message=None, stop_reason="end_turn"
+        )
 
     @pytest.mark.parametrize("reason", ["requires_action", 
"retries_exhausted"])
     def test_message_blocked_is_error(self, reason):
         hook, client = _make_hook()
         client.beta.sessions.retrieve.return_value = _session("idle")
         client.beta.sessions.events.list.return_value = [_idle_event(reason)]
-        done, err = hook.poll_session_completion("s", 
kickoff_event_id="evt_kick")
-        assert done is True
-        assert err is not None
-        assert reason in err
+        poll_result = hook.poll_session_completion(SESSION_ID, 
kickoff_event_id="evt_kick")
+        assert poll_result == SessionPollResult(done=True, 
error_message=mock.ANY, stop_reason=reason)
+        assert reason in poll_result.error_message
 
     def test_message_no_response_yet_not_done(self):
         # newest event is our kickoff (agent hasn't responded) -> keep waiting 
(start race)
         hook, client = _make_hook()
         client.beta.sessions.retrieve.return_value = _session("idle")
-        client.beta.sessions.events.list.return_value = 
[mock.MagicMock(type="user.message", id="evt_kick")]
-        assert hook.poll_session_completion("s", kickoff_event_id="evt_kick") 
== (False, None)
+        # Stub event: the SDK's event models are a discriminated union, so 
there is no single
+        # class to spec against -- only ``type`` and ``id`` are read by the 
code under test.
+        kickoff = mock.MagicMock(type="user.message", id="evt_kick")  # noqa: 
spec
+        client.beta.sessions.events.list.return_value = [kickoff]
+        assert hook.poll_session_completion(SESSION_ID, 
kickoff_event_id="evt_kick") == SessionPollResult(
+            done=False, error_message=None, stop_reason=None
+        )
 
     def test_outcome_satisfied_skips_event_check(self):
         hook, client = _make_hook()
         client.beta.sessions.retrieve.return_value = _session("idle", 
["satisfied"])
-        assert hook.poll_session_completion("s", expect_outcome=True) == 
(True, None)
+        assert hook.poll_session_completion(SESSION_ID, expect_outcome=True) 
== SessionPollResult(
+            done=True, error_message=None, stop_reason=None
+        )
         client.beta.sessions.events.list.assert_not_called()
 
+    def test_budget_reached_names_both_causes(self):
+        # A budget stop must not be reported as "configure an autonomous 
agent": that advice
+        # is wrong, and the no-list-price cause is invisible without being 
named.
+        hook, client = _make_hook()
+        client.beta.sessions.retrieve.return_value = _session("idle")
+        client.beta.sessions.events.list.return_value = 
[_idle_event("budget_reached")]
+        poll_result = hook.poll_session_completion(SESSION_ID, 
kickoff_event_id="evt_kick")
+        assert poll_result == SessionPollResult(
+            done=True, error_message=mock.ANY, stop_reason="budget_reached"
+        )
+        for named_cause in ("budget", "no list price"):
+            assert named_cause in poll_result.error_message
+        # The inverse matters just as much: the generic advice must not appear 
here.
+        assert "autonomous agent" not in poll_result.error_message
+
+
+class TestCreateSessionError:
+    def test_budget_reached_maps_to_budget_exception(self):
+        err = _create_session_error("over budget", "budget_reached")
+        assert isinstance(err, AnthropicSessionBudgetExceeded)
+        assert str(err) == "over budget"
+
+    @pytest.mark.parametrize("stop_reason", [None, "requires_action", 
"retries_exhausted", "end_turn"])
+    def test_other_reasons_map_to_generic_session_error(self, stop_reason):
+        err = _create_session_error("boom", stop_reason)
+        assert isinstance(err, AnthropicAgentSessionError)
+        assert not isinstance(err, AnthropicSessionBudgetExceeded)
+
+    def test_budget_exception_is_catchable_as_session_error(self):
+        # Existing `except AnthropicAgentSessionError` handlers must keep 
catching it.
+        assert isinstance(_create_session_error("over budget", 
"budget_reached"), AnthropicAgentSessionError)
+
 
 class TestDefaultModel:
     def test_defaults_to_constant(self):
@@ -304,7 +351,10 @@ class TestWaitForSession:
     @mock.patch.object(AnthropicHook, "get_connection")
     def test_returns_when_done(self, mock_get_connection, mock_poll, 
mock_sleep):
         mock_get_connection.return_value = _conn()
-        mock_poll.side_effect = [(False, None), (True, None)]
+        mock_poll.side_effect = [
+            SessionPollResult(done=False, error_message=None, 
stop_reason=None),
+            SessionPollResult(done=True, error_message=None, 
stop_reason="end_turn"),
+        ]
         AnthropicHook().wait_for_session("sess_1", poll_interval=0.01)
         assert mock_poll.call_count == 2
 
@@ -314,7 +364,7 @@ class TestWaitForSession:
     @mock.patch.object(AnthropicHook, "get_connection")
     def test_raises_on_timeout(self, mock_get_connection, mock_poll, 
mock_sleep, mock_monotonic):
         mock_get_connection.return_value = _conn()
-        mock_poll.return_value = (False, None)
+        mock_poll.return_value = SessionPollResult(done=False, 
error_message=None, stop_reason=None)
         mock_monotonic.side_effect = [0, 100]
         with pytest.raises(AnthropicAgentSessionTimeout, match="did not reach 
a terminal status"):
             AnthropicHook().wait_for_session("sess_1", poll_interval=0.01, 
timeout=10)
@@ -324,10 +374,25 @@ class TestWaitForSession:
     @mock.patch.object(AnthropicHook, "get_connection")
     def test_failure_raises(self, mock_get_connection, mock_poll, mock_sleep):
         mock_get_connection.return_value = _conn()
-        mock_poll.return_value = (True, "Outcome not satisfied for session 
sess_1: failed.")
+        mock_poll.return_value = SessionPollResult(
+            done=True, error_message="Outcome not satisfied for session 
sess_1: failed.", stop_reason=None
+        )
         with pytest.raises(AnthropicAgentSessionError, match="not satisfied"):
             AnthropicHook().wait_for_session("sess_1", expect_outcome=True, 
poll_interval=0.01)
 
+    @mock.patch(f"{HOOK_PATH}.time.sleep", autospec=True)
+    @mock.patch.object(AnthropicHook, "poll_session_completion", autospec=True)
+    @mock.patch.object(AnthropicHook, "get_connection", autospec=True)
+    def test_budget_stop_raises_budget_exception(self, mock_get_connection, 
mock_poll, mock_sleep):
+        mock_get_connection.return_value = _conn()
+        mock_poll.return_value = SessionPollResult(
+            done=True,
+            error_message="Session sess_1 stopped against its budget.",
+            stop_reason="budget_reached",
+        )
+        with pytest.raises(AnthropicSessionBudgetExceeded, match="budget"):
+            AnthropicHook().wait_for_session("sess_1", poll_interval=0.01)
+
 
 class TestAnthropicHookGetConn:
     @mock.patch(f"{HOOK_PATH}.Anthropic")
diff --git a/providers/anthropic/tests/unit/anthropic/operators/test_agent.py 
b/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
index 3c4f595ad51..d269b1bfc2d 100644
--- a/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
+++ b/providers/anthropic/tests/unit/anthropic/operators/test_agent.py
@@ -24,6 +24,7 @@ from airflow.exceptions import TaskDeferred
 from airflow.providers.anthropic.exceptions import (
     AnthropicAgentSessionError,
     AnthropicAgentSessionTimeout,
+    AnthropicSessionBudgetExceeded,
     AnthropicTriggerEventError,
 )
 from airflow.providers.anthropic.hooks.anthropic import AnthropicHook
@@ -214,6 +215,35 @@ class TestExecuteComplete:
             op.execute_complete({}, {"status": "error", "session_id": "s", 
"message": "boom"})
         hook.archive_session.assert_called_once_with("s")
 
+    @mock.patch.object(AnthropicAgentSessionOperator, "hook", 
new_callable=mock.PropertyMock)
+    def test_budget_stop_raises_budget_exception(self, mock_hook_prop):
+        # The deferrable path must raise the same class the synchronous path 
does; the
+        # trigger event's stop_reason is the only classification channel 
across the boundary.
+        hook = mock.MagicMock(spec=AnthropicHook)
+        mock_hook_prop.return_value = hook
+        op = AnthropicAgentSessionOperator(task_id="a", agent_id="ag", 
environment_id="env", message="hi")
+        with pytest.raises(AnthropicSessionBudgetExceeded, match="over 
budget"):
+            op.execute_complete(
+                {},
+                {
+                    "status": "error",
+                    "session_id": "s",
+                    "message": "over budget",
+                    "stop_reason": "budget_reached",
+                },
+            )
+        hook.archive_session.assert_called_once_with("s")
+
+    @mock.patch.object(AnthropicAgentSessionOperator, "hook", 
new_callable=mock.PropertyMock)
+    def test_error_event_without_stop_reason_raises_generic(self, 
mock_hook_prop):
+        # Version skew: a trigger serialized before stop_reason existed omits 
the key.
+        hook = mock.MagicMock(spec=AnthropicHook)
+        mock_hook_prop.return_value = hook
+        op = AnthropicAgentSessionOperator(task_id="a", agent_id="ag", 
environment_id="env", message="hi")
+        with pytest.raises(AnthropicAgentSessionError) as exc:
+            op.execute_complete({}, {"status": "error", "session_id": "s", 
"message": "boom"})
+        assert not isinstance(exc.value, AnthropicSessionBudgetExceeded)
+
     @mock.patch.object(AnthropicAgentSessionOperator, "hook", 
new_callable=mock.PropertyMock)
     def test_timeout_archives_and_raises(self, mock_hook_prop):
         hook = mock.MagicMock(spec=AnthropicHook)
diff --git a/providers/anthropic/tests/unit/anthropic/test_exceptions.py 
b/providers/anthropic/tests/unit/anthropic/test_exceptions.py
index 365ba875eb4..bce1deea58b 100644
--- a/providers/anthropic/tests/unit/anthropic/test_exceptions.py
+++ b/providers/anthropic/tests/unit/anthropic/test_exceptions.py
@@ -24,6 +24,7 @@ from airflow.providers.anthropic.exceptions import (
     AnthropicBatchJobError,
     AnthropicBatchTimeout,
     AnthropicError,
+    AnthropicSessionBudgetExceeded,
 )
 
 
@@ -35,6 +36,7 @@ from airflow.providers.anthropic.exceptions import (
         AnthropicBatchTimeout,
         AnthropicAgentSessionError,
         AnthropicAgentSessionTimeout,
+        AnthropicSessionBudgetExceeded,
     ],
 )
 def test_provider_errors_share_base_and_are_not_airflow_exceptions(exc):
diff --git a/providers/anthropic/tests/unit/anthropic/triggers/test_agent.py 
b/providers/anthropic/tests/unit/anthropic/triggers/test_agent.py
index 6e01b4259b8..aa5df4b105e 100644
--- a/providers/anthropic/tests/unit/anthropic/triggers/test_agent.py
+++ b/providers/anthropic/tests/unit/anthropic/triggers/test_agent.py
@@ -21,6 +21,7 @@ from unittest import mock
 
 import pytest
 
+from airflow.providers.anthropic.hooks.anthropic import SessionPollResult
 from airflow.providers.anthropic.triggers.agent import 
AnthropicAgentSessionTrigger
 from airflow.triggers.base import TriggerEvent
 
@@ -29,6 +30,9 @@ pytest.importorskip("anthropic")
 TRIGGER_PATH = "airflow.providers.anthropic.triggers.agent"
 POLL = 
"airflow.providers.anthropic.hooks.anthropic.AnthropicHook.poll_session_completion"
 
+STILL_RUNNING = SessionPollResult(done=False, error_message=None, 
stop_reason=None)
+COMPLETED = SessionPollResult(done=True, error_message=None, 
stop_reason="end_turn")
+
 
 def _trigger(end_time=None, expect_outcome=False):
     return AnthropicAgentSessionTrigger(
@@ -66,7 +70,7 @@ async def test_on_kill_archives_session(mock_hook_cls):
 @pytest.mark.asyncio
 @mock.patch(POLL)
 async def test_done_success_yields_success(mock_poll):
-    mock_poll.return_value = (True, None)
+    mock_poll.return_value = COMPLETED
     event = await _trigger().run().__anext__()
     assert event.payload["status"] == "success"
     assert event.payload["session_id"] == "sess_1"
@@ -75,7 +79,9 @@ async def test_done_success_yields_success(mock_poll):
 @pytest.mark.asyncio
 @mock.patch(POLL)
 async def test_done_error_yields_error(mock_poll):
-    mock_poll.return_value = (True, "Session sess_1 terminated.")
+    mock_poll.return_value = SessionPollResult(
+        done=True, error_message="Session sess_1 terminated.", stop_reason=None
+    )
     event = await _trigger().run().__anext__()
     assert event.payload["status"] == "error"
     assert "terminated" in event.payload["message"]
@@ -84,7 +90,7 @@ async def test_done_error_yields_error(mock_poll):
 @pytest.mark.asyncio
 @mock.patch(POLL)
 async def test_timeout_yields_timeout(mock_poll):
-    mock_poll.return_value = (False, None)
+    mock_poll.return_value = STILL_RUNNING
     event = await _trigger(end_time=time.time() - 1).run().__anext__()
     assert event.payload["status"] == "timeout"
 
@@ -93,7 +99,7 @@ async def test_timeout_yields_timeout(mock_poll):
 @mock.patch(f"{TRIGGER_PATH}.asyncio.sleep")
 @mock.patch(POLL)
 async def test_polls_until_done(mock_poll, mock_sleep):
-    mock_poll.side_effect = [(False, None), (False, None), (True, None)]
+    mock_poll.side_effect = [STILL_RUNNING, STILL_RUNNING, COMPLETED]
     event = await _trigger().run().__anext__()
     assert event.payload["status"] == "success"
     assert mock_poll.call_count == 3
@@ -114,15 +120,34 @@ async def 
test_persistent_error_yields_error_after_retries(mock_poll, mock_sleep
 @mock.patch(f"{TRIGGER_PATH}.asyncio.sleep")
 @mock.patch(POLL)
 async def test_transient_error_then_success(mock_poll, mock_sleep):
-    mock_poll.side_effect = [RuntimeError("blip"), (True, None)]
+    mock_poll.side_effect = [RuntimeError("blip"), COMPLETED]
     event = await _trigger().run().__anext__()
     assert event.payload["status"] == "success"
 
 
[email protected]
[email protected](POLL, autospec=True)
+async def test_budget_stop_carries_stop_reason(mock_poll):
+    # The resuming worker needs the reason to raise 
AnthropicSessionBudgetExceeded rather
+    # than the generic session error; the message text is not a classification 
channel.
+    mock_poll.return_value = SessionPollResult(
+        done=True,
+        error_message="Session sess_1 stopped against its budget.",
+        stop_reason="budget_reached",
+    )
+    event = await _trigger().run().__anext__()
+    assert event.payload["status"] == "error"
+    assert event.payload["stop_reason"] == "budget_reached"
+
+
 @pytest.mark.asyncio
 @mock.patch(POLL)
 async def test_outcome_failure_yields_error(mock_poll):
-    mock_poll.return_value = (True, "Outcome not satisfied for session sess_1: 
max_iterations_reached.")
+    mock_poll.return_value = SessionPollResult(
+        done=True,
+        error_message="Outcome not satisfied for session sess_1: 
max_iterations_reached.",
+        stop_reason=None,
+    )
     event = await _trigger(expect_outcome=True).run().__anext__()
     assert event.payload["status"] == "error"
     assert "max_iterations_reached" in event.payload["message"]
diff --git a/uv.lock b/uv.lock
index 940a928dcab..a5ea95aa159 100644
--- a/uv.lock
+++ b/uv.lock
@@ -932,7 +932,7 @@ wheels = [
 
 [[package]]
 name = "anthropic"
-version = "0.117.0"
+version = "0.121.0"
 source = { registry = "https://pypi.org/simple"; }
 dependencies = [
     { name = "anyio" },
@@ -944,9 +944,9 @@ dependencies = [
     { name = "sniffio" },
     { name = "typing-extensions" },
 ]
-sdist = { url = 
"https://files.pythonhosted.org/packages/41/0d/8f71d535edb0d438f023bd825fb65f67c14fa88a2bd6b75f292a58a63de4/anthropic-0.117.0.tar.gz";,
 hash = 
"sha256:98107f2b76439641e0ae2a1754087534b8f178dbab99d6eb1bc4b7bc8c744496", size 
= 989933, upload-time = "2026-07-16T19:36:13.07Z" }
+sdist = { url = 
"https://files.pythonhosted.org/packages/0f/ca/3cb2c20ee729736fbd4546d5d8b67e818288529fe70cb7a80dbf80aef70b/anthropic-0.121.0.tar.gz";,
 hash = 
"sha256:e79d6e08ab3376602fc9a70d4d5ea3540817c76cf7e16658bed790834e1833d6", size 
= 1013292, upload-time = "2026-08-07T17:11:07.241Z" }
 wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/b7/4c/917d21d6619a4475cdafc6d13a69fdb3b901ddac57e76caca5a25c117b6d/anthropic-0.117.0-py3-none-any.whl";,
 hash = 
"sha256:451a0a6905f11dff7663d13e4ee5dbf909eb8942b1d049803c7b937a13ac47ec", size 
= 998327, upload-time = "2026-07-16T19:36:11.225Z" },
+    { url = 
"https://files.pythonhosted.org/packages/fa/91/b3d41643f1f639927e8c5fb02c3bd8bffe6f1f29e219b3bd4c61e267b15c/anthropic-0.121.0-py3-none-any.whl";,
 hash = 
"sha256:6048713fa441e59e1cba8363171cd2a86273b25bd213e9c7ac70a523af88b011", size 
= 1035493, upload-time = "2026-08-07T17:11:08.508Z" },
 ]
 
 [package.optional-dependencies]
@@ -3255,10 +3255,10 @@ docs = [
 
 [package.metadata]
 requires-dist = [
-    { name = "anthropic", specifier = ">=0.101.0" },
-    { name = "anthropic", extras = ["aws"], marker = "extra == 'aws'", 
specifier = ">=0.101.0" },
-    { name = "anthropic", extras = ["bedrock"], marker = "extra == 'bedrock'", 
specifier = ">=0.101.0" },
-    { name = "anthropic", extras = ["vertex"], marker = "extra == 'vertex'", 
specifier = ">=0.101.0" },
+    { name = "anthropic", specifier = ">=0.121.0" },
+    { name = "anthropic", extras = ["aws"], marker = "extra == 'aws'", 
specifier = ">=0.121.0" },
+    { name = "anthropic", extras = ["bedrock"], marker = "extra == 'bedrock'", 
specifier = ">=0.121.0" },
+    { name = "anthropic", extras = ["vertex"], marker = "extra == 'vertex'", 
specifier = ">=0.121.0" },
     { name = "apache-airflow", editable = "." },
     { name = "apache-airflow-providers-common-compat", editable = 
"providers/common/compat" },
 ]

Reply via email to