anthropics / anthropics/anthropic-sdk-typescript

`transformJSONSchema` drops `const`/`enum` into description text, breaking discriminated unions in tool inputs

Ouverte
#1,116 2 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
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.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.