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

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4eda1ee76ea Make Common AI example Dags reach the behaviour they show 
(#74901)
4eda1ee76ea is described below

commit 4eda1ee76eab10f09f36a57005b1dd18eb18103c
Author: Kaxil Naik <[email protected]>
AuthorDate: Sat Oct 17 17:51:50 2026 +0100

    Make Common AI example Dags reach the behaviour they show (#74901)
---
 providers/common/ai/docs/frameworks/anthropic.rst  |  4 +-
 providers/common/ai/docs/retry_policies.rst        |  4 +-
 .../common/ai/example_dags/example_adk_agent.py    |  8 ++--
 .../ai/example_dags/example_agent_tool_approval.py |  2 +-
 .../example_dags/example_anthropic_tool_runner.py  |  7 +++-
 .../ai/example_dags/example_llm_retry_policy.py    | 19 +++++++--
 .../ai/example_dags/example_strands_agent.py       |  6 +--
 .../airflow/providers/common/ai/policies/retry.py  |  5 ++-
 .../ai/tests/unit/common/ai/policies/test_retry.py | 45 ++++++++++++++++++----
 9 files changed, 76 insertions(+), 24 deletions(-)

diff --git a/providers/common/ai/docs/frameworks/anthropic.rst 
b/providers/common/ai/docs/frameworks/anthropic.rst
index 214ee136a8d..11b7bd35186 100644
--- a/providers/common/ai/docs/frameworks/anthropic.rst
+++ b/providers/common/ai/docs/frameworks/anthropic.rst
@@ -68,7 +68,9 @@ the runner still hands an exception from one of your own 
tools to the model. A r
 reaches ``max_iterations`` while the model is still calling tools has no 
answer, so
 ``tools.run`` raises
 :class:`~airflow.providers.common.ai.exceptions.ToolRunnerMaxIterationsError` 
and the
-task fails rather than returning an empty result.
+task fails rather than returning an empty result. A refusal has no answer 
either, but
+``tools.run`` returns it like any final message, so the task above checks for
+``stop_reason == "refusal"`` and raises, which fails the task.
 
 To reach Claude on Amazon Bedrock, Claude Platform on AWS, Google Vertex AI or 
Microsoft
 Foundry, build the
diff --git a/providers/common/ai/docs/retry_policies.rst 
b/providers/common/ai/docs/retry_policies.rst
index 183657e740b..e8eeab44a46 100644
--- a/providers/common/ai/docs/retry_policies.rst
+++ b/providers/common/ai/docs/retry_policies.rst
@@ -326,7 +326,7 @@ Escalating to an LLM
 A classifier is cheap and fast, and a text model can reason about a failure it
 has never seen a category for. ``fallback_policy`` puts one behind the other.
 ``snowflake_policy`` is the classifier policy from the previous section; the
-chain reuses its category table:
+chain reuses its category table with a stricter bar on ``permanent``:
 
 .. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
     :language: python
@@ -398,7 +398,7 @@ Beyond your ``instructions``, it sees only the exception's 
class name and
 message (or, with ``include_traceback=True``, the formatted traceback; either
 after redaction and truncation), how many attempts are left,
 and, under ``ClassifierRetryPolicy``, the category names and descriptions. The 
prompt says
-``attempt {try_number} of {max_tries}``, so the model knows the limit and not
+``attempt 2 of 4`` on the second try of a task with ``retries=3``, so the 
model knows the limit and not
 just where it is right now; an instruction like "after two attempts treat an
 expired token as ``auth`` rather than ``transient``" works because the model
 can see which attempt this is.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_adk_agent.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_adk_agent.py
index fc4bcf888d9..aef76608f44 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_adk_agent.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_adk_agent.py
@@ -25,10 +25,12 @@ results, and with Airflow's secret masker applied to every 
tool result.
 
 Before running:
 
-1. Install ADK: ``pip install "google-adk>=2.9.1"``, without Airflow's 
constraints file.
+1. Install ADK and this provider's ``anthropic`` and ``sql`` extras, without 
Airflow's
+   constraints file:
+   ``pip install "google-adk>=2.9.1" 
"apache-airflow-providers-common-ai[anthropic,sql]"``.
    ADK caps OpenTelemetry at 1.42.1, below the version that file pins; Airflow 
itself
-   accepts it. The Anthropic model below also needs ``anthropic``, which the
-   ``anthropic`` extra of this provider installs.
+   accepts it. The Anthropic model below needs ``anthropic`` and 
``SQLToolset`` needs
+   ``sqlglot``.
 2. Create a connection (``LLM_CONN_ID``, default ``anthropic_default``) whose
    password is an Anthropic API key. Set its host only to route through a 
gateway.
 3. Create a database connection (``DB_CONN_ID``, default ``sql_default``) whose
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_approval.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_approval.py
index 4edcef5a9ca..2ca8f8a6e75 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_approval.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_tool_approval.py
@@ -29,7 +29,7 @@ from airflow.providers.common.compat.sdk import dag
 # [START howto_agent_tool_approval]
 def lookup_order(order_id: int) -> str:
     """Look up an order."""
-    return f"order {order_id}: $42, delivered"
+    return f"order {order_id}: $42, carrier marked it lost"
 
 
 def refund_order(order_id: int) -> str:
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
index cebaeab96ef..499697cc456 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_anthropic_tool_runner.py
@@ -25,8 +25,8 @@ masker applied to every result.
 
 Before running:
 
-1. Install this provider's ``anthropic`` extra:
-   ``pip install "apache-airflow-providers-common-ai[anthropic]"``.
+1. Install this provider's ``anthropic`` and ``sql`` extras:
+   ``pip install "apache-airflow-providers-common-ai[anthropic,sql]"``.
 2. Create a connection (``LLM_CONN_ID``, default ``anthropic_default``) whose 
password is
    an Anthropic API key. Set its host only to route through a gateway.
 3. Create a database connection (``DB_CONN_ID``, default ``sql_default``) 
whose hook is a
@@ -71,6 +71,9 @@ def example_anthropic_tool_runner():
             max_iterations=10,
         )
         message = tools.run(runner)
+        # A refusal ends the run with no answer, so fail the task rather than 
return "".
+        if message.stop_reason == "refusal":
+            raise RuntimeError(f"The model refused to answer: {question!r}")
         return "".join(block.text for block in message.content if block.type 
== "text")
 
     ask_the_warehouse()
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
index a2dd6a52444..8a9f17ce845 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_llm_retry_policy.py
@@ -102,7 +102,8 @@ try:
     # demands more certainty than the rest. Under a bar the answer is 
discarded and the
     # fallback rules, then the task's own retry settings, decide instead. The 
bars come from
     # a calibration run on jev-1.13.0: correct picks landed at 0.89 and above, 
wrong ones
-    # at 0.47 to 0.69, with one wrong ``permanent`` at 0.90 that no sensible 
bar catches.
+    # at 0.47 to 0.69, with one wrong ``permanent`` at 0.90 that no bar 
catches here without
+    # also discarding correct picks.
     snowflake_policy = ClassifierRetryPolicy(
         llm_conn_id="decision_default",
         min_confidence=0.8,
@@ -136,11 +137,17 @@ try:
     # [START howto_retry_policy_escalation]
     # The three layers chained. The decision model answers the clear cases 
cheaply. When it is
     # unsure or unreachable, a text model reasons about the failure and 
decides retry and delay
-    # itself. When that model is unreachable too, the rules decide.
+    # itself. When that model is unreachable too, the rules decide. With a 
text model to ask, a
+    # wrong ``permanent`` costs more than an escalation, so its bar goes up to 
0.95. That catches
+    # the wrong ``permanent`` at 0.90 from the calibration run, and a correct 
pick under 0.95
+    # costs one text-model call rather than the task.
     escalating_policy = ClassifierRetryPolicy(
         llm_conn_id="decision_default",
         min_confidence=0.8,
-        categories=snowflake_policy.categories,
+        categories={
+            **snowflake_policy.categories,
+            "permanent": replace(snowflake_policy.categories["permanent"], 
min_confidence=0.95),
+        },
         fallback_policy=LLMRetryPolicy(llm_conn_id="pydanticai_default", 
timeout=30.0),
         fallback_rules=[
             RetryRule(exception=ConnectionError, action=RetryAction.RETRY, 
retry_delay=timedelta(seconds=30)),
@@ -162,7 +169,11 @@ try:
 
         @task(retries=3, retry_delay=timedelta(minutes=1), 
retry_policy=escalating_policy)
         def task_ambiguous():
-            """Reads as more than one category; under the bar the text model 
decides, then the rules."""
+            """
+            Names no symptom, so the classifier is unsure and the text model 
decides.
+
+            jev-1.13.0 picks ``permanent`` for it at about 0.8 to 0.9, under 
that category's 0.95 bar here.
+            """
             raise RuntimeError("Query failed: an unexpected error occurred 
while processing the request.")
 
         task_warehouse_suspended()
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_agent.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_agent.py
index faebd874dba..c7a5cb1f07b 100644
--- 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_agent.py
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_strands_agent.py
@@ -26,9 +26,9 @@ Airflow's secret masker applied to every tool result.
 
 Before running:
 
-1. Install Strands: ``pip install "strands-agents>=1.56.0"``. The Anthropic 
model
-   below also needs ``anthropic``, which the ``anthropic`` extra of this 
provider
-   installs.
+1. Install Strands and this provider's ``anthropic`` and ``sql`` extras:
+   ``pip install "strands-agents>=1.56.0" 
"apache-airflow-providers-common-ai[anthropic,sql]"``.
+   The Anthropic model below needs ``anthropic`` and ``SQLToolset`` needs 
``sqlglot``.
 2. Create a connection (``LLM_CONN_ID``, default ``anthropic_default``) whose
    password is an Anthropic API key. Set its host only to route through a 
gateway.
 3. Create a database connection (``DB_CONN_ID``, default ``sql_default``) whose
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py 
b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
index 06768fe522c..637679b1177 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/policies/retry.py
@@ -335,8 +335,11 @@ class _ModelRetryPolicy(RetryPolicy):
                 text = f"{text[: self.max_exception_length]}... (truncated)"
         if not self.include_traceback:
             text = f"{type(exception).__name__}: {text}"
+        # ``max_tries`` is the last try number that may still retry (core adds 
to it on a clear),
+        # so the final attempt is ``max_tries + 1``, as core logs "Starting 
attempt N of M".
         return (
-            f"Classify this error from a data pipeline task (attempt 
{try_number} of {max_tries}):\n\n{text}"
+            "Classify this error from a data pipeline task "
+            f"(attempt {try_number} of {max_tries + 1}):\n\n{text}"
         )
 
     def _run(
diff --git a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py 
b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
index e4c25f3e76e..254f4105091 100644
--- a/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
+++ b/providers/common/ai/tests/unit/common/ai/policies/test_retry.py
@@ -473,9 +473,9 @@ class TestClassification:
         assert (first.action, second.action) == (RetryAction.RETRY, 
RetryAction.FAIL)
         prompts = [call.args[0] for call in agent.run_sync.call_args_list]
         assert "ConnectionError: reset" in prompts[0]
-        assert "attempt 1 of 3" in prompts[0]
+        assert "attempt 1 of 4" in prompts[0]
         assert "PermissionError: expired" in prompts[1]
-        assert "attempt 2 of 3" in prompts[1]
+        assert "attempt 2 of 4" in prompts[1]
 
     @patch(HOOK, autospec=True)
     def test_custom_instructions_forwarded_to_agent(self, mock_hook_cls):
@@ -661,7 +661,7 @@ class TestConfidenceGate:
         assert decision.reason == "category=auth confidence=0.05 threshold=n/a 
action=fail"
 
 
-PROMPT_HEADER = "Classify this error from a data pipeline task (attempt 2 of 
4):\n\n"
+PROMPT_HEADER = "Classify this error from a data pipeline task (attempt 2 of 
5):\n\n"
 
 POLICY_CLASSES = [
     pytest.param(ClassifierRetryPolicy, id="classifier"),
@@ -692,13 +692,15 @@ def _truncated_read(*, explicit: bool = True) -> 
Exception:
     )
 
 
-def _prompt_for(mock_hook_cls, policy_cls, exception, **kwargs) -> str:
+def _prompt_for(
+    mock_hook_cls, policy_cls, exception, *, try_number: int = 2, max_tries: 
int = 4, **kwargs
+) -> str:
     """Evaluate ``exception`` under a ``policy_cls`` built with ``kwargs`` and 
return the prompt the model got."""
     if policy_cls is ClassifierRetryPolicy:
         agent = _install(mock_hook_cls, _agent("data"))
     else:
         agent = _install(mock_hook_cls, _open_agent("data", 
should_retry=False))
-    policy_cls(llm_conn_id="test", **kwargs).evaluate(exception, try_number=2, 
max_tries=4)
+    policy_cls(llm_conn_id="test", **kwargs).evaluate(exception, 
try_number=try_number, max_tries=max_tries)
     return agent.run_sync.call_args.args[0]
 
 
@@ -712,7 +714,36 @@ class TestPrompt:
 
         prompt = agent.run_sync.call_args.args[0]
         assert "ValueError: bad column type" in prompt
-        assert "attempt 2 of 5" in prompt
+        assert "attempt 2 of 6" in prompt
+
+    @pytest.mark.parametrize(
+        ("try_number", "max_tries", "expected"),
+        [
+            pytest.param(1, 1, "attempt 1 of 2", 
id="first-try-with-one-retry-left"),
+            pytest.param(2, 1, "attempt 2 of 2", id="last-try"),
+            pytest.param(3, 3, "attempt 3 of 4", id="after-clear"),
+        ],
+    )
+    @pytest.mark.parametrize("policy_cls", POLICY_CLASSES)
+    @patch(HOOK, autospec=True)
+    def test_prompt_counts_the_first_try_in_the_attempt_total(
+        self, mock_hook_cls, policy_cls, try_number, max_tries, expected
+    ):
+        """
+        ``max_tries`` is the last try number that may still retry, so the 
final attempt is one more.
+
+        It equals ``retries`` on a fresh run; a clear adds the tries already 
used, so a ``retries=1``
+        task cleared after two tries runs try 3 with ``max_tries=3``.
+        """
+        prompt = _prompt_for(
+            mock_hook_cls,
+            policy_cls,
+            ValueError("bad column type"),
+            try_number=try_number,
+            max_tries=max_tries,
+        )
+
+        assert f"({expected})" in prompt
 
     @pytest.mark.enable_redact
     @patch(HOOK, autospec=True)
@@ -728,7 +759,7 @@ class TestPrompt:
         )
 
         assert agent.run_sync.call_args.args[0] == (
-            "Classify this error from a data pipeline task (attempt 1 of 
3):\n\n"
+            "Classify this error from a data pipeline task (attempt 1 of 
4):\n\n"
             "ConnectionError: could not authenticate with password ***"
         )
 

Reply via email to