Decode base64 for bytes-typed HookToolset parameters - #70433
Conversation
|
It looks like the CI needs to be restarted. |
2621461 to
4b4c59b
Compare
|
Thanks — the bug is real and well diagnosed. The implementation handles the cases I'd want: One thing to settle before this goes in: what the string actually contains. The schema advertises Worth making the contract explicit rather than leaving it implied. Two directions, and I don't think this should be decided in a review thread alone — it sets the behaviour for every
Either is defensible; leaving it unstated is the option I'd avoid. Input from whoever owns Two smaller points:
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting |
|
Thanks! agreed on base64, since it's the only option that can carry real binary payloads without ever needing revisiting. bytes: {"type": "string", "contentEncoding": "base64"},Decoding with On the two smaller points: I'll narrow to Happy to hold for |
| ) -> Any: | ||
| method_name = name.removeprefix(self._tool_name_prefix) if self._tool_name_prefix else name | ||
| method: Callable[..., Any] = getattr(self._hook, method_name) | ||
| bytes_params = _bytes_param_names(method) |
There was a problem hiding this comment.
+1 on the base64 contract agreed above. One constraint for the rework: the decode has to stay here in call_tool rather than move into build_args_validator, even though #70096 makes the validator look like the natural home. Validated args flow through the whole toolset chain, and CachingToolset fingerprints tool_args before delegating (_digest is json.dumps with no default=), so bytes values there would turn every bytes-param call into an unverifiable fingerprint under durable=True. Building a new dict inside the innermost toolset, as this PR already does, keeps bytes out of that path, so worth a short code comment to make the placement survive the rework. Since contentEncoding is ignored by most function-calling APIs, the base64 instruction should also be appended to each parameter's schema description after the docstring enrichment loop in get_tools (that loop overwrites descriptions, and e.g. S3Hook.load_bytes's ":param bytes_data: bytes to set as content for the key" would otherwise be all the model sees), plus a sentence in toolsets.rst documenting the contract.
There was a problem hiding this comment.
Agree with all your points. I checked _digest, it is json.dumps without default=, so bytes in tool_args will fingerprint as None and those calls lose replay verification with durable=True. So keeping the decode in call_tool makes sense.
one more thing from my side.
Output side has the same problem. _serialize_for_llm passes bytes to json.dumps(..., default=str), so the model gets the Python repr:
json.dumps(b'\x89PNG', default=str) # -> "b'\\x89PNG'"GCSHook.download returns bytes and our docs recommend exposing such read-only methods. So download -> load_bytes through an agent already writes wrong data, and base64 only on the input side does not fix it. I think we should define both directions in this PR. @ColtenOuO FYI
There was a problem hiding this comment.
Confirmed, and agreed both directions belong here. Proposal below.
Same wire format both ways, so a round trip is a pass-through and the model never transforms anything:
def _json_default(value: Any) -> str:
if isinstance(value, (bytes, bytearray, memoryview)):
return _b64(value)
return str(value)
def _serialize_for_llm(value: Any) -> str:
if value is None:
return "null"
if isinstance(value, str):
return value
if isinstance(value, (bytes, bytearray, memoryview)):
return _b64(value)
try:
return json.dumps(value, default=_json_default)
except (TypeError, ValueError):
return str(value)Top-level bytes bypass json.dumps, mirroring the existing str early return — no surrounding quotes, so the value goes straight back into a bytes parameter. Nested bytes are handled by swapping default=, leaving str() as the fallback for datetimes and friends.
Correctness comes from the runtime isinstance, not from annotations, and deliberately so: download is overloaded on filename, CloudKMSHook.decrypt yields no hints at all, and nested dict[str, bytes] gives no signal either. Annotations only decide whether the model gets told — " Binary output is returned base64-encoded." appended to the description when the return annotation mentions bytes. That check is looser than the argument side's _resolves_to_bytes, which is safe here because it governs a sentence of documentation rather than how data is handled; the worst case is a missing hint, never a wrong payload.
Not adding contentEncoding to return_schema: download returns a path string when filename is passed, so the declaration would be wrong half the time. The argument side can declare it because parameter types are static.
toolsets.rst gets both directions plus the limitation that bytes nested inside a JSON structure are indistinguishable from ordinary strings once encoded.
4b4c59b to
3c42aec
Compare
|
Thanks all — reworked and rebased onto current Point by point:
One correction to what I said earlier in this thread. I wrote that decode failures would be re-raised as Two things that surfaced while implementing this: A leak I introduced and caught before pushing. Putting The annotation fix reaches further than the Deliberately left alone for now:
Drafted-by: Claude Code (Opus 5); reviewed by @ColtenOuO before posting |
JSON has no binary type, so HookToolset advertises a hook method's bytes-typed parameters as strings, and nothing converted them back. A method such as S3Hook.load_bytes received a str and failed deep inside BytesIO with a TypeError that gave no hint about where the bad value came from. What that string was meant to contain was never stated. Models routinely send base64 for binary payloads, and read as UTF-8 that text would have been written to storage verbatim — a successful upload of the wrong bytes. Declaring the encoding makes the contract explicit, and a value that is not valid base64 now fails loudly as a retryable error rather than being stored. The schema and the decoding are driven by one condition so a parameter can never be advertised as base64 without also being decoded. Annotations are resolved per parameter because get_type_hints is all-or-nothing: a single TYPE_CHECKING-only import elsewhere in a signature, as in CloudKMSHook.encrypt, would otherwise turn the conversion off for the whole method with nothing to show for it.
3c42aec to
98eff4a
Compare
|
Apologies for the slow reply :( I spent quite a bit of time reviewing the feedback and looking into the related issues today. Thank you all for the thoughtful discussion and feedback! |
Summary
JSON has no binary type, so
HookToolsetadvertises a hook method'sbytes-typedparameters to the model as strings — and nothing converted them back. A method such
as
S3Hook.load_bytes(bytes_data: bytes, ...)received a plainstrand failed deepinside
BytesIOwith an opaqueTypeError: a bytes-like object is required, not 'str',which gave no hint about where the bad value came from.
What that string was meant to contain was never stated either. Models routinely emit
base64 for binary payloads, so reading it as UTF-8 would have written the base64 text
to storage: a successful upload of the wrong bytes. The contract is now explicit.
Changes
bytes-typed parameters are advertised withcontentEncoding: "base64", and thesame instruction is appended to the parameter description — most function-calling
APIs drop the keyword, and the description is the field the model is guaranteed to see.
call_tooldecodes those parameters withbase64.b64decode(value, validate=True).A value that is not valid base64 is raised as
ModelRetry, the only exceptionpydantic-ai feeds back to the model, so it can correct the call instead of failing
the run. Decoding stays in the innermost toolset so
bytesnever reachCachingToolset, which fingerprints tool arguments with a plainjson.dumps.get_type_hintsis all-or-nothing, so oneTYPE_CHECKING-only import elsewhere in a signature —CloudKMSHook.encrypthasexactly that — would otherwise turn the conversion off for the whole method silently.
As a side effect that method's parameters stop being advertised as untyped.
bytesparameters come from a single introspectionpass at build time, driven by one condition, so a parameter can never be advertised
as base64 without also being decoded.
Only
bytesandbytes | Noneare affected. A parameter that already accepts text,such as
LambdaHook.invoke_lambda'spayload: bytes | str | None, is passed throughuntouched.
Still open
The return direction has the same problem —
_serialize_for_llmhandsbytestojson.dumps(..., default=str), so the model receives a Python repr. A proposal isunder discussion in the review thread and will follow once the shape is agreed.
Was generative AI tooling used to co-author this PR?