Skip to content

Add native async execution to AgentOperator and @task.agent - #74201

Draft
dabla wants to merge 6 commits into
mainfrom
dabla/agent-async/01-task-agent-aexecute
Draft

dabla wants to merge 6 commits into
mainfrom
dabla/agent-async/01-task-agent-aexecute

Conversation

@dabla

@dabla dabla commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #74150 (async aget_conn / acreate_agent on PydanticAIHook), which must be merged first. This branch is built on top of it, so the diff shows its commits until then; only the last commit belongs to this PR. Draft until #74150 is in.

@task.agent cannot be used with an async def function today. The decorator calls the function without awaiting it, so the task fails in validate_prompt on the coroutine it gets back, while the operator already reports is_async through DecoratedOperator. An agent task therefore cannot await async hooks to build its prompt, and cannot share the event loop of an async task with other work, although the pydantic-ai agent behind it is async-native.

This makes AgentOperator a BaseAsyncOperator, the way PythonOperator is one:

  • @task.agent on an async def function awaits it for the prompt and runs the agent on the task's event loop. A regular function keeps the synchronous path unchanged. AgentOperator.is_async is False; on the decorator it follows the function.
  • AgentOperator.aexecute() awaits the agent run on the event loop (await agent.run() through CancellableAgentRunMixin.run_agent_async, same cancellation token as the sync run) and makes no blocking call to the supervisor on it: the hook comes from PydanticAIHook.aget_hook() and the agent from acreate_agent() (Add async aget_conn and acreate_agent to PydanticAIHook #74150), and the usage budget (aload / asave / aclear), the tool approval transcript and the XComs go through their async accessors.
  • What has no async counterpart reuses the synchronous code from a worker thread: the connection lookup inside aget_hook(), the task state store before Airflow 3.4 (its async accessors are on main only, Add async task store accessors for async tasks #68232), the usage report of a failed run and the pause for a tool approval. Resuming after an approval stays synchronous.
  • The template fields are rendered in a worker thread, since a template can read a Variable or a Connection with a blocking call.
  • durable=True and enable_hitl_review=True do blocking I/O during the run (the durable storage inside the model and tool calls, the review poll loop) and are rejected with an async def function, at parse time on the decorator. They can follow in a later PR.
  • axcom_push exists since Airflow 3.3.0; on 3.2 the XComs are pushed with xcom_push from a worker thread.

What it changes for performance. One agent run is not faster, its duration is the model's. The gain is in running many of them in one task process. Measured with pydantic-ai alone and a fake model that sleeps 1 s, one run per cell: a thread pool calling run_sync versus one event loop awaiting run.

Concurrent runs Thread pool + run_sync One loop + run CPU, threads CPU, loop
8 1.06 s 1.03 s 0.07 s 0.02 s
32 1.26 s 1.10 s 0.30 s 0.09 s
64 1.41 s 1.19 s 0.51 s 0.18 s
256 2.97 s 1.82 s 2.33 s 0.78 s

Tests. New tests cover run_agent_async (token held and cleared, on_kill cancelling an awaited run), the async usage budget methods and their fallback to the sync store before Airflow 3.4, UsageBudgetMixin._arun_tracked, aexecute with mocks and with a real FunctionModel run where every blocking state store call and xcom_push fails the test, the failed-run and tool-approval branches, the rejected features, the decorator with an async def function through both aexecute and execute, rendering off the event loop, and eight runs overlapping on one loop.

Run locally: the whole providers/common/ai/tests/unit suite (3104 passed, 13 skipped), and after the last round the touched modules again, mypy on the changed source files, and prek on the changed files. A backport of the same design to common.ai 0.9 also ran on a Celery worker with Airflow 3.3.2: one async @task.agent task, and eight agent runs sharing one event loop inside a single task, against an Azure OpenAI model, without a DeadlockImminentError.


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Fable 5.1)

Generated-by: Claude Code (Fable 5.1) following the guidelines

@dabla dabla self-assigned this Oct 4, 2026
@dabla
dabla marked this pull request as ready for review October 6, 2026 08:16
@dabla
dabla marked this pull request as draft October 6, 2026 08:16
@dabla
dabla removed the request for review from gopidesupavan October 13, 2026 15:07
@dabla
dabla force-pushed the dabla/agent-async/01-task-agent-aexecute branch from f55607b to d091857 Compare October 14, 2026 21:42
dabla and others added 4 commits October 16, 2026 08:34
get_conn() resolves the hook's connection and every fallback connection
with get_connection() and reads extra_dejson, whose secret masking sends
to the supervisor synchronously; inside an async task with another async
SDK call in flight both raise DeadlockImminentError, although the agent
the hook builds is async-native. aget_conn() fetches the connection and
its fallbacks with get_async_connection() (extra from
Connection.aextra_dejson(), #71890, Airflow 3.3.2+, or the raw extra via
json.loads on older versions as in #72130), seeds the existing
connection cache and builds the model with the unchanged get_conn();
acreate_agent() does the same before create_agent(), spec_file included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the local aextra_dejson fallback with the common.compat helper
added in #74147, which masks the extra's secrets asynchronously on
Airflow 3.3.2+ and reads extra_dejson from a worker thread on older
versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prefetching the connections only covered the base hook's own lookups: a
subclass's _get_provider_kwargs (Snowflake's builds a SnowflakeHook)
still made synchronous Task SDK calls on the event loop, and on Airflow
3.1 to 3.3.1 get_async_connection itself reads extra_dejson
synchronously. aget_conn() and acreate_agent() now run the unchanged
get_conn() / create_agent() in asyncio.to_thread: from any thread other
than the loop's, a synchronous send() waits for the in-flight asend() to
release the comms lock instead of raising DeadlockImminentError. This
drops the prefetch, the prefetched fallback cache and the extra
parameter of _seed_connection.

aget_hook() is the async counterpart of get_hook(), with the same
connection type check, so an async operator can dispatch to the
pydanticai* subclasses. get_hook() now caches the connection it fetched
on the hook it returns, so the hook's first get_conn() does not fetch it
again.

The tests refuse the synchronous lookups only on the event loop, drive a
subclass that reads another connection as primary and as fallback, and
check that every argument of acreate_agent reaches the agent. The hook
docs gain a section on using it from an async task, and uv.lock is back
to main's.
`@task.agent` could not be used with an `async def` function: the function
was called without being awaited, so the task failed on the coroutine it
got back as its prompt, while the operator already reported `is_async`
through `DecoratedOperator`. An agent run could therefore not await async
hooks to build its prompt, nor share the event loop of an async task with
other work, although the pydantic-ai agent behind it is async-native.

`AgentOperator` is now a `BaseAsyncOperator` with an `aexecute()` that
awaits the agent run on the event loop and makes no blocking call to the
supervisor on it: the usage budget, the tool approval transcript and the
XComs of a successful run go through their async accessors. What has no
async counterpart (the connection lookup, the task state store before
Airflow 3.4, the usage report of a failed run, the pause for a tool
approval) runs the synchronous code in a worker thread.

`durable=True` and `enable_hitl_review=True` do blocking I/O during the
run and are rejected with an async function for now.
@dabla
dabla force-pushed the dabla/agent-async/01-task-agent-aexecute branch from d091857 to df446fb Compare October 18, 2026 09:57

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant