Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/ai-sdk-v5-plus-input-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"acture-ai-vercel": major
---

Require AI SDK v5+ and project tools onto `inputSchema` (was `parameters`).

**Breaking:** the `ai` peer range moves from `^4.0.0` to `^5.0.0 || ^6.0.0 || ^7.0.0`.
Tools are now built with `tool({ inputSchema })`, the field the AI SDK has used since
v5. Nothing else in the public API changes — `toAITools` and `toToolNameMap` keep their
signatures, tier filtering, `[DEPRECATED]` banners, wire-safe tool names, and the
errors-as-data `execute` contract.

To upgrade, move your app to `ai@^5` (or later) and bump this package. If you must stay
on `ai@^4`, pin `acture-ai-vercel@1`.

**Why this is worth the major.** The v4 line is a dead end for Google. Its final
`@ai-sdk/google` (1.2.22, published months before Gemini 3 shipped) has no representation
for `thoughtSignature` — it is stripped in the response schema, again when tool calls are
extracted, and again when the model turn is re-serialized. Gemini 3 *requires* that
signature to be echoed back on the first `functionCall` part of each step, and rejects the
request otherwise:

> Function call is missing a thought_signature in functionCall parts.

There is no fix available on v4, and no way to opt out on Gemini 3 — the requirement holds
even at `thinking_level: minimal`. Support first landed in `@ai-sdk/google@2.0.3`
(the v5-era line), which round-trips the signature via `providerOptions.google`.
10 changes: 8 additions & 2 deletions .claude/skills/acture-schema-bridge/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,15 @@ Iterates registry, filters by tier (default: `['stable']` only), projects each t
registry.toAITools({ tiers: ['stable'] }) // default
```

For Vercel AI SDK: returns `Record<string, Tool>` keyed by command id, ready to pass to `streamText({ tools })`. Each `Tool` has `{ description, parameters: ZodSchema, execute }`.
For Vercel AI SDK: returns `Record<string, Tool>` keyed by a wire-safe tool name (see `commandIdToToolName`), ready to pass to `streamText({ tools })`. Each `Tool` has `{ description, inputSchema, execute }`.

For Anthropic SDK: returns `AnthropicTool[]` with `{ name, description, input_schema }`. The Vercel SDK uses Zod directly; Anthropic SDK takes JSON Schema, so the Anthropic projection uses `toJsonSchema` while the Vercel projection passes the Zod schema through.
**Requires AI SDK v5+.** The schema field is `inputSchema`; the v4 line called it `parameters` and is no longer supported (`acture-ai-vercel@1` is the last v4-compatible release). Emitting `parameters` against v5+ means the tool reaches the model with *no* schema and cannot be called — silently, with no error.

For Anthropic SDK: returns `AnthropicTool[]` with `{ name, description, input_schema }`.

**Both projections go through JSON Schema — neither passes Zod through.** Although the Vercel SDK *accepts* a Zod schema on `inputSchema`, we convert `params` ourselves with Zod 4's native `z.toJSONSchema()` and hand the SDK a ready schema via `jsonSchema()`. This keeps the wire schema ours rather than the SDK's bundled converter's — a coupling that has already bitten us once (`ai` v4 bundled a Zod-v3-only converter that, given a Zod v4 schema, silently emitted `{}`).

Runtime validation is unaffected: `registry.dispatch` still validates against the original Zod schema, so refinements JSON Schema cannot express (`z.refine` predicates and friends) are still enforced. The JSON Schema is only what the model is *shown*.

## Strict mode (OpenAI)

Expand Down
10 changes: 7 additions & 3 deletions docs/hand-written-assistant-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,14 @@ read-side context (`useAssistantContext` / `useCopilotReadable` / AG-UI
// Build ONE tools config from the registry (plain data — NOT hooks in a loop,
// which would violate the Rules of Hooks). Register it with your chat layer in a
// single top-level call; the exact registration API is framework-specific.
// `toJsonSchema(cmd)` yields `{ name, description, inputSchema }` — JSON Schema, not
// raw Zod. Hand the SDK a schema you converted, so the wire contract is yours rather
// than whichever Zod->JSON-Schema converter the SDK bundles. (`ai` v4 bundled a
// Zod-v3-only one that silently emitted `{}` for a Zod v4 schema: the model saw a tool
// with no parameters and could not call it.) Note the field is `inputSchema` on AI SDK
// v5+; v4 called it `parameters`.
const tools = registry.list({ tiers: ['stable'] }).map((cmd) => ({
name: cmd.id,
description: cmd.description,
parameters: cmd.params,
...toJsonSchema(cmd),
handler: (args: unknown) => registry.dispatch(cmd.id, args, { channel: 'assistant' }),
}));
// e.g. assistant-ui / AG-UI: hand `tools` to the runtime; CopilotKit: register each
Expand Down
4 changes: 3 additions & 1 deletion docs/phase-2-reflection.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ This file answers the six questions from `docs/implementation_plan.md` §"Phase

**Yes, and the dual path is cleaner than expected.**

- **Vercel AI SDK** accepts Zod schemas directly. `acture-ai-vercel` passes `record.params` through unchanged, preserving every `z.refine`, `z.transform`, and custom error message that JSON Schema would silently lose. The AI SDK's `tool({ parameters: zodSchema, execute })` handles the rest.
> **Superseded (`acture-ai-vercel@2`, AI SDK v5+).** The Zod-passthrough described in the first bullet below was reversed. Both projections now go through JSON Schema, and the AI SDK tool field is `inputSchema`, not `parameters`. The reasoning is preserved here as a record of what we believed at Phase 2; see `packages/ai-vercel/README.md` ("Why convert to JSON Schema") for current behavior. **Do not write new code against the shape below.**

- **Vercel AI SDK** accepts Zod schemas directly, so `acture-ai-vercel` passed `record.params` through unchanged, and the AI SDK's `tool({ parameters: zodSchema, execute })` handled the rest. *(This is what broke: `ai` v4 bundled a Zod-v3-only converter that, handed a Zod v4 schema, silently emitted `{}` — the model saw a tool with no parameters and could not call it. We now convert with `z.toJSONSchema()` and pass a ready schema via `jsonSchema()`, keeping the wire schema ours. Refinements are still enforced at `registry.dispatch`, which validates against the original Zod schema.)*

- **MCP** wants JSON Schema on the wire. `acture-mcp-server/tools.ts` calls `toJsonSchema(record)` and emits the envelope as a `McpToolDescriptor`. Strict mode is opt-in (the OpenAI-style `additionalProperties: false` flavor).

Expand Down
4 changes: 2 additions & 2 deletions examples/greenfield/graph-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"acture-state-zustand": "workspace:*",
"@modelcontextprotocol/sdk": "^1.0.4",
"acture": "workspace:*",
"ai": "^4.0.40",
"ai": "^7.0.22",
"cmdk": "^1.0.0",
"immer": "^11.0.0",
"react": "^19.0.0",
Expand All @@ -34,7 +34,7 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@ai-sdk/anthropic": "^1.0.0",
"@ai-sdk/anthropic": "^4.0.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
Expand Down
4 changes: 2 additions & 2 deletions examples/greenfield/graph-editor/scripts/ai-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* pairs to form a triangle. The final state is logged.
*/

import { generateText } from 'ai';
import { generateText, isStepCount } from 'ai';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createRegistry } from 'acture';
import { createZustandAdapter } from 'acture-state-zustand';
Expand All @@ -39,7 +39,7 @@ const anthropic = createAnthropic({ apiKey });
const result = await generateText({
model: anthropic('claude-sonnet-4-5'),
tools: toAITools(registry),
maxSteps: 12,
stopWhen: isStepCount(12),
prompt: [
'You have a graph editor with the listed tools.',
'Currently the graph already has nodes n1, n2, n3 and one edge.',
Expand Down
28 changes: 23 additions & 5 deletions packages/ai-vercel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@ Project an [acture](https://npm.im/acture) registry as [Vercel AI SDK](https://s
pnpm add acture-ai-vercel ai acture zod
```

Requires **AI SDK v5 or later** (`ai@^5 || ^6 || ^7`), where a tool's schema field is
`inputSchema`. For `ai@^4` — which called it `parameters` — use `acture-ai-vercel@1`.
Staying on v4 is not recommended: its final `@ai-sdk/google` predates Gemini 3 and
drops the `thoughtSignature` Gemini 3 requires you to echo back, so multi-step tool
calling fails outright.

## Use

```ts
import { streamText } from 'ai';
import { streamText, isStepCount } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { toAITools } from 'acture-ai-vercel';
import { registry } from './registry';

const result = streamText({
model: anthropic('claude-sonnet-4-5'),
model: anthropic('claude-sonnet-5'),
tools: toAITools(registry),
prompt: 'Add three nodes labeled A, B, C and connect them in a triangle.',
maxSteps: 8,
stopWhen: isStepCount(8),
});

for await (const part of result.fullStream) {
Expand All @@ -49,9 +55,21 @@ Tool `execute` resolves to:

The model sees the same shape on every surface (palette, hotkeys, MCP, AI SDK). This is the central guarantee of acture's architecture.

## Why pass Zod through (not JSON Schema)?
## Why convert to JSON Schema (not pass Zod through)?

The AI SDK accepts a Zod schema on `inputSchema` directly, but we convert each
command's `params` up front with Zod 4's native `z.toJSONSchema()` and hand the SDK a
ready schema via `jsonSchema()`.

This keeps the wire schema *ours*: what the model sees is decided here, not by whichever
Zod-to-JSON-Schema converter the SDK happens to bundle. That coupling has already bitten
us once — `ai` v4 shipped a Zod-**v3**-only converter that, given a Zod **v4** schema,
silently emitted `{}`. The model then saw a tool with no parameters and could not call it,
with no error anywhere.

The Vercel AI SDK accepts Zod schemas directly. Passing the original schema preserves validators (`z.refine`, `z.transform` constraints on output) that JSON Schema would silently drop. The same registry exposed via `acture-mcp-server` projects through `toJsonSchema` because MCP wants JSON Schema on the wire.
Runtime validation is unaffected. `registry.dispatch` still validates against the original
Zod schema, so refinements JSON Schema cannot express (`z.refine` predicates and friends)
are still enforced on every dispatch — the JSON Schema is only what the model is *shown*.

## See also

Expand Down
6 changes: 3 additions & 3 deletions packages/ai-vercel/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "acture-ai-vercel",
"version": "1.1.0",
"description": "Project an acture registry as Vercel AI SDK tool definitions. Tier-filtered; errors-as-data; passes Zod schemas through directly.",
"description": "Project an acture registry as Vercel AI SDK tool definitions. Requires AI SDK v5+ (inputSchema). Tier-filtered; errors-as-data; converts each command's Zod params to JSON Schema so the wire contract is ours, not the SDK's bundled converter's.",
"license": "Apache-2.0",
"author": "Thor Whalen",
"repository": {
Expand Down Expand Up @@ -37,12 +37,12 @@
},
"peerDependencies": {
"acture": "^1.0.0",
"ai": "^4.0.0",
"ai": "^5.0.0 || ^6.0.0 || ^7.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"acture": "workspace:*",
"ai": "^4.0.40",
"ai": "^7.0.22",
"tsup": "^8.3.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0",
Expand Down
41 changes: 23 additions & 18 deletions packages/ai-vercel/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,33 +105,38 @@ describe('toAITools', () => {
expect((cmd as { id: string }).id).toBe('app.search');
});

it('projects Zod v4 params to a non-empty JSON Schema', () => {
// Regression: `ai` v4 converts a passed-through Zod schema with
// `zod-to-json-schema` (Zod v3 only) and silently yields an empty
// `{}` for a Zod v4 schema — the model then sees no parameters. The
// adapter must convert via `z.toJSONSchema()` itself.
it('projects Zod v4 params to a non-empty JSON Schema on `inputSchema`', () => {
// Two regressions guarded at once.
//
// 1. The field is `inputSchema` (AI SDK v5+). On the v4 line it was
// `parameters`; a tool object still carrying `parameters` reaches the
// model with NO schema at all, so it can never supply arguments.
// 2. We convert with `z.toJSONSchema()` ourselves rather than passing the
// Zod schema through: `ai` v4 converted it with `zod-to-json-schema`
// (Zod v3 only) and silently yielded `{}` for a Zod v4 schema. Owning
// the conversion decouples us from whatever converter the SDK bundles.
const { registry } = setup();
const tools = toAITools(registry);
const params = (
tools['app_search'] as unknown as {
parameters: {
jsonSchema?: { type?: string; properties?: Record<string, unknown> };
};
}
).parameters;
expect(params.jsonSchema?.type).toBe('object');
expect(params.jsonSchema?.properties ?? {}).toHaveProperty('query');
const t = tools['app_search'] as unknown as {
parameters?: unknown;
inputSchema: {
jsonSchema?: { type?: string; properties?: Record<string, unknown> };
};
};
expect(t.parameters).toBeUndefined();
expect(t.inputSchema.jsonSchema?.type).toBe('object');
expect(t.inputSchema.jsonSchema?.properties ?? {}).toHaveProperty('query');
});

it('projects a param-less command to an empty object schema', () => {
const { registry } = setup();
const tools = toAITools(registry, { tiers: ['stable', 'experimental'] });
const params = (
const schema = (
tools['app_exp_thing'] as unknown as {
parameters: { jsonSchema?: { type?: string } };
inputSchema: { jsonSchema?: { type?: string } };
}
).parameters;
expect(params.jsonSchema?.type).toBe('object');
).inputSchema;
expect(schema.jsonSchema?.type).toBe('object');
});
});

Expand Down
34 changes: 23 additions & 11 deletions packages/ai-vercel/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
* `acture-ai-vercel` — project an acture registry as Vercel AI SDK
* tool definitions.
*
* **Requires AI SDK v5 or later** (`ai@^5`), where a tool's schema field
* is `inputSchema`. The v4 line called it `parameters` and is no longer
* supported — it is also a dead end for Google: the final v4-era
* `@ai-sdk/google` predates Gemini 3 and drops the `thoughtSignature`
* that Gemini 3 requires you to echo back on `functionCall` parts, so
* multi-step tool calling 400s. See the v1 → v2 note in CHANGELOG.md.
*
* Each command's Zod `params` schema is converted to a JSON Schema (with
* Zod 4's native `z.toJSONSchema()`) and handed to the AI SDK via
* `jsonSchema()` — see `toParameterSchema` for why the conversion cannot
* be left to the SDK. Runtime validation is unaffected: `registry.dispatch`
* `jsonSchema()` — see `toParameterSchema` for why we keep that
* conversion ours. Runtime validation is unaffected: `registry.dispatch`
* still validates against the original Zod schema, so refinements a JSON
* Schema cannot express (e.g. `z.refine` predicates) are still enforced.
*
Expand Down Expand Up @@ -116,14 +123,19 @@ function selectCommands(
* Project a command's Zod `params` to a JSON Schema the AI SDK can send
* to the model.
*
* The AI SDK's `tool({ parameters })` *accepts* a Zod schema, but `ai`
* v4 converts it internally with `zod-to-json-schema`, which understands
* only Zod **v3**'s internals. Given a Zod **v4** schema it silently
* emits an empty `{}` — the model then sees a tool with no parameters
* and cannot supply arguments. So we convert up front with Zod 4's
* native `z.toJSONSchema()` and hand the SDK a ready JSON Schema via
* `jsonSchema()`. A command with no `params` projects to an empty object
* schema.
* `tool({ inputSchema })` accepts a Zod schema directly, but we convert
* up front with Zod 4's native `z.toJSONSchema()` and hand the SDK a
* ready JSON Schema via `jsonSchema()`. This keeps the wire schema
* ours: the exact JSON Schema the model sees is decided here, not by
* whichever Zod-to-JSON-Schema converter the SDK happens to bundle — a
* coupling that already bit us once (`ai` v4 shipped a Zod-v3-only
* converter that silently emitted `{}` for a Zod v4 schema, leaving the
* model with a parameter-less tool it could not call).
*
* Runtime validation is unaffected: `registry.dispatch` still validates
* against the original Zod schema, so refinements a JSON Schema cannot
* express (e.g. `z.refine` predicates) are still enforced. A command
* with no `params` projects to an empty object schema.
*/
function toParameterSchema(
params: AnyCommandRecord['params'],
Expand All @@ -142,7 +154,7 @@ function projectCommand(
const description = applyDeprecationPrefix(cmd, cmd.description);
return tool({
description: description ?? cmd.title,
parameters: toParameterSchema(cmd.params),
inputSchema: toParameterSchema(cmd.params),
execute: async (args: unknown) => {
// `cmd.id` (not the sanitized wire name) is what the registry
// dispatches on — sanitization is a wire-format concern, not a
Expand Down
Loading
Loading