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.


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