anthropics / anthropics/anthropic-sdk-typescript
`transformJSONSchema` drops `const`/`enum` into description text, breaking discriminated unions in tool inputs
- Langage dominant
- TypeScript
- Étoiles
- 2.1k
- Forks
- 403
- Merge moyen
- 1 j 21 h
- PR mergées (30 j)
- 8
Description
### Summary
When a tool's input schema contains a Zod **discriminated union** (or any schema
using `const`/`enum`), `betaZodTool` produces an `input_schema` where the
discriminant is stripped out of the schema and stuffed into the `description` as
plain text. Without a real discriminant, the model frequently emits the nested
union value as a **JSON string** instead of an object, and `tool.parse()` then
rejects it with `expected object, received string`.
The net effect: tools with a nested discriminated union fail intermittently,
while otherwise-identical tools with flat inputs work fine.
### Environment
- `@anthropic-ai/sdk`: `0.100.1`
- `zod`: `4.3.6` (via `zod/v4`)
- node: `v22.14.0`
### Repro
```ts
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod.mjs";
import * as z from "zod/v4";
const tool = betaZodTool({
name: "modify_project",
description: "Edit a project",
inputSchema: z.object({
change: z.discriminatedUnion("action", [
z.object({ action: z.literal("set_title"), title: z.string() }),
z.object({ action: z.literal("add_members"), emails: z.array(z.string()) }),
]),
}),
run: async (i) => JSON.stringify(i),
});
console.log(JSON.stringify(tool.input_schema.properties.change, null, 2));
```
### Actual output
The `const` discriminant is gone from the schema and demoted to prose:
```jsonc
{
"anyOf": [
{
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "{const: \"set_title\"}" // <-- no longer a real constraint
},
"title": { "type": "string" }
},
...
},
...
]
}
```
Because the branches are now only distinguishable by description text (and `oneOf`
has been rewritten to `anyOf`), the model often returns:
```json
{ "change": "{\"action\":\"set_title\",\"title\":\"Hello\"}" }
```
i.e. `change` as a **string**, which then fails validation in
`runRunnableTool` → `tool.parse()`:
```
Error: [{ "code": "invalid_type", "expected": "object",
"path": ["change"], "message": "Invalid input: expected object, received string" }]
```
### Cause
`lib/transform-json-schema.mjs` builds a "strict" schema by whitelisting a fixed
set of keywords and appending everything else to `description`. `const` and
`enum` aren't in the whitelist, so any discriminant/literal is silently turned
into text like `{const: "set_title"}`.
### Expected
`const` / `enum` should survive the transform as real schema constraints so the
model can reliably pick a branch and return an object. (Nested discriminated
unions are a common, idiomatic Zod pattern for tool inputs.)
### Possible fixes
1. **Preferred:** keep `const`/`enum` in the strict schema instead of demoting
them to description text.
2. As defense-in-depth, have `betaZodTool`'s `parse` attempt a `JSON.parse`
fallback when a field arrives as a string but the schema expects an
object/union.
### Current workaround
Wrapping the union in `z.preprocess` to JSON-parse a stringified value before
validation. It works and leaves the model-facing schema unchanged, but every
consumer with a nested union has to remember to do this:
```ts
change: z.preprocess(
(v) => (typeof v === "string" ? JSON.parse(v) : v),
z.discriminatedUnion("action", [ /* ... */ ]),
),
```
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.