Files
David McKay 7cbc2b5d4f fix(runtime): preserve anyOf/oneOf unions in frontend tool schemas (#6143)
## 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)
2026-07-24 11:49:50 -07:00
..
2026-04-10 23:38:59 +00:00

CopilotKit - Runtime

banner

✨ Why CopilotKit?

  • Minutes to integrate - Get started quickly with our CLI
  • Framework agnostic - Works with React, Next.js, AGUI and more
  • Production-ready UI - Use customizable components or build with headless UI
  • Built-in security - Prompt injection protection
  • Open source - Full transparency and community-driven
class-support-ecosystem

🧑‍💻 Real life use cases

Deploy deeply-integrated AI assistants & agents that work alongside your users inside your applications.

headless-ui

Documentation

To get started with CopilotKit, please check out the documentation.

Analytics & Privacy

CopilotKit uses Scarf for anonymous usage analytics to help improve the product. Scarf handles all privacy compliance and does not store raw IP addresses. This helps us understand how CopilotKit is being used and prioritize improvements.

Opting Out

To disable analytics, set the environment variable:

export COPILOTKIT_TELEMETRY_DISABLED=true

Or use the DO_NOT_TRACK standard:

export DO_NOT_TRACK=1