kaxil opened a new pull request, #76786:
URL: https://github.com/apache/airflow/pull/76786
Stacked on #76785.
With this change, a function tool with `needs_approval=True` in an agent run
through `run_durably_sync` pauses the task in Required Actions until a person
approves or rejects the call. Where pausing isn't possible, the task still
fails as in #76785.
```python
@function_tool(needs_approval=True)
def issue_refund(order_id: str, amount: float, reason: str) -> str: ...
@task
def handle_ticket(ticket: str) -> str:
agent = Agent(name="support", model=..., tools=[get_order, issue_refund])
return run_durably_sync(agent, ticket).final_output
```
Each pending call becomes one `request_input()` request (standard provider,
Airflow 3.4+). The subject names the agent and the tool; the body shows the
tool's description and its arguments after the secret masker; the reviewer
picks Approve or Reject and can give a reason. The answer resumes the task,
which runs from the top. `run_durably` replays the model and tool calls the
durable journal recorded before the pause, then approves the call, or rejects
it with the reviewer's reason as the call's result. The journal is deleted only
once a run ends with no pending approval.
## Design rationale
**Resuming uses the journal, not a serialized `RunState`.** The journal
already replays every recorded model and tool call, so the resumed run reaches
the same interruption with the same call id, and `request_input()` hands back
the answer stored under it. Nothing new is stored, and `run_durably` takes no
`RunState`.
**One request per call.** When one model answer asks for several calls that
need approval, each is asked separately, in the model's order, with its own
decision and reason. `AgentOperator` asks a turn's calls in one request with
one decision; separate requests let the reviewer approve one refund and reject
the other with a reason the agent can act on.
**Retries and divergence.**
- A retry approves without asking any call whose result is already recorded,
since it ran after a person approved it.
- Before pausing, `DurableRun.pause()` deletes an earlier failed attempt's
entries at positions this attempt left empty and moves the replay boundary up,
so a resume replays this attempt's model answers instead of asking the model
again and getting a new call id.
- The answer's key includes the call's fingerprint, so an answer never
applies to a different call that reuses the id.
- If a step before the paused call returns something different on resume (a
tool that always runs live, a prompt built from live data, a Dag edited while
the task waited), the stored answer belongs to a call the run no longer makes,
and the task fails with `DurableJournalError`.
**Where it cannot pause**, the task fails with
`UnsupportedToolDeferralError`, without retrying, and the message says why:
Airflow before 3.4 or a standard provider without `request_input()` (checked at
runtime, no `>=` floors change), a task that isn't a `@task` or
`PythonOperator`, a run with a `session`, or a call by an agent whose model the
journal doesn't record (one used through `as_tool`, or reached through
`handoff()`).
## Evidence
A local Airflow from main with a scheduler, real model (`gpt-5.4-mini`
through an OpenAI-compatible gateway), `openai-agents` 0.24.0: the new example
Dag's support agent, with a ticket asking to refund two orders. The model asked
for both refunds in one answer, and both requests were approved in the UI.
| Run of the task | Live model calls | Tools that ran | Ended with |
|---|---|---|---|
| 1 | 2 (look up the orders, ask for the refunds) | `get_order` ×2 | pause
for A-1001 |
| 2 (resumed) | 0 | none | A-1001 approved by admin, pause for A-1002 |
| 3 (resumed) | 1 (final answer) | `issue_refund` ×2 | success, answering
that both orders were refunded in full |
Journal stats of the last run: `replayed={'model': 2, 'tool': 2}
recorded={'tool': 2, 'model': 1}`.

Tests drive the real task runner (`create_runtime_ti`) through pause and
resume: approve; reject with and without a reason; a second approval later in
the run; two calls in one answer; a handoff agent's tool; a retry after the
call ran; a retry after a model failure, with fresh and reused call ids, one or
two approvals, and reject then approve; a resume that diverges; two agents in
one task; masking; and each fallback. They pass on `openai-agents` 0.24 with
`openai` 3.42 and on 0.20 with `openai` 2.54.
## Known issues
- An agent run that finished earlier in the same task runs again on every
resume, since its record is deleted when it finishes. The guide says to give an
agent whose tools need approval a task of its own.
- A function tool whose `needs_approval` is a function, and an MCP server's
tool, are not recorded, so once approved they run again on every later resume.
The guide says so.
- An approved call runs with the arguments the journal recorded, which pass
through the secret masker, so a registered secret the model wrote into an
argument reaches the tool masked. Replayed calls already behave this way.
- The check for an answer the resumed run never reached reads the standard
provider's `answer_log_var`, which isn't public API yet.
--
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]