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
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:
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"}'
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.
# 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 Self-hosted models.
Test an agent task without calling a model¶
Script the model’s replies with pydantic-ai’s
FunctionModel 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:
# 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:
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, before you ship the change.