This is an automated email from the ASF dual-hosted git repository.

kaxil pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 4e8ab38306f Fix HookToolset calls to hook methods that take a datetime 
(#74759)
4e8ab38306f is described below

commit 4e8ab38306f2bec5262e2da465224d4635deb287
Author: Kaxil Naik <[email protected]>
AuthorDate: Thu Oct 15 09:24:15 2026 +0100

    Fix HookToolset calls to hook methods that take a datetime (#74759)
---
 providers/common/ai/docs/toolsets/hook.rst         |  16 ++
 .../airflow/providers/common/ai/toolsets/hook.py   | 133 +++++++++----
 .../ai/tests/unit/common/ai/toolsets/test_hook.py  | 216 +++++++++++++++------
 3 files changed, 275 insertions(+), 90 deletions(-)

diff --git a/providers/common/ai/docs/toolsets/hook.rst 
b/providers/common/ai/docs/toolsets/hook.rst
index 089d8949630..955a1072ce8 100644
--- a/providers/common/ai/docs/toolsets/hook.rst
+++ b/providers/common/ai/docs/toolsets/hook.rst
@@ -194,6 +194,22 @@ The value of ``base64`` can be passed to a ``bytes`` 
parameter as it is; text ha
 to be encoded by the model first. ``bytes`` nested in a returned list or dict 
are
 rendered as UTF-8 text, with any byte that does not decode replaced.
 
+.. _hook-toolset-datetimes:
+
+Dates and times
+---------------
+
+JSON has no date type either. A parameter typed ``datetime`` or ``datetime | 
None``,
+such as ``from_datetime`` of ``S3Hook.list_keys``, is offered to the model as 
a string
+in ``date-time`` format whose description asks for ISO 8601 with a UTC offset, 
and the
+value is parsed into a timezone-aware ``datetime`` before the method is 
called. A value
+with no offset, such as ``2026-09-01T00:00:00``, or one that does not parse, 
is refused
+while the call's arguments are validated, and the model is asked to correct 
the call.
+The toolset does not assume a timezone for it, because hooks disagree on one:
+``S3Hook.list_keys`` and ``GCSHook.list_by_timespan`` raise ``TypeError`` for 
a naive
+value, while ``GCSHook.is_updated_after`` treats it as UTC. A ``datetime`` 
nested in
+another type, such as ``list[datetime]``, is not converted.
+
 When to choose it
 -----------------
 
diff --git 
a/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py 
b/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
index 5a6fa60c0c3..aa35dcc80b6 100644
--- a/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
+++ b/providers/common/ai/src/airflow/providers/common/ai/toolsets/hook.py
@@ -25,6 +25,8 @@ import inspect
 import re
 import types
 import warnings
+from dataclasses import dataclass
+from datetime import datetime
 from typing import TYPE_CHECKING, Any, Union, get_args, get_origin, 
get_type_hints
 
 from pydantic_ai.exceptions import ModelRetry
@@ -58,6 +60,50 @@ _TYPE_MAP: dict[type, dict[str, Any]] = {
 }
 
 _BASE64_PARAM_NOTE = "Provide this value base64-encoded."
+_DATETIME_PARAM_NOTE = "An ISO 8601 date-time with a UTC offset, such as 
2026-09-01T00:00:00Z."
+
+
+def _decode_base64(value: str) -> bytes:
+    # Whitespace never carries data, so line-wrapped base64 is accepted. 
validate=True
+    # because the default discards any other character outside the alphabet 
instead of
+    # failing, which would store the wrong bytes without a word.
+    return base64.b64decode("".join(value.split()), validate=True)
+
+
+def _parse_aware_datetime(value: str) -> datetime:
+    parsed = datetime.fromisoformat(value)
+    if parsed.utcoffset() is None:
+        # A naive value is ambiguous: S3Hook.list_keys and 
GCSHook.list_by_timespan raise
+        # TypeError comparing it with an aware time, other hooks assume UTC 
for it.
+        raise ValueError(f"{value!r} has no UTC offset")
+    return parsed
+
+
+@dataclass(frozen=True)
+class _StringEncoding:
+    """How a parameter type JSON cannot carry is sent as a string, and turned 
back."""
+
+    schema: dict[str, Any]
+    """The JSON Schema of the string."""
+    note: str
+    """Added to the parameter's description."""
+    expected: str
+    """What the value must be, for the retry prompt."""
+    decode: Callable[[str], Any]
+    """Raises ``ValueError`` for a value it cannot decode."""
+
+
+_STRING_ENCODINGS: dict[type, _StringEncoding] = {
+    bytes: _StringEncoding(
+        {"type": "string"}, _BASE64_PARAM_NOTE, "base64-encoded binary data", 
_decode_base64
+    ),
+    datetime: _StringEncoding(
+        {"type": "string", "format": "date-time"},
+        _DATETIME_PARAM_NOTE,
+        "an ISO 8601 date-time with a UTC offset",
+        _parse_aware_datetime,
+    ),
+}
 
 
 class HookToolset(AirflowToolset):
@@ -175,7 +221,7 @@ class HookToolset(AirflowToolset):
         if conn_attr is None or not hasattr(hook, conn_attr):
             conn_attr = "conn_id" if hasattr(hook, "conn_id") else None
         self._conn_attr = conn_attr
-        self._bytes_params = {
+        self._encoded_params = {
             method_name: _introspect_signature(getattr(hook, method_name))[1]
             for method_name in allowed_methods
         }
@@ -208,7 +254,7 @@ class HookToolset(AirflowToolset):
             method = getattr(self._hook, method_name)
             tool_name = f"{self._name_prefix}{method_name}"
 
-            json_schema, bytes_params = _introspect_signature(method)
+            json_schema, encoded_params = _introspect_signature(method)
             description = _extract_description(method)
             param_docs = _parse_param_docs(method.__doc__ or "")
             properties = json_schema["properties"]
@@ -220,10 +266,10 @@ class HookToolset(AirflowToolset):
             # After the loop above, which would otherwise overwrite the note. 
The encoding is
             # stated in the description rather than as ``contentEncoding``: 
Gemini drops that
             # keyword, and pydantic-ai passes it through to OpenAI strict mode.
-            for param_name in bytes_params:
+            for param_name, encoding in encoded_params.items():
                 existing = properties[param_name].get("description")
                 properties[param_name]["description"] = (
-                    f"{existing} {_BASE64_PARAM_NOTE}" if existing else 
_BASE64_PARAM_NOTE
+                    f"{existing} {encoding.note}" if existing else 
encoding.note
                 )
             _drop_properties(json_schema, self._pinned)
             # The validator accepts the pinned names, with any value, so a 
model that sends one
@@ -254,18 +300,20 @@ class HookToolset(AirflowToolset):
                 args_validator=build_args_validator(args_schema),
                 # Refused during validation, so an approval gate never asks 
about such a call.
                 args_validator_func=(
-                    functools.partial(self._validate_args, bytes_params)
-                    if self._pinned or bytes_params
+                    functools.partial(self._validate_args, encoded_params)
+                    if self._pinned or encoded_params
                     else None
                 ),
             )
         return tools
 
-    def _validate_args(self, bytes_params: frozenset[str], ctx: 
RunContext[Any], /, **tool_args: Any) -> None:
+    def _validate_args(
+        self, encoded_params: dict[str, _StringEncoding], ctx: 
RunContext[Any], /, **tool_args: Any
+    ) -> None:
         self._refuse_pinned(ctx, **tool_args)
-        # Only checked here: the decoded bytes are not kept, so what the 
toolset chain passes
+        # Only checked here: the decoded values are not kept, so what the 
toolset chain passes
         # on, and the durable journal fingerprints, stays JSON.
-        _decode_bytes_args(tool_args, bytes_params)
+        _decode_string_args(tool_args, encoded_params)
 
     def _refuse_pinned(self, ctx: RunContext[Any], /, **tool_args: Any) -> 
None:
         if supplied := sorted(self._pinned.keys() & tool_args.keys()):
@@ -289,8 +337,8 @@ class HookToolset(AirflowToolset):
         self._refuse_pinned(ctx, **tool_args)
         # Decoded here rather than in the args validator: validated arguments 
travel the whole
         # toolset chain, and the durable journal fingerprints them with a 
plain json.dumps, so bytes
-        # upstream of this point would make every binary call unverifiable on 
durable replay.
-        tool_args = _decode_bytes_args(tool_args, 
self._bytes_params[method_name])
+        # or datetimes upstream of this point would make such calls 
unverifiable on durable replay.
+        tool_args = _decode_string_args(tool_args, 
self._encoded_params[method_name])
         # A copy per call, so a method that modifies an argument it is given 
cannot change the pin.
         result = await self.run_blocking(method, **tool_args, 
**copy.deepcopy(self._pinned))
         return serialize_for_llm(result)
@@ -329,31 +377,41 @@ def _python_type_to_json_schema(annotation: Any) -> 
dict[str, Any]:
     return dict(schema) if schema else {}
 
 
-def _resolves_to_bytes(annotation: Any) -> bool:
-    """Whether ``annotation`` is ``bytes`` or ``bytes | None``."""
-    if annotation is bytes:
-        return True
+def _string_encoding(annotation: Any) -> _StringEncoding | None:
+    """Return the encoding of ``annotation`` when it is ``T`` or ``T | None`` 
for a ``T`` in ``_STRING_ENCODINGS``."""
     if get_origin(annotation) in (types.UnionType, Union):
         non_none = [arg for arg in get_args(annotation) if arg is not 
type(None)]
-        return len(non_none) == 1 and _resolves_to_bytes(non_none[0])
-    return False
+        return _string_encoding(non_none[0]) if len(non_none) == 1 else None
+    return _STRING_ENCODINGS.get(annotation)
 
 
-def _decode_bytes_args(tool_args: dict[str, Any], bytes_params: 
frozenset[str]) -> dict[str, Any]:
-    """Decode the base64 strings the model supplied for ``bytes``-typed 
parameters."""
+def _string_encoded_schema(annotation: Any, encoding: _StringEncoding) -> 
dict[str, Any]:
+    """Return ``encoding.schema`` for ``T``, and it or null for ``T | 
None``."""
+    if get_origin(annotation) in (types.UnionType, Union):
+        return {
+            "anyOf": [
+                {"type": "null"} if arg is type(None) else 
_string_encoded_schema(arg, encoding)
+                for arg in get_args(annotation)
+            ]
+        }
+    return dict(encoding.schema)
+
+
+def _decode_string_args(
+    tool_args: dict[str, Any], encoded_params: dict[str, _StringEncoding]
+) -> dict[str, Any]:
+    """Decode the strings the model supplied for parameters of a type JSON 
cannot carry."""
     decoded = dict(tool_args)
-    for name in bytes_params & tool_args.keys():
+    for name in encoded_params.keys() & tool_args.keys():
         value = tool_args[name]
         if not isinstance(value, str):
             continue
+        encoding = encoded_params[name]
         try:
-            # Whitespace never carries data, so line-wrapped base64 is 
accepted. validate=True
-            # because the default discards any other character outside the 
alphabet instead of
-            # failing, which would store the wrong bytes without a word.
-            decoded[name] = base64.b64decode("".join(value.split()), 
validate=True)
+            decoded[name] = encoding.decode(value)
         except ValueError as e:
             # ModelRetry asks the model to correct the call; a ValueError 
would fail the run.
-            raise ModelRetry(f"Parameter {name!r} must be base64-encoded 
binary data: {e}.") from e
+            raise ModelRetry(f"Parameter {name!r} must be {encoding.expected}: 
{e}.") from e
     return decoded
 
 
@@ -380,19 +438,21 @@ def _resolve_annotations(method: Callable[..., Any]) -> 
dict[str, Any]:
     return hints
 
 
-def _introspect_signature(method: Callable[..., Any]) -> tuple[dict[str, Any], 
frozenset[str]]:
+def _introspect_signature(
+    method: Callable[..., Any],
+) -> tuple[dict[str, Any], dict[str, _StringEncoding]]:
     """
-    Build ``method``'s JSON Schema and the names of its ``bytes``-typed 
parameters.
+    Build ``method``'s JSON Schema and the string encoding of each parameter 
that needs one.
 
-    Both come from one pass, so the parameters described to the model as 
base64 are
-    exactly the ones decoded on the way back.
+    Both come from one pass, so the parameters described to the model as an 
encoded
+    string are exactly the ones decoded on the way back.
     """
     sig = inspect.signature(method)
     hints = _resolve_annotations(method)
 
     properties: dict[str, Any] = {}
     required: list[str] = []
-    bytes_params: set[str] = set()
+    encoded_params: dict[str, _StringEncoding] = {}
     allows_additional_properties = False
 
     for name, param in sig.parameters.items():
@@ -405,10 +465,13 @@ def _introspect_signature(method: Callable[..., Any]) -> 
tuple[dict[str, Any], f
             continue
 
         annotation = hints.get(name, param.annotation)
-        prop = _python_type_to_json_schema(annotation)
-        properties[name] = prop
-        if _resolves_to_bytes(annotation):
-            bytes_params.add(name)
+        if encoding := _string_encoding(annotation):
+            # Only here, not in _TYPE_MAP: a datetime nested in a list is not 
decoded, so it is
+            # not described as an encoded string either.
+            properties[name] = _string_encoded_schema(annotation, encoding)
+            encoded_params[name] = encoding
+        else:
+            properties[name] = _python_type_to_json_schema(annotation)
 
         if param.default is inspect.Parameter.empty:
             required.append(name)
@@ -418,7 +481,7 @@ def _introspect_signature(method: Callable[..., Any]) -> 
tuple[dict[str, Any], f
         schema["required"] = required
     if allows_additional_properties:
         schema["additionalProperties"] = True
-    return schema, frozenset(bytes_params)
+    return schema, encoded_params
 
 
 def _extract_description(method: Callable[..., Any]) -> str:
diff --git a/providers/common/ai/tests/unit/common/ai/toolsets/test_hook.py 
b/providers/common/ai/tests/unit/common/ai/toolsets/test_hook.py
index 0c5b3314430..cdd31796a1e 100644
--- a/providers/common/ai/tests/unit/common/ai/toolsets/test_hook.py
+++ b/providers/common/ai/tests/unit/common/ai/toolsets/test_hook.py
@@ -22,6 +22,7 @@ import contextlib
 import json
 import re
 import threading
+from datetime import UTC, datetime, timedelta, timezone
 from typing import TYPE_CHECKING
 from unittest.mock import MagicMock
 
@@ -36,6 +37,7 @@ from pydantic_core import ValidationError
 from airflow.exceptions import AirflowProviderDeprecationWarning
 from airflow.providers.common.ai.toolsets.hook import (
     _BASE64_PARAM_NOTE,
+    _DATETIME_PARAM_NOTE,
     HookToolset,
     _extract_description,
     _introspect_signature,
@@ -89,6 +91,13 @@ class _FakeHook:
         """Return the bytes it is given."""
         return data
 
+    def list_modified(self, since: datetime, until: datetime | None = None) -> 
str:
+        """List objects modified in a period.
+
+        :param since: Start of the period.
+        """
+        return f"since={since!r} until={until!r}"
+
 
 class TestHookToolsetInit:
     def test_requires_non_empty_allowed_methods(self):
@@ -295,6 +304,21 @@ class TestHookToolsetGetTools:
             "description": _BASE64_PARAM_NOTE,
         }
 
+    def test_datetime_params_ask_for_iso_8601_with_an_offset(self):
+        ts = HookToolset(_FakeHook(), allowed_methods=["list_modified"])
+        tools = asyncio.run(ts.get_tools(ctx=MagicMock(spec=RunContext)))
+
+        props = 
tools["list_modified"].tool_def.parameters_json_schema["properties"]
+        assert props["since"] == {
+            "type": "string",
+            "format": "date-time",
+            "description": f"Start of the period. {_DATETIME_PARAM_NOTE}",
+        }
+        assert props["until"] == {
+            "anyOf": [{"type": "string", "format": "date-time"}, {"type": 
"null"}],
+            "description": _DATETIME_PARAM_NOTE,
+        }
+
 
 class TestHookToolsetArgsValidator:
     @pytest.fixture
@@ -400,14 +424,14 @@ class TestHookToolsetCallTool:
         assert calls[0] != loop_thread
 
 
-class TestHookToolsetBytes:
-    @staticmethod
-    def _call(method_name, tool_args):
-        ts = HookToolset(_FakeHook(), allowed_methods=[method_name])
-        ctx = MagicMock(spec=RunContext)
-        tools = asyncio.run(ts.get_tools(ctx=ctx))
-        return asyncio.run(ts.call_tool(method_name, tool_args, ctx=ctx, 
tool=tools[method_name]))
+def _call_tool(method_name, tool_args):
+    ts = HookToolset(_FakeHook(), allowed_methods=[method_name])
+    ctx = MagicMock(spec=RunContext)
+    tools = asyncio.run(ts.get_tools(ctx=ctx))
+    return asyncio.run(ts.call_tool(method_name, tool_args, ctx=ctx, 
tool=tools[method_name]))
+
 
+class TestHookToolsetBytes:
     @pytest.mark.parametrize(
         ("method_name", "tool_args", "expected"),
         [
@@ -429,7 +453,7 @@ class TestHookToolsetBytes:
         ],
     )
     def test_decodes_base64_for_bytes_params(self, method_name, tool_args, 
expected):
-        assert self._call(method_name, tool_args) == expected
+        assert _call_tool(method_name, tool_args) == expected
 
     @pytest.mark.parametrize(
         "value",
@@ -442,64 +466,128 @@ class TestHookToolsetBytes:
     )
     def test_an_undecodable_value_asks_the_model_to_retry(self, value):
         with pytest.raises(ModelRetry, match="'data' must be base64-encoded"):
-            self._call("upload_bytes", {"data": value, "key": "greeting.txt"})
+            _call_tool("upload_bytes", {"data": value, "key": "greeting.txt"})
 
     def test_binary_round_trips_byte_exact(self):
         payload = b"\x89PNG\r\n\x1a\n\xff\x00"
 
-        result = self._call("echo_bytes", {"data": 
base64.b64encode(payload).decode("ascii")})
+        result = _call_tool("echo_bytes", {"data": 
base64.b64encode(payload).decode("ascii")})
 
         assert base64.b64decode(json.loads(result)["base64"]) == payload
 
     def test_a_utf8_result_reaches_the_model_as_text(self):
-        result = self._call("echo_bytes", {"data": 
base64.b64encode("naïve,1\n".encode()).decode("ascii")})
+        result = _call_tool("echo_bytes", {"data": 
base64.b64encode("naïve,1\n".encode()).decode("ascii")})
 
         assert result == "naïve,1\n"
 
-    def test_a_bad_value_is_corrected_by_the_model_in_a_real_run(self):
-        hook = _FakeHook()
-        ts = HookToolset(hook, allowed_methods=["upload_bytes"])
-        attempts = iter(
-            [{"data": "hello world", "key": "k"}, {"data": 
base64.b64encode(b"hello").decode(), "key": "k"}]
-        )
+    def test_a_pinned_bytes_argument_is_passed_as_given(self):
+        ts = HookToolset(_FakeHook(), allowed_methods=["upload_bytes"], 
pinned_arguments={"data": b"raw"})
+        ctx = MagicMock(spec=RunContext)
+        tools = asyncio.run(ts.get_tools(ctx=ctx))
+
+        result = asyncio.run(ts.call_tool("upload_bytes", {"key": "k"}, 
ctx=ctx, tool=tools["upload_bytes"]))
+
+        assert result == "uploaded 3 bytes to k (type=bytes)"
+
+
+class TestHookToolsetDatetime:
+    @pytest.mark.parametrize(
+        ("tool_args", "expected_since", "expected_until"),
+        [
+            pytest.param(
+                {"since": "2026-09-01T00:00:00Z"},
+                datetime(2026, 9, 1, tzinfo=UTC),
+                None,
+                id="utc-z",
+            ),
+            pytest.param(
+                {"since": "2026-09-01T05:30:00+05:30", "until": 
"2026-09-30T23:59:59+00:00"},
+                datetime(2026, 9, 1, 5, 30, tzinfo=timezone(timedelta(hours=5, 
minutes=30))),
+                datetime(2026, 9, 30, 23, 59, 59, tzinfo=UTC),
+                id="offsets",
+            ),
+            pytest.param(
+                {"since": "2026-09-01T00:00:00Z", "until": None},
+                datetime(2026, 9, 1, tzinfo=UTC),
+                None,
+                id="explicit-null",
+            ),
+        ],
+    )
+    def test_parses_iso_8601_for_datetime_params(self, tool_args, 
expected_since, expected_until):
+        assert _call_tool("list_modified", tool_args) == 
f"since={expected_since!r} until={expected_until!r}"
+
+    @pytest.mark.parametrize(
+        ("value", "reason"),
+        [
+            pytest.param("2026-09-01T00:00:00", "has no UTC offset", 
id="naive"),
+            pytest.param("2026-09-01", "has no UTC offset", id="date-only"),
+            pytest.param("last tuesday", "Invalid isoformat string", 
id="not-iso-8601"),
+        ],
+    )
+    def 
test_a_value_without_an_offset_or_unparsable_asks_the_model_to_retry(self, 
value, reason):
+        with pytest.raises(
+            ModelRetry, match=f"'since' must be an ISO 8601 date-time with a 
UTC offset: .*{reason}"
+        ):
+            _call_tool("list_modified", {"since": value})
+
+
+_ENCODED_ARG_CASES = [
+    pytest.param(
+        "upload_bytes",
+        {"data": "hello world", "key": "k"},
+        {"data": base64.b64encode(b"hello").decode(), "key": "k"},
+        "'data' must be base64-encoded binary data",
+        "uploaded 5 bytes to k (type=bytes)",
+        id="bytes",
+    ),
+    pytest.param(
+        "list_modified",
+        {"since": "2026-09-01T00:00:00"},
+        {"since": "2026-09-01T00:00:00Z"},
+        "'since' must be an ISO 8601 date-time with a UTC offset",
+        f"since={datetime(2026, 9, 1, tzinfo=UTC)!r} until=None",
+        id="datetime",
+    ),
+]
+
+
+class TestHookToolsetEncodedArgs:
+    @pytest.mark.parametrize(("method_name", "bad", "good", "retry", 
"result"), _ENCODED_ARG_CASES)
+    def test_a_bad_value_is_corrected_by_the_model_in_a_real_run(self, 
method_name, bad, good, retry, result):
+        ts = HookToolset(_FakeHook(), allowed_methods=[method_name])
+        attempts = iter([bad, good])
 
         def model(messages, info):
             retried = [p for m in messages for p in m.parts if isinstance(p, 
RetryPromptPart)]
             returned = [p for m in messages for p in m.parts if isinstance(p, 
ToolReturnPart)]
             if returned:
                 return ModelResponse(parts=[TextPart(f"{retried[0].content} | 
{returned[0].content}")])
-            return ModelResponse(parts=[ToolCallPart("upload_bytes", 
next(attempts), tool_call_id="c")])
+            return ModelResponse(parts=[ToolCallPart(method_name, 
next(attempts), tool_call_id="c")])
 
-        answer = Agent(FunctionModel(model), 
toolsets=[ts]).run_sync("upload").output
+        answer = Agent(FunctionModel(model), 
toolsets=[ts]).run_sync("go").output
 
-        assert "'data' must be base64-encoded binary data" in answer
-        assert answer.endswith("uploaded 5 bytes to k (type=bytes)")
+        assert retry in answer
+        assert answer.endswith(result)
 
-    def test_an_approval_gate_is_never_asked_about_an_undecodable_value(self):
+    @pytest.mark.parametrize(("method_name", "bad", "good", "retry", 
"result"), _ENCODED_ARG_CASES)
+    def test_an_approval_gate_is_never_asked_about_an_undecodable_value(
+        self, method_name, bad, good, retry, result
+    ):
         """A task instance can pause for approval only once, so the request 
must be one that can run."""
-        ts = HookToolset(_FakeHook(), allowed_methods=["upload_bytes"])
-        valid = {"data": base64.b64encode(b"hello").decode(), "key": "k"}
-        attempts = iter([{"data": "hello world", "key": "k"}, valid])
+        ts = HookToolset(_FakeHook(), allowed_methods=[method_name])
+        attempts = iter([bad, good])
 
         def model(messages, info):
-            return ModelResponse(parts=[ToolCallPart("upload_bytes", 
next(attempts), tool_call_id="c")])
+            return ModelResponse(parts=[ToolCallPart(method_name, 
next(attempts), tool_call_id="c")])
 
         agent = Agent(
             FunctionModel(model), toolsets=[ts.approval_required()], 
output_type=[str, DeferredToolRequests]
         )
-        output = agent.run_sync("upload").output
+        output = agent.run_sync("go").output
 
         assert isinstance(output, DeferredToolRequests)
-        assert [call.args for call in output.approvals] == [valid]
-
-    def test_a_pinned_bytes_argument_is_passed_as_given(self):
-        ts = HookToolset(_FakeHook(), allowed_methods=["upload_bytes"], 
pinned_arguments={"data": b"raw"})
-        ctx = MagicMock(spec=RunContext)
-        tools = asyncio.run(ts.get_tools(ctx=ctx))
-
-        result = asyncio.run(ts.call_tool("upload_bytes", {"key": "k"}, 
ctx=ctx, tool=tools["upload_bytes"]))
-
-        assert result == "uploaded 3 bytes to k (type=bytes)"
+        assert [call.args for call in output.approvals] == [good]
 
 
 def _takes_bytes(value: bytes): ...
@@ -520,34 +608,52 @@ def _takes_str(value: str): ...
 def _takes_unannotated(value): ...
 
 
-class TestBytesParamDetection:
+def _takes_datetime(value: datetime): ...
+
+
+def _takes_optional_datetime(value: datetime | None = None): ...
+
+
+def _takes_datetime_or_str(value: datetime | str): ...
+
+
+def _takes_list_of_datetimes(value: list[datetime]): ...
+
+
+class TestStringEncodedParamDetection:
     @pytest.mark.parametrize(
-        ("func", "is_bytes_param"),
+        ("func", "note"),
         [
-            pytest.param(_takes_bytes, True, id="bytes"),
-            pytest.param(_takes_optional_bytes, True, id="optional-bytes"),
-            pytest.param(_takes_bytes_or_str, False, id="bytes-or-str"),
-            pytest.param(_takes_list_of_bytes, False, id="list-of-bytes"),
-            pytest.param(_takes_str, False, id="str"),
-            pytest.param(_takes_unannotated, False, id="unannotated"),
+            pytest.param(_takes_bytes, _BASE64_PARAM_NOTE, id="bytes"),
+            pytest.param(_takes_optional_bytes, _BASE64_PARAM_NOTE, 
id="optional-bytes"),
+            pytest.param(_takes_bytes_or_str, None, id="bytes-or-str"),
+            pytest.param(_takes_list_of_bytes, None, id="list-of-bytes"),
+            pytest.param(_takes_datetime, _DATETIME_PARAM_NOTE, id="datetime"),
+            pytest.param(_takes_optional_datetime, _DATETIME_PARAM_NOTE, 
id="optional-datetime"),
+            pytest.param(_takes_datetime_or_str, None, id="datetime-or-str"),
+            pytest.param(_takes_list_of_datetimes, None, 
id="list-of-datetimes"),
+            pytest.param(_takes_str, None, id="str"),
+            pytest.param(_takes_unannotated, None, id="unannotated"),
         ],
     )
-    def test_only_bytes_and_optional_bytes_are_decoded(self, func, 
is_bytes_param):
+    def test_only_a_type_or_optional_type_is_decoded(self, func, note):
         hook = type("_Hook", (), {"method": staticmethod(func)})()
         ts = HookToolset(hook, allowed_methods=["method"])
         tools = asyncio.run(ts.get_tools(ctx=MagicMock(spec=RunContext)))
         prop = 
tools["method"].tool_def.parameters_json_schema["properties"]["value"]
+        encoding = _introspect_signature(func)[1].get("value")
 
-        assert ("value" in _introspect_signature(func)[1]) is is_bytes_param
-        # A parameter described as base64 but never decoded would hand the 
hook the encoded text.
-        assert (_BASE64_PARAM_NOTE in prop.get("description", "")) is 
is_bytes_param
+        # A parameter described as encoded but never decoded would hand the 
hook the encoded text.
+        assert prop.get("description") == note
+        assert (encoding.note if encoding else None) == note
+        assert ('"date-time"' in json.dumps(prop)) is (note == 
_DATETIME_PARAM_NOTE)
 
     def test_var_args_are_never_decoded(self):
         def fn(*chunks: bytes, **extra: bytes): ...
 
-        schema, bytes_params = _introspect_signature(fn)
+        schema, encoded_params = _introspect_signature(fn)
 
-        assert bytes_params == frozenset()
+        assert encoded_params == {}
         assert schema["properties"] == {}
 
     def test_an_unresolvable_annotation_does_not_hide_the_other_params(self):
@@ -555,9 +661,9 @@ class TestBytesParamDetection:
         # TYPE_CHECKING-only import.
         def fn(data: bytes, precision: Decimal | None = None): ...
 
-        schema, bytes_params = _introspect_signature(fn)
+        schema, encoded_params = _introspect_signature(fn)
 
-        assert bytes_params == {"data"}
+        assert encoded_params.keys() == {"data"}
         assert schema["properties"]["data"]["type"] == "string"
 
     def 
test_a_name_missing_from_an_imported_module_does_not_hide_the_other_params(self):
@@ -566,9 +672,9 @@ class TestBytesParamDetection:
         # Raises AttributeError, not NameError, when evaluated: ``json`` is 
imported.
         fn.__annotations__["other"] = "json.NoSuchType | None"
 
-        schema, bytes_params = _introspect_signature(fn)
+        schema, encoded_params = _introspect_signature(fn)
 
-        assert bytes_params == {"data"}
+        assert encoded_params.keys() == {"data"}
         assert schema["properties"]["other"] == {}
 
 

Reply via email to