Skip to content

Commit 736c349

Browse files
YAshhh29kaxil
andauthored
Fingerprint durable agent steps that hold sets or values that are not JSON (#71575)
Plain JSON tool arguments and settings hash exactly as before, and the message history and request parameters are still hashed from pydantic's json-mode dump, so stored fingerprints keep matching. Other values go through to_jsonable_python, so datetime, Decimal, dataclasses, bytes and non-string dict keys fingerprint instead of coming back as None. The json rendering is paired with pydantic's python-mode dump of the same payload, which has the same shape but keeps sets as sets and wraps an iterator instead of reading it. A list is sorted where the dump holds a set, since a set of strings otherwise hashes differently in every process. Dict keys that collide once rendered, and anything that renders through an iterator, are refused rather than hashed. Tool arguments are rendered from deep copies, so fingerprinting cannot change what the tool receives. Whatever rendering raises costs the step a live re-run, never the task, and both warnings name the step, the error and its type. * Do not replay or cache AI steps that cannot be verified A request that could not be fingerprinted came back as None, and None equalled the None stored with the entry, so the replay guard passed for exactly the requests it could not verify. The guard now requires a real fingerprint, and such a step is no longer written to the cache, since an entry without a fingerprint could never be replayed. It counts as skipped, so the end-of-run warning names it, and a usable entry from an earlier attempt survives instead of being overwritten. * Document what the durable fingerprint covers and refuses Says which values fingerprint, what is refused and why, what changes for entries cached by an earlier version, and when a step that cannot be fingerprinted costs the rest of the run. * Sort a set only where its rendering is found A list was sorted wherever the python-mode dump had a set at the same place, which a serializer that applies only in JSON mode could defeat by putting another list there. A list is now sorted only where it holds the members of that set, rendered on their own. A set that cannot be found is refused when its order follows the hash seed, and kept as rendered when it does not, such as a set of integers. Non-string dict keys are rendered on their own, so a serializer that drops or reorders entries is no longer mistaken for colliding keys, or paired with a neighbouring value. A set of models or dataclass instances, or a dict keyed by one, has no python-mode dump. The message history and request parameters then hash from the JSON rendering alone, as they did before. The hash-seed tests share one snippet run under two seeds. * Order sets holding NaN and refuse bytes keys that collide in a model A NaN hashes by its identity, so a set holding one iterated differently in every run, and the message dump renders it as null where the set's own rendering kept it, so the set was never found and was hashed in that order. The set's own rendering now also tries the null mode, and a NaN no longer counts as seed-free. Inside a model, b"a" and "a" render as one key, which rendering the keys on their own did not show, so two different payloads could share a digest. A dict with a bytes key that renders fewer keys is now refused, as one with a nan or inf key already was. The docs now say that a set behind a JSON-only serializer that renames or reshapes it is refused even when that serializer sorts it, that a dict keyed by a model is only refused from pydantic 2.13, and that a value python mode cannot dump turns off set ordering for the whole history. Tests pin the template branches for enum members, tuples holding sets, dict members and the empty set, and add those set shapes to the cross-seed run. The two pairs of caching tests that differed only in whether an entry already existed are now one parametrized test each. --------- Co-authored-by: Kaxil Naik <kaxilnaik@gmail.com>
1 parent e1d881b commit 736c349

4 files changed

Lines changed: 2173 additions & 35 deletions

File tree

‎providers/common/ai/docs/durable_execution.rst‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,55 @@ In a plain ``@task``, which has no such moment, the capability deletes a run's j
193193
as soon as that run succeeds: after a failure, only the run that failed replays, and
194194
the agents that ran before it run again.
195195

196+
Values that are not plain JSON
197+
------------------------------
198+
199+
Plain JSON tool arguments and settings are hashed as they are. Anything else is
200+
fingerprinted from pydantic's JSON rendering, so ordinary types that are not JSON, such
201+
as a ``datetime`` or ``Decimal`` tool argument, a dataclass in ``tool_choice``, the
202+
bytes in a ``BinaryContent`` or a dict keyed by date, fingerprint normally. Bytes in
203+
tool arguments and settings are rendered as base64, so binary data that is not valid
204+
UTF-8 fingerprints too; bytes inside a pydantic model follow that model's
205+
``ser_json_bytes`` setting instead, which renders them as UTF-8 text by default. Because
206+
a tool call is fingerprinted from how its arguments render, a field excluded from
207+
serialization and a secret value, which renders masked, take no part in it.
208+
209+
The members of a ``set`` are ordered before hashing. A set of strings iterates in an
210+
order that changes from one process to the next, and every attempt is a new process, so
211+
without this a step holding one would never match on retry. A list that a serializer
212+
produces is hashed as the serializer produced it, so a set that a serializer turns into
213+
a list may not match on retry. The one list that is re-sorted is the output of a
214+
JSON-only serializer (``when_used="json"``) that returns the members of a set: it cannot
215+
be told from the set itself, so ``["z-first", "a"]`` hashes as ``["a", "z-first"]``.
216+
217+
A request cannot be fingerprinted when:
218+
219+
- two distinct dict keys render alike, such as ``1`` and ``"1"``, since the fingerprint
220+
could then not tell those payloads apart;
221+
- a set whose order changes between processes cannot be found in the rendering, because
222+
its members have different shapes, or because a JSON-only serializer renamed or
223+
reshaped it, even one that also sorts it;
224+
- a value would render through an iterator, since rendering would consume it. A
225+
parameter annotated ``Iterable[...]`` is the usual case: pydantic validates it lazily,
226+
and reading it to hash it would consume input the tool has not read yet. Tool
227+
arguments are rendered from copies, so fingerprinting never changes what the tool
228+
receives, and an argument that cannot be copied is not fingerprinted either;
229+
- pydantic cannot render a value at all.
230+
231+
Such a step is handled like a step with no request to fingerprint: it replays on its
232+
name, and only when the step before it replayed. The task log names it with a
233+
``could not fingerprint model request`` or ``could not fingerprint tool call`` warning.
234+
On the model path the cause is rarely confined to one step. ``model_settings`` and the
235+
tool definitions travel with every request, and a value that enters the message
236+
history, for example in a tool return, is carried into every later request, so every
237+
model step from that point on is replayed without verification.
238+
239+
A set of models or dataclass instances, and from pydantic 2.13 a dict keyed by a model
240+
or dataclass instance, is a special case. In a tool argument or a setting it cannot be
241+
fingerprinted. In the message history it is rendered, and the history is then hashed
242+
from its JSON rendering alone: nothing in it is ordered and nothing is refused, so a set
243+
of strings elsewhere in that history can make the run diverge at that step on retry.
244+
196245
.. _durable-code-mode:
197246

198247
Code mode

0 commit comments

Comments
 (0)