Skip to content

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

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

dabla wants to merge 3 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 2 times, most recently from d091857 to df446fb Compare October 18, 2026 09:57
@dabla
dabla force-pushed the dabla/agent-async/01-task-agent-aexecute branch from 96b0117 to 661dba8 Compare October 28, 2026 13:59
dabla added 2 commits October 28, 2026 17:56
`@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 8c10f6b to 3785311 Compare October 28, 2026 17:01
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

The re-run of the jobs stopped by EC2 Spot interruptions finished: attempt 2 failed.

@dabla
dabla marked this pull request as ready for review November 5, 2026 12:42

# Rendering a template can read a Variable or a Connection with a blocking call to
# the supervisor, which must not happen on the event loop.
await asyncio.to_thread(self.render_template_fields, context)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This renders every template field a second time rather than only the prompt, and the context never gets the positional arguments. So the docstring's own example, analyze("x") returning "Answer: {{ question }}", fails here with UndefinedError while the sync path renders it. A message_history or op_kwargs value from an upstream XCom that contains {{ also gets evaluated as Jinja on this second pass. Could this mirror execute: merge call_arguments(...) into the context, then self.prompt = await asyncio.to_thread(self.render_template, self.prompt, context)? A test that calls the async function with a positional argument would pin it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we actually just fixed it recenrtly on main

async def _acomplete_run(self, context: Context, result: Any, *, attempt_usage: RunUsage) -> Any:
"""Async version of :meth:`_complete_run`, without the features :meth:`aexecute` refuses."""
log_run_summary(self.log, result, usage=attempt_usage)
if isinstance(result.output, DeferredToolRequests):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sync _complete_run calls _report_failed_run when the approval pause is refused (UnsupportedToolDeferralError, ToolApprovalAlreadyRequestedError), but here the error goes straight up. An async attempt that ends this way pushes no run_id/usage XCom, which the class docstring promises for a failed attempt. Should this get the same except BaseException guard, with _report_failed_run run in a thread?

await op.aexecute(context)

pushes = {
c.kwargs["key"]: c.kwargs["value"] for c in context["task_instance"].axcom_push.await_args_list

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only reads axcom_push, but on 3.2 _axcom_push falls back to xcom_push in a thread, so [seeded] and [opt-in] fail there. That is the red Compat 3.2.2 job. Collecting from both mocks would fix it, or skip this below 3.3 and add a test with the 3.3 flag patched off, which would also cover the fallback branch (it has no test yet).

such as ``SQLToolset`` and ``HookToolset``, run in a worker thread with either kind of function.

``durable=True`` and ``enable_hitl_review=True`` do blocking I/O during the run and are not
supported with an ``async def`` function: the Dag fails to parse with a ``ValueError``. Use a regular

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A mapped task still parses: the check is in __init__, which .expand() doesn't call until the task runs. Maybe say the task fails with a ValueError, or add the check on the mapped path too?


On Airflow 3.2+ the decorated function can be an ``async def``. It is awaited for the prompt, and
the agent run is then awaited on the task's event loop. The function can await async hooks. What
has no async counterpart, such as the connection lookup, runs in a worker thread, so nothing blocks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite with AgentSkillsToolset: its __aexit__ still calls xcom_pull/xcom_push synchronously on the loop thread. Could this sentence be narrowed to the operator's own calls, or that push moved to a thread?

raise

def execute(self, context: Context) -> Any:
if self.is_async:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in-tree reaches this branch: AgentOperator.is_async is always False, and the decorator dispatches in its own execute. Could BaseAsyncOperator go on _AgentDecoratedOperator only, so the public operator's base classes don't change? Or is an async path for the plain operator planned?


assert results == ["the answer"] * 8
# Eight runs of 0.2 s each take 1.6 s one after the other.
assert loop.time() - started < 1.0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also passes if _arun_tracked hands run_sync to asyncio.to_thread, since eight threads overlap just as well. Recording threading.get_ident() in slow and asserting it is the loop's thread would pin the one-loop claim.

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.

2 participants