mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
7cbc2b5d4f
## Problem
A frontend/client tool (`useFrontendTool`) whose Zod `parameters` use
`z.discriminatedUnion(...)` — or any schema that serializes to a
JSON-schema `anyOf`/`oneOf` node — silently loses the union-typed field
when calling OpenAI. The tool call arrives with that field missing or
empty. Switching the same tool to a flat object with an enum
discriminant works, which points at schema conversion rather than the
model.
## Root cause
The runtime has **two** JSON-Schema → Zod converters:
- `@copilotkit/shared`'s `convertJsonSchemaToZodSchema` already handles
`anyOf`/`oneOf` as `z.union` (and `$ref`, null-unions, graceful
fallback).
- The **local copy** in `packages/runtime/src/agent/index.ts` — used by
the classic `BuiltInAgent` / AI SDK path via
`convertToolsToVercelAITools` — never did.
A union node carries no top-level `type`, so it hit the empty-schema
guard (`if (!jsonSchema.type)`) and collapsed to `z.object({})`. The
reconstructed tool schema therefore dropped the union entirely — most
visibly for a union nested inside array `items` — so the model was never
offered those fields and could not emit them. (The legacy GraphQL OpenAI
adapter is unaffected: it forwards the JSON schema directly.)
## Fix
Handle `anyOf`/`oneOf` as `z.union` **before** the empty-schema guard,
mirroring the already-proven shared converter. A single-variant union
unwraps to that variant.
## Backward compatibility
Only previously-broken union nodes change behavior (empty object → real
union). Empty `{}` schemas, typed nodes, and the `isJsonSchema` gate are
untouched. Two regression tests added: a direct `anyOf` conversion and
the exact nested-`items` `oneOf` trap.
## Repro
A `useFrontendTool` with
```ts
parameters: z.object({
blocks: z.array(z.discriminatedUnion("type", [
z.object({ type: z.literal("heading"), level: z.number() }),
z.object({ type: z.literal("paragraph"), content: z.string() }),
])),
})
```
against OpenAI: before, `blocks` items arrived empty; after, both
variants survive into the model call.
## Note on OpenAI strict mode
This path does not enable OpenAI strict function-calling, so once the
union survives conversion it serializes back to `anyOf` and OpenAI
accepts it. The data loss was upstream, in CopilotKit's own converter,
not an OpenAI strict-mode limitation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)