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}`.
   
   ![Required Actions request for the first 
refund](./e2e-pr2/lane3-request-1.png)
   
   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]

Reply via email to