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

   A durable agent task fails when one of its tools returns bytes that are not 
UTF-8 text, such as a PNG thumbnail or `{"blob": b"\xff"}`. The same agent runs 
fine without durable execution, because pydantic-ai shows bytes to the model as 
base64 (`tool_return_ta`, `ser_json_bytes='base64'`). The journal encodes tool 
results with pydantic-ai's `JSON_CODEC`, which writes bytes as UTF-8 text and 
raises `UnicodeDecodeError` on anything else. For function, MCP and dynamic 
toolsets that error is not mapped to `DurableJournalError`, so the task retries 
and fails the same way every time; Airflow's own toolsets fail with 
`DurableJournalError`. Bytes that are UTF-8 text were recorded, but as `str`, 
so a retry handed the tool's caller a string where the first attempt had bytes. 
The journal has encoded results this way since `AirflowDurability` was added in 
#74385.
   
   The journal now uses a codec of its own on top of `JSON_CODEC`. It records 
bytes a tool returns, on their own or as values inside dicts, lists and tuples 
(and in a `ToolReturn`'s `return_value` and `metadata`), as 
`{"__airflow_bytes__": "<base64>"}`, and turns them back into the same bytes on 
replay. Entries written by earlier versions have no tag and load as before. 
Typed values such as `BinaryContent` are left to encode their own bytes. 
Secrets inside the bytes are masked before they are recorded, as they are for 
text.
   
   ## Design rationale
   
   **Why a tag and not pydantic's `ser_json_bytes='base64'`.** Configuring the 
codec to write base64 would stop the failure, but tool results are typed `Any`, 
so on replay the base64 string loads as `str`. The retry would hand back text 
where the first attempt had bytes. Only a tag can tell bytes from text on the 
way back.
   
   **Matching pydantic-ai's recorded tool result.** The codec reaches the 
tool's value through the `result` field of pydantic-ai's recorded tool-call 
result, found by its `kind`. `CallToolResult` is public only from pydantic-ai 
2.61 and the floor is 2.36, so it cannot be matched by type. That wire shape is 
what pydantic-ai keeps loading across versions, and if it changes, the bytes 
tests fail rather than the code silently falling back.
   
   **A journal that does not fail on an unrecordable result.** A result that 
still has no JSON form (an object pydantic cannot serialize, or bytes that are 
not UTF-8 text inside a dataclass) fails the task with `DurableJournalError`, 
as before. `DurableJournal(fail_on_unrecordable=False)` instead logs the step, 
leaves it unrecorded so a retry runs it again, and lets the run go on, the same 
way a write the task state store refuses is handled today. Nothing passes 
`False` yet: it is for turning durable execution on by default, where a task 
whose author never asked for durability should not fail on a value durability 
cannot hold. An explicit `durable=True` keeps failing. The switch lives on the 
journal rather than on `AirflowDurability`, because `AgentOperator` already 
builds its own journal, so it adds no public parameter to the capability.
   
   ## Checked end to end
   
   A local Airflow (main) ran an agent with a real model 
(`openai:gpt-5.4-mini`) whose `get_thumbnail` tool returns a 70-byte PNG, and 
whose next tool fails on the first try. The task log of each try:
   
   | Try | `get_thumbnail` | What the model got back | Durable summary line |
   |---|---|---|---|
   | 1 | ran live | bytes, 70 bytes, sha256 `c414cd0e204de974` | `replayed 0 
steps (0 model, 0 tool, 0 other), recorded 3 new steps (2 model, 1 tool, 0 
other)` |
   | 2 | replayed | bytes, 70 bytes, sha256 `c414cd0e204de974` | `replayed 3 
steps (2 model, 1 tool, 0 other), recorded 2 new steps (1 model, 1 tool, 0 
other)` |
   
   Both model responses replayed on try 2, so the conversation after the 
replayed bytes matched what was recorded. On the base commit the new 
`TestBytesResults` cases fail with `UnicodeDecodeError` for bytes that are not 
UTF-8 text, and get `str` back for bytes that are.
   
   ![Try 1: get_thumbnail runs live and the next tool 
fails](./e2e-try1-fails-records-bytes.png)
   
   ![Try 2: get_thumbnail replays and the model gets the same 
bytes](./e2e-try2-replays-same-bytes.png)
   
   ## Known issues
   
   Bytes in a field of a dataclass or Pydantic model are not converted. Bytes 
that are not UTF-8 text there still fail the task, and the rest can come back 
as text; the docs say to put bytes in a dict instead. Results of other 
capabilities' `@durable_operation` methods go through pydantic-ai's own result 
codec, not the journal's, so bytes there are not converted either. A tool that 
itself returns a dict whose only key is `__airflow_bytes__` with a valid base64 
string replays it as bytes.
   


-- 
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