Skip to content

Commit 454340c

Browse files
andreasrongeclaude
andcommitted
fix(datetime): drop format keyword from schema, fix fractional-second warning
Two findings from codex review of the :datetime feature. [P2] Remove `format: "date-time"` from the JSON Schema for `:datetime`. OpenAI's strict-mode structured output (the very provider I optimized for) rejects unsupported keywords including `format`, which would 400 any request that paired `:datetime` with `response_format: {type: "json_schema", strict: true}` BEFORE local coercion got a chance to run. Schema is now a plain `{"type": "string"}`. Local coercion still validates ISO 8601 + offset, and the prompt-side example value ("2026-05-03T09:14:00Z") covers the LLM-guidance role that `format` would have played. [P3] Fix the non-UTC warning for fractional-second timestamps. The old shape check used a binary pattern matching exactly 19 chars before the offset (`<<_::binary-19, "Z", _::binary>>`). Inputs like "2026-05-03T09:14:00.123Z" have 23 chars before the Z and were falsely flagged as non-UTC even though they're valid UTC. Switched to checking the parsed offset (0 = UTC) returned by `DateTime.from_iso8601/1`, which is the source of truth and indifferent to fractional seconds. Tests: - Updated the schema-shape assertions in signature_test.exs and text_mode_json_test.exs to expect plain string schema (no format). - Two new regression tests in coercion_test.exs covering "...Z" and "...+00:00" with fractional seconds, asserting no spurious non-UTC warning. Docs: signature-syntax.md updated to explain why format is omitted (strict-mode compatibility) instead of overclaiming the server-side enforcement benefit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e719306 commit 454340c

6 files changed

Lines changed: 59 additions & 22 deletions

File tree

docs/signature-syntax.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,12 @@ DateTime.diff(DateTime.utc_now(), step.return["at"]) # works directly
7171
| `"2026-05-03T11:14:00+02:00"` | Shifted to `~U[2026-05-03 09:14:00Z]` with a "non-UTC offset" warning |
7272
| `"2026-05-03T09:14:00"` (no offset) | Validation error — naive strings are ambiguous and rejected at the type boundary |
7373

74-
The JSON Schema sent to the LLM provider includes `format: "date-time"`. OpenAI's
75-
structured output enforces this server-side; Anthropic treats it as guidance.
76-
Either way, coercion validates the string locally so an invalid date never
77-
reaches the caller.
74+
The JSON Schema sent to the LLM provider is a plain `{"type": "string"}`
75+
the `format: "date-time"` keyword is omitted because OpenAI's strict-mode
76+
structured output rejects unsupported keywords and would 400 the request.
77+
Local coercion validates the ISO 8601 + offset shape, so an invalid date
78+
never reaches the caller. The prompt-side example value
79+
(`"2026-05-03T09:14:00Z"`) covers the LLM-guidance role.
7880

7981
**When to pick `:string` vs `:datetime`:**
8082
- `:datetime` if your code does anything with the value (compare, diff, format,

lib/ptc_runner/sub_agent/signature.ex

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,11 +204,16 @@ defmodule PtcRunner.SubAgent.Signature do
204204
def type_to_json_schema(:float), do: %{"type" => "number"}
205205
def type_to_json_schema(:bool), do: %{"type" => "boolean"}
206206
def type_to_json_schema(:keyword), do: %{"type" => "string"}
207-
# `:datetime` is RFC 3339 / ISO 8601 with offset. OpenAI's structured output
208-
# respects `format: "date-time"` server-side; Anthropic treats it as guidance
209-
# in tool schemas. Either way, coercion validates the string locally so an
210-
# invalid date never reaches the caller.
211-
def type_to_json_schema(:datetime), do: %{"type" => "string", "format" => "date-time"}
207+
# `:datetime` ships as a plain `{"type": "string"}` to providers. We initially
208+
# emitted `format: "date-time"` here, but OpenAI's strict-mode structured
209+
# output (and strict tool schemas) reject any keyword outside their supported
210+
# subset, including `format`. Strict-mode requests would 400 before our
211+
# local DateTime coercion got a chance to run. Local coercion does the
212+
# actual ISO 8601 + offset validation; the type's value to the caller (a
213+
# `%DateTime{}` struct, not a string) is what makes `:datetime` more than
214+
# `:string`. The prompt-side example value (`"2026-05-03T09:14:00Z"`)
215+
# covers the LLM-guidance role that `format` would have played.
216+
def type_to_json_schema(:datetime), do: %{"type" => "string"}
212217
# Bedrock requires input_schema to have a "type" field, so :any uses "object"
213218
def type_to_json_schema(:any), do: %{"type" => "object"}
214219
def type_to_json_schema(:map), do: %{"type" => "object"}

lib/ptc_runner/sub_agent/signature/coercion.ex

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,13 @@ defmodule PtcRunner.SubAgent.Signature.Coercion do
162162

163163
defp coerce_impl(value, :datetime) when is_binary(value) do
164164
case DateTime.from_iso8601(value) do
165-
{:ok, dt, _offset} ->
165+
{:ok, dt, offset_seconds} ->
166+
# Use the parsed offset (in seconds) to decide whether to warn, NOT a
167+
# pattern match on the raw string shape — fractional seconds shift the
168+
# offset characters, so `~r/^.{19}Z/` would false-positive on inputs
169+
# like "2026-05-03T09:14:00.123Z" even though they're valid UTC.
166170
{:ok, DateTime.shift_zone!(dt, "Etc/UTC"),
167-
maybe_warn_non_utc(value, ["normalized to UTC"])}
171+
offset_warnings(offset_seconds, ["normalized to UTC"])}
168172

169173
{:error, :missing_offset} ->
170174
{:error,
@@ -446,10 +450,13 @@ defmodule PtcRunner.SubAgent.Signature.Coercion do
446450
# UTC. The agent's caller still gets a UTC `%DateTime{}` (the type's
447451
# canonical form), but knowing the model picked a non-UTC zone is useful
448452
# for prompt-tuning.
449-
defp maybe_warn_non_utc(<<_::binary-19, "Z", _::binary>>, base), do: base
450-
defp maybe_warn_non_utc(<<_::binary-19, "+00:00", _::binary>>, base), do: base
451-
defp maybe_warn_non_utc(<<_::binary-19, "-00:00", _::binary>>, base), do: base
452-
453-
defp maybe_warn_non_utc(_other, base),
453+
#
454+
# Driven by the offset returned by `DateTime.from_iso8601/1` (seconds from
455+
# UTC) rather than the raw string shape — fractional-second timestamps
456+
# would otherwise false-positive a non-UTC warning on "...Z" / "+00:00"
457+
# inputs because they shift the offset chars beyond a fixed binary pattern.
458+
defp offset_warnings(0, base), do: base
459+
460+
defp offset_warnings(_offset, base),
454461
do: base ++ ["non-UTC offset in datetime, normalized to UTC"]
455462
end

test/ptc_runner/sub_agent/signature/coercion_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,25 @@ defmodule PtcRunner.SubAgent.Signature.CoercionTest do
371371
assert "non-UTC offset in datetime, normalized to UTC" in warnings
372372
end
373373

374+
test "fractional-second UTC strings do NOT trigger a spurious non-UTC warning" do
375+
# Regression: the previous binary-pattern shape check
376+
# (`<<_::binary-19, "Z", _::binary>>`) only matched at exactly 19 chars
377+
# before the offset. Inputs like "2026-05-03T09:14:00.123Z" have 23
378+
# chars, so they got falsely flagged as non-UTC. Fixed by basing the
379+
# warning on the parsed offset (0 = UTC) instead of string shape.
380+
assert {:ok, _dt, warnings} =
381+
Coercion.coerce("2026-05-03T09:14:00.123Z", :datetime, [])
382+
383+
refute "non-UTC offset in datetime, normalized to UTC" in warnings
384+
end
385+
386+
test "fractional-second +00:00 offset is also recognized as UTC" do
387+
assert {:ok, _dt, warnings} =
388+
Coercion.coerce("2026-05-03T09:14:00.123+00:00", :datetime, [])
389+
390+
refute "non-UTC offset in datetime, normalized to UTC" in warnings
391+
end
392+
374393
test "%DateTime{} passes through unchanged" do
375394
assert {:ok, ~U[2026-05-03 09:14:00Z], []} =
376395
Coercion.coerce(~U[2026-05-03 09:14:00Z], :datetime, [])

test/ptc_runner/sub_agent/signature_test.exs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -326,15 +326,19 @@ defmodule PtcRunner.SubAgent.SignatureTest do
326326
":any schema must have 'type' field for Bedrock compatibility (currently returns #{inspect(schema)})"
327327
end
328328

329-
test ":datetime emits {type: string, format: date-time}" do
329+
test ":datetime emits a plain string schema (no `format` keyword)" do
330+
# OpenAI strict-mode structured output rejects unsupported keywords like
331+
# `format`. Local coercion does the actual ISO 8601 + offset validation,
332+
# so the schema stays minimal for provider compatibility.
330333
{:ok, sig} = Signature.parse("() -> :datetime")
331-
assert %{"type" => "string", "format" => "date-time"} = Signature.to_json_schema(sig)
334+
assert %{"type" => "string"} = Signature.to_json_schema(sig)
335+
refute Map.has_key?(Signature.to_json_schema(sig), "format")
332336
end
333337

334-
test ":datetime inside a map field carries the format down" do
338+
test ":datetime inside a map field also emits plain string (no format)" do
335339
{:ok, sig} = Signature.parse("() -> {at :datetime, who :string}")
336340
schema = Signature.to_json_schema(sig)
337-
assert schema["properties"]["at"] == %{"type" => "string", "format" => "date-time"}
341+
assert schema["properties"]["at"] == %{"type" => "string"}
338342
assert schema["properties"]["who"] == %{"type" => "string"}
339343
end
340344
end

test/ptc_runner/sub_agent/text_mode_json_test.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,11 +1024,11 @@ defmodule PtcRunner.SubAgent.TextModeJsonTest do
10241024
assert step.fail.message =~ "datetime"
10251025
end
10261026

1027-
test "JSON Schema generated for :datetime carries format: date-time" do
1027+
test "JSON Schema for :datetime is a plain string (provider-strict-mode safe)" do
10281028
alias PtcRunner.SubAgent.Signature
10291029
agent = SubAgent.new(prompt: "?", output: :text, signature: "{at :datetime}")
10301030
schema = Signature.to_json_schema(agent.parsed_signature)
1031-
assert schema["properties"]["at"] == %{"type" => "string", "format" => "date-time"}
1031+
assert schema["properties"]["at"] == %{"type" => "string"}
10321032
end
10331033
end
10341034
end

0 commit comments

Comments
 (0)