kaxil opened a new pull request, #76882:
URL: https://github.com/apache/airflow/pull/76882

   `AgentOperator(durable=True)` and `@task.agent(durable=True)` could not use 
tool approval. A tool marked `requires_approval=True` or wrapped with 
`.approval_required()` failed the task:
   
   ```
   UnsupportedToolDeferralError: The agent called tools that need approval 
(issue_refund), but tool approval needs Airflow 3.3+ and is not available with 
durable, enable_hitl_review, a CodeMode capability or a SandboxToolset.
   ```
   
   Removing that check would not make it work. The non-durable resume runs a 
second agent run from a saved transcript, outside the task's journal, so a 
failure after the resume would run the approved tool again on retry. Nothing 
recorded the answer either: the retry would replay up to the same pause and ask 
the reviewer again. On Airflow 3.3 that second request fails the task with 
`ToolApprovalAlreadyRequestedError`. On 3.4, a reviewer who rejected this time 
would have the agent told "denied" about a refund that had already gone out.
   
   With this PR, on Airflow 3.3 and later, a durable agent task pauses for 
approval like a non-durable one, and the reviewer's answer becomes a step of 
the journal. When the reviewer answers, the task runs the agent again from the 
prompt. Every model request and tool call before the pause replays from the 
journal, and the approval step gets the answer. A retry after that replays the 
decision too: the reviewer is not asked again, and an approved call that 
already ran is not run again.
   
   ## Design rationale
   
   **Approval step.** The approval is a pydantic-ai capability whose decision 
is a `@durable_operation`; `handle_deferred_tool_calls` is not one of the hooks 
pydantic-ai forbids as durable operations. While the reviewer has not answered, 
the step writes the approval request and parks the task (`TaskAwaitingInput`). 
A parked step is not recorded, so the resumed run reaches it again. Once it 
returns `DeferredToolResults`, the journal records it. The step is 
fingerprinted by the calls it settles, through 
`fingerprint_deferred_tool_requests`, so a recorded answer replays only for 
those calls. This replaces the saved transcript, its hash check and the 
`{ti.id}-resumed-{n}` run id on the durable path. The non-durable path keeps 
them, because without a journal, running again from the prompt would call the 
model again for every step before the pause.
   
   **Stored answers.** Answers live in the task state store, bound to the 
approval step that asked. Each answer is stored with:
   - the approval step's number in the run, counted the same way on replay and 
on a live run;
   - the tool name and an argument fingerprint of each call the reviewer saw;
   - the agent's rendered toolset ids.
   
   They are deleted with the journal when the task succeeds. Keeping them only 
in the resume kwargs broke in these cases:
   - A handler that changes its mind between resumes. Each resume's answer 
stays available, so the run does not re-ask in a loop.
   - A handler that fails after an answer was applied. The retry still has the 
answer.
   - A model request asked again on resume, after a tool opted out of the 
journal returned something new. A real provider names the same call with a new 
id, so matching on ids would never apply the answer.
   
   Matched by content alone, an earlier answer reached a later call with the 
same tool and arguments, and a rejected refund ran. Answers given for other 
toolset ids are not applied, so a retry never runs an approved call against a 
connection the reviewer did not see.
   
   **Other handlers.** The approval capability declares `outermost` ordering, 
and its one durable step does three things in order:
   1. Applies the stored answers.
   2. Lets the run's other `handle_deferred_tool_calls` handlers (a 
decision-model judge, say) settle what is left. It reaches them through 
`ctx.root_capability`, so pydantic-ai's own chain decides which capabilities 
are active and in what order.
   3. Asks the reviewer about anything still left.
   Left where pydantic-ai's list order puts it, ahead of the approval step, a 
judge that changed its verdict between pause and resume overruled a Reject; 
placed after it, the judge would only see calls a person had already been asked 
about. A retry replays the whole decision, so the judge is not consulted again.
   
   **Journal.** On a pause, `DurableRun.pause()` trusts every step the paused 
attempt recorded and deletes what an earlier attempt left at positions this one 
did not record. `fail()` would hand the resume back to an earlier attempt's 
failure point, so the model would be asked again on every resume and the 
reviewer with it.
   
   **Nested approvals.** An approval reached inside another tool's work fails 
the task, such as one in an agent that a tool runs (`ctx.agent.run`) or in a 
call a tool makes inline. Once that tool is recorded, a replay skips its 
insides, so every later approval step would be numbered one lower and get 
another step's answer. The refusal uses the journal's executing-step context 
and raises `UnsupportedToolDeferralError`, as approval under CodeMode already 
does.
   
   The `@task.agent` resume goes through `execute`, so the prompt function runs 
again on each resume, as it does on a retry. pydantic-ai-slim 2.46.0, the 
current floor, has everything this needs; the durable approval, agent and 
journal tests pass at 2.46.0 and at the locked 2.63.0.
   
   ## End to end
   
   `AgentOperator` and `@task.agent` with `durable=True` on a live stack 
(Airflow `main`, 3.5.0.dev), with a real model (`gpt-5.4-mini`), from the PR's 
commit. Each tool logs `TOOL RAN LIVE` when its code runs.
   
   In `refund_approved` the reviewer approved, `issue_refund` ran once, and 
`notify_customer` failed on purpose; try 2 replayed 6 steps (3 model, 2 tool, 1 
approval) with no new request, ran only `notify_customer`, and succeeded.
   
   ![Try 2 replays the approval and the 
refund](./e2e-refund-approved-try2-replays-approval.png)
   
   After a reject with a reason, the refund never ran and the model replied 
"The refund was not issued because refunds over $15 require a support ticket 
number." `two_refunds` paused twice in one try; the second request was answered 
on the Required Actions page, and each refund ran once:
   
   ![Request 2 on the Required Actions tab](./e2e-two-refunds-request-2.png)
   
   ![Two pauses, each refund once](./e2e-two-refunds-two-pauses.png)
   
   Through `@task.agent`, the prompt function ran again on the resume and the 
refund ran once.
   
   The issue also asks for these cases on Airflow 3.3 and 3.4 releases. Those 
legs were not run against released Airflow. The unit tests cover both version 
branches (one request per task instance on 3.3, repeat requests on 3.4) by 
patching the version check.
   
   ## Known issues
   
   - A tool that runs the agent in a bare thread (`run_in_executor` with 
`asyncio.run`) does not carry the journal's context, so an approval there is 
not refused and is numbered as a top-level step.
   - A tool opted out of the journal that returns something new on resume makes 
the model requests and calls after it run again, approved calls included. The 
answer still applies when the same call comes back at the same approval step.
   - Under durable, `ToolLoggingCapability` logs "waiting to be approved" each 
time a gated call replays, although nothing waits then.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to