Repository navigation
Conversation
d091857 to
df446fb
Compare
96b0117 to
661dba8
Compare
`@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.
8c10f6b to
3785311
Compare
|
The re-run of the jobs stopped by EC2 Spot interruptions finished: attempt 2 failed. |
|
|
||
| # 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
@task.agentcannot be used with anasync deffunction today. The decorator calls the function without awaiting it, so the task fails invalidate_prompton the coroutine it gets back, while the operator already reportsis_asyncthroughDecoratedOperator. 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
AgentOperatoraBaseAsyncOperator, the wayPythonOperatoris one:@task.agenton anasync deffunction 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_asyncisFalse; on the decorator it follows the function.AgentOperator.aexecute()awaits the agent run on the event loop (await agent.run()throughCancellableAgentRunMixin.run_agent_async, same cancellation token as the sync run) and makes no blocking call to the supervisor on it: the hook comes fromPydanticAIHook.aget_hook()and the agent fromacreate_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.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.durable=Trueandenable_hitl_review=Truedo blocking I/O during the run (the durable storage inside the model and tool calls, the review poll loop) and are rejected with anasync deffunction, at parse time on the decorator. They can follow in a later PR.axcom_pushexists since Airflow 3.3.0; on 3.2 the XComs are pushed withxcom_pushfrom 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_syncversus one event loop awaitingrun.run_syncrunTests. New tests cover
run_agent_async(token held and cleared,on_killcancelling an awaited run), the async usage budget methods and their fallback to the sync store before Airflow 3.4,UsageBudgetMixin._arun_tracked,aexecutewith mocks and with a realFunctionModelrun where every blocking state store call andxcom_pushfails the test, the failed-run and tool-approval branches, the rejected features, the decorator with anasync deffunction through bothaexecuteandexecute, rendering off the event loop, and eight runs overlapping on one loop.Run locally: the whole
providers/common/ai/tests/unitsuite (3104 passed, 13 skipped), and after the last round the touched modules again,mypyon the changed source files, andprekon 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.agenttask, and eight agent runs sharing one event loop inside a single task, against an Azure OpenAI model, without aDeadlockImminentError.Was generative AI tooling used to co-author this PR?
Generated-by: Claude Code (Fable 5.1) following the guidelines