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 57a4751c967 Add an LLM task loop example that stops on a dollar or 
token budget (#75516)
57a4751c967 is described below

commit 57a4751c9678432858662aa3223ff7688c07650f
Author: Kaxil Naik <[email protected]>
AuthorDate: Sat Oct 24 00:36:03 2026 +0100

    Add an LLM task loop example that stops on a dollar or token budget (#75516)
    
    The Common AI example Dag example_agent_loop_budget has an agent write a
    status page update and a second model score it, repeating both in a task
    loop until the score passes or the loop has spent a dollar or token budget.
    A terminal tally task sums each pass's usage XCom into loop.previous, and
    the gate returns a LoopDecision whose reason names the ending.
    
    A model with no price data turns the dollar cap off for the rest of the
    loop instead of counting as zero, so the token cap always applies.
    
    The new use-case page documents the four endings, the one-pass overshoot,
    estimated versus billed cost, unpriced models and retries. The core loops
    page links to it and states that an in-loop xcom_pull returns the current
    iteration's value.
    
    * Keep the loop example's judge rules on separate lines
    
    Move the judge's system prompt to a module-level JUDGE_INSTRUCTIONS
    constant, next to INCIDENT_NOTES, so the numbered rules reach the model
    as a list instead of one run-on line.
    
    * Let the loop example's budget stop the loop before max_iterations
    
    With max_iterations=5, the per-task usage limits capped the loop at 150,000
    tokens and $3.50, below its $5.00 and 500,000-token budget, so neither 
budget
    stop could fire. Raise max_iterations to 20 and recapture the converged and
    max_iterations runs the page shows.
    
    Quote the provider's own log lines for a tripped per-task limit instead of a
    line printed by the example event listener, and drop the use-case count from
    the docs index so it does not go stale.
---
 .../docs/authoring-and-scheduling/loops.rst        |  10 +-
 providers/common/ai/docs/examples.rst              |   4 +
 providers/common/ai/docs/index.rst                 |   2 +-
 providers/common/ai/docs/use_cases/index.rst       |   4 +
 .../use_cases/refine_a_draft_within_a_budget.rst   | 202 +++++++++++++++++++++
 .../ai/example_dags/example_agent_loop_budget.py   | 141 ++++++++++++++
 6 files changed, 359 insertions(+), 4 deletions(-)

diff --git a/airflow-core/docs/authoring-and-scheduling/loops.rst 
b/airflow-core/docs/authoring-and-scheduling/loops.rst
index 9e414563e59..42f45dc57ea 100644
--- a/airflow-core/docs/authoring-and-scheduling/loops.rst
+++ b/airflow-core/docs/authoring-and-scheduling/loops.rst
@@ -130,6 +130,9 @@ stopping.
 
 The ``accurate_enough`` condition in the ``refine_estimate`` example above 
returns a
 ``LoopDecision``, so each iteration of that loop shows its error next to its 
outcome.
+:doc:`Refine a draft within a budget 
<apache-airflow-providers-common-ai:use_cases/refine_a_draft_within_a_budget>`
+uses the same pattern for LLM calls, stopping with a reason when a judge 
passes the draft or when
+the loop has spent its dollar or token budget.
 
 Read the loop context
 =====================
@@ -270,9 +273,10 @@ gate's state. A trigger rule such as ``always`` does not 
wait for the loop and
 may see only the iterations that exist so far.
 
 Inside the loop nothing changes: use ``loop.result`` and ``loop.previous``. A
-task outside the loop cannot pull a single value from a loop task, because the
-task ran once for each iteration, and cannot use its output to expand a mapped
-task.
+``ti.xcom_pull`` of another task in the same loop returns that task's value 
from the
+current iteration. A task outside the loop cannot pull a single value from a 
loop
+task, because the task ran once for each iteration, and cannot use its output 
to
+expand a mapped task.
 
 .. _loops-mapped-tasks:
 
diff --git a/providers/common/ai/docs/examples.rst 
b/providers/common/ai/docs/examples.rst
index 0672b0d4b67..67015bec505 100644
--- a/providers/common/ai/docs/examples.rst
+++ b/providers/common/ai/docs/examples.rst
@@ -100,6 +100,10 @@ Agents & tools
      - A LangChain ReAct agent that decides its own tool calls, composed with 
``LLMOperator`` for
        report formatting and AIP-90 HITL review
        (`example_langchain_tool_agent.py 
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_langchain_tool_agent.py>`__).
+   * - :doc:`use_cases/refine_a_draft_within_a_budget`
+     - An agent and a judge in a task loop that stops when the draft passes or 
a dollar or token
+       budget is spent
+       (`example_agent_loop_budget.py 
<https://github.com/apache/airflow/blob/providers-common-ai/|version|/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_loop_budget.py>`__).
 
 Retrieval & document processing
 --------------------------------
diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index 63ced48e7b0..8b2322bca01 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -35,7 +35,7 @@ Start here
 - :doc:`features`: what the provider can do, with the page, extra, minimum 
Airflow
   version and stability of each feature.
 - :doc:`quickstart`: install, connect a vendor, run a two-task Dag and check 
its output.
-- :doc:`use_cases/index`: ten jobs a data team already has, each with the Dag 
that does it.
+- :doc:`use_cases/index`: jobs a data team already has, each with the Dag that 
does it.
 - :doc:`model_providers`: which vendors work, and the extra, connection and 
prefix for each.
 
 This is the Dag the quick start runs. The ``summarize`` task sends the release 
notes to the
diff --git a/providers/common/ai/docs/use_cases/index.rst 
b/providers/common/ai/docs/use_cases/index.rst
index 7793e4e4c5d..6da286663ea 100644
--- a/providers/common/ai/docs/use_cases/index.rst
+++ b/providers/common/ai/docs/use_cases/index.rst
@@ -70,6 +70,9 @@ exception: it reads a decision-model connection, 
``decision_default``, instead.
    * - :doc:`research_agent_with_review`
      - Decides which tools to call to answer a research question
      - Human edits the question first, separate formatting step, approval 
before delivery
+   * - :doc:`refine_a_draft_within_a_budget`
+     - Writes a status page update, then scores it and gives feedback
+     - Repeats both in a task loop until the score passes or a dollar or token 
budget is spent
 
 More ideas
 ----------
@@ -122,3 +125,4 @@ can be built from the patterns shown on the pages above.
     Weekly status report <weekly_status_report>
     Classify reviews in bulk <classify_reviews_in_bulk>
     Research agent with review <research_agent_with_review>
+    Refine a draft within a budget <refine_a_draft_within_a_budget>
diff --git 
a/providers/common/ai/docs/use_cases/refine_a_draft_within_a_budget.rst 
b/providers/common/ai/docs/use_cases/refine_a_draft_within_a_budget.rst
new file mode 100644
index 00000000000..c65f8a96ad4
--- /dev/null
+++ b/providers/common/ai/docs/use_cases/refine_a_draft_within_a_budget.rst
@@ -0,0 +1,202 @@
+ .. 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.
+
+Refine a draft within a budget
+==============================
+
+A model's first draft of a customer status page update is rarely the one you 
publish. This Dag
+has an agent write the update and a second model score it against a style 
guide. A
+:ref:`task loop <apache-airflow:loops>` repeats both until the score reaches 
0.9, and stops
+sooner once the loop as a whole has spent $5.00 or 500,000 tokens. Each pass 
shows in the UI with
+the reason the loop continued or stopped.
+
+The budget is checked between passes, so it limits how many passes run, not 
what one pass
+spends. To cap a pass, set ``usage_limits`` on each task.
+
+What this demonstrates
+----------------------
+
+* :ref:`Task loops <apache-airflow:loops>` -- ``refine.loop(max_iterations=20, 
until=good_enough)``
+  repeats the task group, and ``good_enough`` returns a ``LoopDecision`` with 
a reason.
+* The ``usage`` XCom that ``@task.agent`` and ``@task.llm`` push, read back 
inside the loop to
+  keep a running total of tokens and estimated cost.
+* :ref:`howto/operator:llm_usage_limits` on each task, which caps what one 
pass can spend.
+* :doc:`../structured_output` -- the judge's output is validated against 
``Score``.
+
+Run it
+------
+
+The Dag needs Airflow 3.4.0 or later for task loops, and Common AI 1.1.0 or 
later, the first
+release in which ``@task.llm`` pushes the ``usage`` XCom. Both tasks use the 
model set on the
+``pydanticai_default`` connection.
+
+1. Install the provider with the extra for your vendor:
+
+   .. code-block:: bash
+
+       pip install "apache-airflow-providers-common-ai[anthropic]>=1.1.0"
+
+2. Trigger the Dag:
+
+   .. code-block:: bash
+
+       airflow dags test example_agent_loop_budget
+
+The gate logs its decision for each iteration, and ``publish`` logs the 
result. From a run with
+``anthropic:claude-sonnet-4-6``:
+
+.. code-block:: text
+
+    Loop iteration 1: continue (max_iterations=20): score 0.5; $0.007317 of 
$5.00, 1,427 of 500,000 tokens
+    Loop iteration 2: continue (max_iterations=20): score 0.7; $0.016605 of 
$5.00, 3,131 of 500,000 tokens
+    Loop iteration 3: continue (max_iterations=20): score 0.5; $0.025290 of 
$5.00, 4,870 of 500,000 tokens
+    Loop iteration 4: continue (max_iterations=20): score 0.5; $0.033585 of 
$5.00, 6,543 of 500,000 tokens
+    Loop iteration 5: continue (max_iterations=20): score 0.5; $0.042198 of 
$5.00, 8,226 of 500,000 tokens
+    Loop iteration 6: continue (max_iterations=20): score 0.7; $0.051069 of 
$5.00, 9,919 of 500,000 tokens
+    Loop iteration 7: continue (max_iterations=20): score 0.5; $0.060381 of 
$5.00, 11,643 of 500,000 tokens
+    Loop iteration 8: continue (max_iterations=20): score 0.7; $0.069204 of 
$5.00, 13,376 of 500,000 tokens
+    Loop iteration 9: continue (max_iterations=20): score 0.5; $0.079605 of 
$5.00, 15,175 of 500,000 tokens
+    Loop iteration 10: continue (max_iterations=20): score 0.5; $0.087213 of 
$5.00, 16,883 of 500,000 tokens
+    Loop iteration 11: stop (max_iterations=20): passed: score 0.9; $0.096552 
of $5.00, 18,624 of 500,000 tokens
+    11 passes, score 0.9, 18624 tokens, $0.096552
+
+The Dag
+-------
+
+The whole loop, with the budget in ``tally`` and ``good_enough``:
+
+.. exampleinclude:: 
/../../ai/src/airflow/providers/common/ai/example_dags/example_agent_loop_budget.py
+    :language: python
+    :start-after: [START howto_loop_budget]
+    :end-before: [END howto_loop_budget]
+
+Each pass runs ``write_draft``, then ``judge``, then ``tally``. ``tally`` is 
the loop body's only
+terminal task, so what it returns is ``loop.result`` for the gate and 
``loop.previous`` for the
+next pass: the draft, the judge's feedback, and the running totals.
+
+Inside a loop, ``ti.xcom_pull(..., key="usage")`` returns the ``usage`` XCom 
of the current
+iteration only, so ``tally`` adds this pass's spend to the totals it finds in 
``loop.previous``.
+Money is added as ``Decimal`` and stored as a string in XCom, so the total 
does not pick up
+``float`` rounding.
+
+How the loop ends
+-----------------
+
+Every reason carries the score and both totals, and a reason that stops the 
loop starts with the
+ending:
+
+.. list-table::
+   :header-rows: 1
+   :widths: 22 40 38
+
+   * - Ending
+     - Reason on the last gate
+     - Result
+   * - Converged
+     - ``passed: score 0.9; $0.096552 of $5.00, 18,624 of 500,000 tokens``
+     - The gate succeeds and ``publish`` runs.
+   * - Dollar budget spent
+     - ``dollar budget spent: score 0.9; $0.016362 of $0.01, 3,226 of 500,000 
tokens``
+     - The gate succeeds and ``publish`` runs.
+   * - Token budget spent
+     - ``token budget spent: score 0.0; cost unknown, 745 of 600 tokens``
+     - The gate succeeds and ``publish`` runs.
+   * - ``max_iterations`` reached
+     - ``score 0.0; cost unknown, 4,995 of 500,000 tokens``
+     - The last gate fails and ``publish`` is ``upstream_failed``.
+
+The converged row is the run above. The dollar row ran the same models with a 
$0.01 cap and a
+threshold of 0.99, so the budget ran out first. The token and 
``max_iterations`` rows ran
+pydantic-ai's ``test`` model, which has no price, the token row with a 
600-token cap.
+
+Reaching ``max_iterations`` fails the last gate with 
``LoopMaxIterationsExceeded``, carrying the
+last reason:
+
+.. code-block:: text
+
+    Loop condition remained false at max_iterations=20: score 0.0; cost 
unknown, 4,995 of 500,000 tokens
+
+Stopping on a budget is a successful end of the loop, so ``publish`` runs and 
returns the last
+draft even though it did not pass. Check ``passes[-1]["score"]`` in 
``publish`` if a draft below
+the threshold should not go out.
+
+A per-task ``usage_limits`` that trips is not a budget stop. The task fails 
with
+``UsageLimitExceeded``, the gate does not run, and the loop fails. The task 
log shows what the
+call spent before it stopped:
+
+.. code-block:: text
+
+    LLM run failed: requests=0, tool_calls=0, input_tokens=118, 
output_tokens=4, total_tokens=122
+    Task failed with exception
+
+The exception reads ``Exceeded the total_tokens_limit of 100 
(total_tokens=122)``.
+
+Set each task's limits above what a normal pass spends, so they only catch a 
runaway call.
+
+What the totals count
+---------------------
+
+**Either cap can be overshot by one pass.** The pass that crosses a cap has 
already run when the
+gate checks it: a 600-token cap stopped at 745 after a pass of 250 tokens. The 
two tasks'
+``usage_limits`` bound one pass, give or take one model response per task, 
since each limit is
+checked after every response; the 122 tokens against a limit of 100 above are 
one such response.
+
+**The dollar total is an estimate from list prices.** ``cost`` in the 
``usage`` XCom is
+pydantic-ai's ``RunUsage.cost``, which `genai-prices 
<https://github.com/pydantic/genai-prices>`__
+computes from the token counts and the model name at the provider's list 
prices. The provider is
+matched by its API URL first and by its name after that, so a call through a 
gateway that
+``genai-prices`` does not know is still priced at the vendor's list rates. 
When a provider reports
+what it billed, as OpenRouter does, pydantic-ai keeps that amount in the 
response's
+``provider_details`` and the ``usage`` XCom still carries the estimate. Token 
counts are the ones
+the provider reported.
+
+**An unpriced model switches the dollar cap off.** A model ``genai-prices`` 
does not know, such
+as a private fine-tune, a model newer than your ``genai-prices`` data, or 
pydantic-ai's ``test``
+model, reports ``cost=None``. The first pass with such a call sets the loop's 
``usd`` to ``None``
+for the rest of the loop and logs a warning in ``tally``:
+
+.. code-block:: text
+
+    A model call had no price, so the dollar cap no longer applies to this 
loop.
+
+From then on the gate's reasons say ``cost unknown``, and only the token cap 
can stop the loop.
+One unpriced model is enough: with a priced writer and the unpriced ``test`` 
judge, ``usd`` was
+``None`` from the first pass and the loop ended on
+``token budget spent: score 0.0; cost unknown, 2,063 of 2,000 tokens``.
+
+The same call also leaves that task's own ``cost_limit`` unenforced. The task 
log warns and the
+task carries on:
+
+.. code-block:: text
+
+    A `cost_limit` is set but cannot be enforced because no cost was 
calculated for this run. ...
+
+Set a token cap even when the budget you care about is dollars. Counting the 
unknown cost as zero
+would report less than was spent, and with no priced model at all the dollar 
cap would never trip.
+
+**Spend from an earlier try of a task is not in the totals.** The ``usage`` 
XCom holds the last
+try's usage only. In a run where ``write_draft`` failed after its model call 
and passed on retry,
+each try used 127 tokens and the loop counted 127. A retry of ``write_draft`` 
or ``judge`` after a
+try that reached the model leaves that try's spend out of the totals.
+
+Adapting it
+-----------
+
+* Give the writer and the judge different models with ``model_id``, for 
example a cheaper judge.
+  Mixing a priced and an unpriced model switches the dollar cap off, as 
described above.
+* Raise ``max_iterations`` for harder drafts. Reaching it fails the loop, so 
keep it high enough
+  that the threshold or a budget normally ends the loop first.
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_loop_budget.py
 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_loop_budget.py
new file mode 100644
index 00000000000..1c8faef123f
--- /dev/null
+++ 
b/providers/common/ai/src/airflow/providers/common/ai/example_dags/example_agent_loop_budget.py
@@ -0,0 +1,141 @@
+# 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.
+"""Revise a draft until a judge passes it, within a dollar and a token budget, 
using a task loop."""
+
+from __future__ import annotations
+
+# [START howto_loop_budget]
+import logging
+from decimal import Decimal
+
+from pydantic import BaseModel, Field
+
+from airflow.sdk import LoopDecision, dag, task, task_group
+
+log = logging.getLogger(__name__)
+
+THRESHOLD = 0.9
+MAX_USD = Decimal("5.00")
+MAX_TOKENS = 500_000
+
+INCIDENT_NOTES = """\
+09:12 UTC p95 latency on the orders API jumps from 180 ms to 4 s.
+09:20 Checkout errors at 12% in eu-west. Other regions normal.
+09:41 Cause: a connection pool limit lowered in this morning's config deploy.
+09:55 Config rolled back. Error rate falling, 2% at 10:05.
+Next update at 10:30 UTC.
+"""
+
+JUDGE_INSTRUCTIONS = """\
+Score a customer status page update from 0 to 1 against this style guide, and 
give one
+sentence of feedback on what to fix. Score 1 only if every rule holds.
+
+1. Every fact matches the incident notes.
+2. The first sentence says which customers are affected and how.
+3. No internal terms: no metric names, config, deploys, connection pools or 
rollbacks.
+4. At most 80 words.
+5. It ends with the time of the next update.
+"""
+
+
+class Score(BaseModel):
+    """The judge's verdict on one draft."""
+
+    score: float = Field(ge=0, le=1)
+    feedback: str
+
+
+@dag(tags=["example"])
+def example_agent_loop_budget():
+    @task_group
+    def refine():
+        @task.agent(
+            llm_conn_id="pydanticai_default",
+            system_prompt="You write status page updates for customers. Plain 
words, no internal jargon.",
+            # Bounds one pass: the loop's own caps are only checked between 
passes.
+            usage_limits={"total_tokens_limit": 20_000, "cost_limit": "0.50"},
+        )
+        def write_draft(*, loop):
+            if loop.index == 1:
+                return f"Write a status page update from these incident 
notes:\n{INCIDENT_NOTES}"
+            return (
+                f"Revise this status page update.\nFeedback: 
{loop.previous['feedback']}\n"
+                f"Draft:\n{loop.previous['draft']}\nIncident 
notes:\n{INCIDENT_NOTES}"
+            )
+
+        @task.llm(
+            llm_conn_id="pydanticai_default",
+            output_type=Score,
+            # A dict in XCom, so ``tally`` can read it under ``airflow dags 
test`` too.
+            serialize_output=True,
+            system_prompt=JUDGE_INSTRUCTIONS,
+            usage_limits={"total_tokens_limit": 10_000, "cost_limit": "0.20"},
+        )
+        def judge(draft):
+            return f"Incident notes:\n{INCIDENT_NOTES}\nUpdate:\n{draft}"
+
+        @task
+        def tally(draft, verdict, *, loop, ti):
+            # Inside a loop, this pulls the usage of the current iteration 
only.
+            usage = ti.xcom_pull(task_ids=["refine.write_draft", 
"refine.judge"], key="usage")
+            spent = {"tokens": 0, "usd": "0"} if loop.index == 1 else 
loop.previous
+            tokens = spent["tokens"] + sum(u["total_tokens"] for u in usage)
+            costs = [u["cost"] for u in usage]
+            if spent["usd"] is None:
+                usd = None
+            elif None in costs:
+                # An unpriced call makes the dollar total unknown, not lower.
+                log.warning("A model call had no price, so the dollar cap no 
longer applies to this loop.")
+                usd = None
+            else:
+                usd = str(Decimal(spent["usd"]) + sum(map(Decimal, costs)))
+            return {
+                "draft": draft,
+                "score": verdict["score"],
+                "feedback": verdict["feedback"],
+                "tokens": tokens,
+                "usd": usd,
+            }
+
+        draft = write_draft()
+        tally(draft, judge(draft))
+
+    def good_enough(*, loop):
+        result = loop.result
+        usd = "cost unknown" if result["usd"] is None else f"${result['usd']} 
of ${MAX_USD}"
+        progress = f"score {result['score']}; {usd}, {result['tokens']:,} of 
{MAX_TOKENS:,} tokens"
+        if result["score"] >= THRESHOLD:
+            return LoopDecision(stop=True, reason=f"passed: {progress}")
+        if result["usd"] is not None and Decimal(result["usd"]) >= MAX_USD:
+            return LoopDecision(stop=True, reason=f"dollar budget spent: 
{progress}")
+        if result["tokens"] >= MAX_TOKENS:
+            return LoopDecision(stop=True, reason=f"token budget spent: 
{progress}")
+        return LoopDecision(stop=False, reason=progress)
+
+    @task
+    def publish(passes):
+        last = passes[-1]
+        usd = "cost unknown" if last["usd"] is None else f"${last['usd']}"
+        log.info("%d passes, score %s, %s tokens, %s", len(passes), 
last["score"], last["tokens"], usd)
+        return last["draft"]
+
+    refinement = refine.loop(max_iterations=20, until=good_enough)
+    publish(refinement["tally"].output)
+
+
+example_agent_loop_budget()
+# [END howto_loop_budget]

Reply via email to