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 f10a3de1136 Add a guide to developing and testing Common AI tasks 
locally (#73602)
f10a3de1136 is described below

commit f10a3de113680203c72f732cbd7807cebd18443c
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Sep 23 12:35:22 2026 +0100

    Add a guide to developing and testing Common AI tasks locally (#73602)
    
    Covers iterating on an agent in a notebook with the same hook and
    toolsets the task uses, testing an agent task offline with a scripted
    FunctionModel and dag.test(), and why setting a connection's model to
    "test" does not work for an agent with a SQLToolset. The agent operator
    page gains a section on defining an agent once and reusing it across
    tasks, and the quick start links to the new page.
---
 providers/common/ai/docs/index.rst             |   1 +
 providers/common/ai/docs/local_development.rst | 154 +++++++++++++++++++++++++
 providers/common/ai/docs/operators/agent.rst   |  81 +++++++++++++
 providers/common/ai/docs/quickstart.rst        |   2 +
 4 files changed, 238 insertions(+)

diff --git a/providers/common/ai/docs/index.rst 
b/providers/common/ai/docs/index.rst
index 247ce5c73ac..033593b19fa 100644
--- a/providers/common/ai/docs/index.rst
+++ b/providers/common/ai/docs/index.rst
@@ -94,6 +94,7 @@ waits for the result, use that vendor's provider.
     Installation <installation>
     Quick start <quickstart>
     Core concepts <concepts>
+    Develop and test locally <local_development>
 
 .. toctree::
     :titlesonly:
diff --git a/providers/common/ai/docs/local_development.rst 
b/providers/common/ai/docs/local_development.rst
new file mode 100644
index 00000000000..64884ebfc23
--- /dev/null
+++ b/providers/common/ai/docs/local_development.rst
@@ -0,0 +1,154 @@
+ .. 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/local_development:
+
+Develop and test AI tasks locally
+=================================
+
+Getting an agent's prompt and tools right takes many reruns. Do them in a
+notebook, with no Airflow running, then test the finished Dag without an API 
key.
+
+Iterate in a notebook
+---------------------
+
+``AgentOperator`` builds its agent from a
+:class:`~airflow.providers.common.ai.hooks.pydantic_ai.PydanticAIHook` and the
+toolsets you pass it. Build the same agent yourself and call it directly. Hooks
+read connections from ``AIRFLOW_CONN_<ID>`` environment variables, so no
+scheduler, metadata database, or ``airflow db migrate`` is involved:
+
+.. code-block:: bash
+
+    export AIRFLOW_CONN_MY_LLM='{"conn_type": "pydanticai", "password": 
"sk-...", "extra": {"model": "openai:gpt-5"}}'
+    export AIRFLOW_CONN_ORDERS_DB='{"conn_type": "sqlite", "host": 
"/tmp/orders.db"}'
+
+.. code-block:: python
+
+    from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
+    from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+    agent = PydanticAIHook.get_hook("my_llm").create_agent(
+        instructions="You answer questions about the orders database.",
+        toolsets=[SQLToolset(db_conn_id="orders_db", 
allowed_tables=["orders"])],
+    )
+    result = agent.run_sync("How many orders are there?")
+    print(result.output)
+
+Change the instructions, tools, or prompt and rerun the cell. To talk to the 
agent
+instead, pydantic-ai's ``agent.to_cli_sync()`` opens a chat in the terminal (it
+needs the ``pydantic-ai-slim[cli]`` extra).
+
+When the answers look right, copy the arguments into the task. ``instructions``
+becomes ``system_prompt``, the connection ID goes in ``llm_conn_id``, 
``toolsets``
+and ``output_type`` keep their names, and any other agent argument, such as
+``retries``, goes in ``agent_params``.
+
+.. code-block:: python
+
+    # dags/orders_report.py
+    from airflow.providers.common.ai.toolsets.sql import SQLToolset
+    from airflow.sdk import dag, task
+
+
+    @dag(schedule=None)
+    def orders_report():
+        @task.agent(
+            llm_conn_id="my_llm",
+            system_prompt="You answer questions about the orders database.",
+            toolsets=[SQLToolset(db_conn_id="orders_db", 
allowed_tables=["orders"])],
+        )
+        def orders_question() -> str:
+            return "How many orders are there?"
+
+        orders_question()
+
+
+    orders_report()
+
+To keep prompt data on your machine while iterating, point ``my_llm`` at a 
model
+you serve yourself; see :ref:`howto/self_hosted_models`.
+
+Test an agent task without calling a model
+------------------------------------------
+
+Script the model's replies with pydantic-ai's
+`FunctionModel <https://pydantic.dev/docs/ai/guides/testing/>`__ and patch
+``PydanticAIHook.get_conn``, which is where ``AgentOperator`` and 
``@task.agent``
+get their model. ``dag.test()`` then runs the real task, including template 
rendering,
+toolset calls against your test database and XCom, without network access:
+
+.. code-block:: python
+
+    # tests/test_orders_report.py
+    from unittest import mock
+
+    import pydantic_ai.models
+    from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart, 
ToolReturnPart
+    from pydantic_ai.models.function import FunctionModel
+
+    from airflow.providers.common.ai.hooks.pydantic_ai import PydanticAIHook
+
+    from orders_report import orders_report  # dags/orders_report.py from above
+
+    # Fail the test instead of calling a real model by accident.
+    pydantic_ai.models.ALLOW_MODEL_REQUESTS = False
+
+
+    def scripted_model(messages, info):
+        returns = [p for m in messages for p in m.parts if isinstance(p, 
ToolReturnPart)]
+        if not returns:
+            return ModelResponse(parts=[ToolCallPart("query", {"sql": "SELECT 
COUNT(*) AS n FROM orders"})])
+        return ModelResponse(parts=[TextPart(f"Result: 
{returns[-1].content}")])
+
+
+    def test_report_counts_orders():
+        with mock.patch.object(
+            PydanticAIHook, "get_conn", autospec=True, 
return_value=FunctionModel(scripted_model)
+        ):
+            dag_run = orders_report().test()
+
+        assert dag_run.state == "success"
+
+The first reply calls the ``query`` tool, which runs against the test database
+behind ``orders_db``; the second turns the tool result into the answer. The 
task
+still looks up ``my_llm``, so define it in the test environment too; it needs 
no
+key:
+
+.. code-block:: bash
+
+    export AIRFLOW_CONN_MY_LLM='{"conn_type": "pydanticai", "extra": {"model": 
"openai:gpt-5"}}'
+
+``dag.test()`` needs a metadata database, so run ``airflow db migrate`` once 
in the
+test environment. It also looks the Dag up in your Dags folder, so import the 
Dag
+from its file instead of defining it in the test, and put the Dags folder on 
the
+import path (pytest's ``pythonpath`` setting).
+
+Setting a ``pydanticai`` connection's model to ``test`` swaps in pydantic-ai's
+``TestModel`` instead, with no patching. It calls *every* tool once with 
generated
+arguments, against your real connections. That suits ``@task.llm`` and agents 
whose
+tools accept any input, but a ``SQLToolset`` rejects the generated SQL until 
its
+retries run out, and the task fails.
+
+Check the answers themselves
+----------------------------
+
+The test above scripts the model's replies, so it can't tell you whether a 
changed
+prompt still gives good answers. For that, run a set of cases with expected
+outcomes against the notebook agent from the first section, using a real model 
and
+a library such as `pydantic-evals 
<https://pydantic.dev/docs/ai/evals/evals/>`__,
+before you ship the change.
diff --git a/providers/common/ai/docs/operators/agent.rst 
b/providers/common/ai/docs/operators/agent.rst
index 1dfe9daa663..f443150b5ac 100644
--- a/providers/common/ai/docs/operators/agent.rst
+++ b/providers/common/ai/docs/operators/agent.rst
@@ -160,6 +160,87 @@ Open the **Rendered Template** tab on the task instance to 
see the
 substituted ``system_prompt`` after Jinja fills in ``classify``'s XCom
 values.
 
+.. _howto/operator:agent-reuse:
+
+Reuse one agent across tasks
+----------------------------
+
+When several tasks, or several Dags, run the same agent, define it once and
+import it. ``AgentOperator`` and ``@task.agent`` take the whole agent 
definition
+as keyword arguments, so a dict in a module next to your Dags is enough. A task
+that needs something different overrides single keys.
+
+.. code-block:: python
+
+    # dags/shared_agents/__init__.py
+    from airflow.providers.common.ai.toolsets.sql import SQLToolset
+
+    ORDERS_ANALYST = {
+        "llm_conn_id": "pydanticai_default",
+        "system_prompt": "You are the orders analyst. Answer only from the 
orders database.",
+        "toolsets": [SQLToolset(db_conn_id="orders_db", 
allowed_tables=["orders"])],
+        "agent_params": {"name": "orders_analyst"},
+    }
+
+.. code-block:: python
+
+    # dags/orders.py
+    from shared_agents import ORDERS_ANALYST
+
+    from airflow.sdk import dag, task
+
+
+    @dag(schedule=None)
+    def orders():
+        @task.agent(**ORDERS_ANALYST)
+        def weekly_summary() -> str:
+            return "Summarize this week's orders."
+
+        @task.agent(**{**ORDERS_ANALYST, "system_prompt": "Answer in one 
sentence."})
+        def one_liner() -> str:
+            return "How many orders are there?"
+
+        weekly_summary()
+        one_liner()
+
+
+    orders()
+
+When span export is on (see :doc:`../observability`), the ``name`` in
+``agent_params`` becomes the ``gen_ai.agent.name`` attribute on each agent 
run's
+span, so traces from every task that uses the definition group under one agent.
+
+To keep the definition out of Python, for example to share it with a program 
that
+does not run on Airflow, write a pydantic-ai
+`agent spec <https://pydantic.dev/docs/ai/core-concepts/agent-spec/>`__ file 
and pass its path through
+``agent_params``. A model set on the connection wins over a ``model`` in the
+file, and ``system_prompt`` is added to the file's ``instructions``:
+
+.. code-block:: yaml
+
+    # dags/shared_agents/orders_analyst.yaml
+    name: orders_analyst
+    instructions: >
+      You are the orders analyst. Answer only from the orders database.
+    retries: 2
+
+.. code-block:: python
+
+    from pathlib import Path
+
+    AgentOperator(
+        task_id="orders_question",
+        llm_conn_id="pydanticai_default",
+        prompt="How many orders are there?",
+        agent_params={"spec_file": Path(__file__).parent / "shared_agents" / 
"orders_analyst.yaml"},
+    )
+
+Build the path from ``__file__``: a relative path resolves against the worker's
+working directory, not the Dag file.
+
+With ``durable=True``, tools from capabilities declared in the spec file are 
not
+replayed on retry; they run again. Pass tools you need replayed in 
``toolsets=``.
+
 Agent features
 --------------
 
diff --git a/providers/common/ai/docs/quickstart.rst 
b/providers/common/ai/docs/quickstart.rst
index 0ee55c3cd8e..a21393ed190 100644
--- a/providers/common/ai/docs/quickstart.rst
+++ b/providers/common/ai/docs/quickstart.rst
@@ -121,3 +121,5 @@ Where to go next
   files, generating SQL, batch processing.
 - :doc:`operators/agent` gives the model tools built from Airflow hooks, SQL 
databases or
   MCP servers, so it can act instead of only answering.
+- :doc:`local_development` shows how to iterate on an agent in a notebook and 
test the
+  Dag without an API key.

Reply via email to