mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
fix(eve): preserve composed schemas in compiled tools (#3500)
Signed-off-by: Rui Conti <ruiconti@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"eve": patch
|
||||
---
|
||||
|
||||
Preserve schema composition when compiling tools that reuse another tool's Zod input or output schema. Built agents no longer fail route initialization with `Cannot read properties of undefined (reading 'def')` for these nested schemas.
|
||||
@@ -32,6 +32,8 @@ A tool definition needs:
|
||||
|
||||
When a tool returns structured data, add an optional `outputSchema`. With Zod or Standard Schema it also types the `execute` return.
|
||||
|
||||
Compiled tools preserve Zod schema composition, including input and output schemas imported from helper modules. Durable replay metadata stays separate from the original schema, so adding it does not mutate shared schema objects.
|
||||
|
||||
### Label tool activity
|
||||
|
||||
Use `label.start(input)` to describe a call in user-facing activity. The callback receives the validated tool input, so it can select the useful fields without exposing the full argument object:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ScenarioAppDescriptor } from "#internal/testing/scenario-app.js";
|
||||
|
||||
export const COMPOSED_TOOL_SCHEMAS_DESCRIPTOR: ScenarioAppDescriptor = {
|
||||
name: "composed-tool-schemas",
|
||||
installDependencies: true,
|
||||
dependencies: { zod: "4.5.4" },
|
||||
files: {
|
||||
"agent/instructions.md": "Echo the input with the dispatch tool.\n",
|
||||
"agent/lib/operation.ts": `import { defineTool } from "eve/tools";
|
||||
import { z } from "zod";
|
||||
|
||||
export default defineTool({
|
||||
description: "Echo one value",
|
||||
inputSchema: z.object({ value: z.string().trim().min(1) }),
|
||||
outputSchema: z.object({ value: z.string().min(1) }),
|
||||
execute: async (input) => input,
|
||||
});
|
||||
`,
|
||||
"agent/tools/dispatch.ts": `import { defineTool } from "eve/tools";
|
||||
import { z } from "zod";
|
||||
import operation from "../lib/operation.ts";
|
||||
|
||||
export default defineTool({
|
||||
description: "Dispatch to the echo operation",
|
||||
inputSchema: z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("echo"),
|
||||
input: operation.inputSchema as z.ZodType<{ value: string }>,
|
||||
}),
|
||||
]),
|
||||
outputSchema: z.object({
|
||||
result: operation.outputSchema as z.ZodType<{ value: string }>,
|
||||
}),
|
||||
execute: async (input) => ({ result: input.input }),
|
||||
});
|
||||
`,
|
||||
"agent/channels/schema-composition.ts": `import { defineChannel, GET } from "eve/channels";
|
||||
import { z } from "zod";
|
||||
import dispatch from "../tools/dispatch.ts";
|
||||
|
||||
export default defineChannel({
|
||||
routes: [GET("/schema-composition", async () => {
|
||||
const input = dispatch.inputSchema as z.ZodType;
|
||||
const output = dispatch.outputSchema as z.ZodType;
|
||||
const parsed = input.parse({ action: "echo", input: { value: " hello " } });
|
||||
return Response.json({
|
||||
parsed,
|
||||
invalidAccepted: input.safeParse({ action: "echo", input: { value: " " } }).success,
|
||||
output: output.parse({ result: { value: "hello" } }),
|
||||
});
|
||||
})],
|
||||
});
|
||||
`,
|
||||
},
|
||||
};
|
||||
@@ -2,9 +2,39 @@ import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import { z } from "#compiled/zod/index.js";
|
||||
import { defineDurableSchema, readDurableSchema } from "#tools/durable-schema.js";
|
||||
import { defineTool } from "#tools/definition.js";
|
||||
import { serializeInputSchema } from "#tools/schema.js";
|
||||
import { serializeInputSchema, serializeOutputSchema } from "#tools/schema.js";
|
||||
|
||||
describe("defineDurableSchema", () => {
|
||||
it.each([false, true])("preserves nested Zod composition (frozen: %s)", (frozen) => {
|
||||
const source = z.object({ value: z.string().trim().min(1) });
|
||||
if (frozen) {
|
||||
// Zod installs its Standard Schema property lazily on first access.
|
||||
void source["~standard"];
|
||||
Object.freeze(source);
|
||||
}
|
||||
const schema = defineDurableSchema({ closure: {}, schema: () => source });
|
||||
const composed = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("echo"), input: schema as typeof source }),
|
||||
]);
|
||||
|
||||
expect(serializeInputSchema(composed)).toMatchObject({
|
||||
oneOf: [
|
||||
{ properties: { input: { properties: { value: { type: "string", minLength: 1 } } } } },
|
||||
],
|
||||
});
|
||||
expect(serializeOutputSchema(composed)).toMatchObject({
|
||||
oneOf: [
|
||||
{ properties: { input: { properties: { value: { type: "string", minLength: 1 } } } } },
|
||||
],
|
||||
});
|
||||
expect(composed.parse({ action: "echo", input: { value: " hello " } })).toEqual({
|
||||
action: "echo",
|
||||
input: { value: "hello" },
|
||||
});
|
||||
expect(composed.safeParse({ action: "echo", input: { value: " " } }).success).toBe(false);
|
||||
expect(readDurableSchema(source)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves JSON-only schemas unchanged without adding a runtime validation contract", () => {
|
||||
const source = { type: "string", minLength: 1 };
|
||||
const schema = defineDurableSchema({ closure: {}, schema: () => source });
|
||||
|
||||
@@ -53,6 +53,8 @@ export function defineDurableSchema<
|
||||
},
|
||||
},
|
||||
};
|
||||
// Authored tools may compose library-native schemas before eve normalizes them.
|
||||
Object.setPrototypeOf(schema, source);
|
||||
Object.defineProperty(schema, DURABLE_SCHEMA, {
|
||||
value: {
|
||||
callback: (closure: JsonObject) => input.schema(closure as TClosure),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { runCli } from "../../src/cli/run.js";
|
||||
import { resolveInstalledPackageInfo } from "../../src/internal/application/package.js";
|
||||
import { useScenarioApp } from "../../src/internal/testing/scenario-app.js";
|
||||
import { WEATHER_AGENT_DESCRIPTOR } from "../../src/internal/testing/scenario-apps/weather-agent.js";
|
||||
import { COMPOSED_TOOL_SCHEMAS_DESCRIPTOR } from "../../src/internal/testing/scenario-apps/composed-tool-schemas.js";
|
||||
import { resolveLocalWorkflowWorldDataDirectory } from "../../src/internal/workflow/local-world-data-directory.js";
|
||||
import {
|
||||
EVE_HEALTH_ROUTE_PATH,
|
||||
@@ -429,6 +430,28 @@ describe("runCli", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves imported tool schema composition in the built server", async () => {
|
||||
const { buildApplication } = await import("../../src/internal/nitro/host.js");
|
||||
const { appRoot } = await scenarioApp(COMPOSED_TOOL_SCHEMAS_DESCRIPTOR);
|
||||
await buildApplication(appRoot, { skipVercelSandboxPrewarm: true });
|
||||
const server = await startPackagedEveStart(appRoot);
|
||||
|
||||
try {
|
||||
const response = await fetch(new URL("/schema-composition", server.url), {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const body = await response.text();
|
||||
expect(response.status, `${body}\n${server.stderr()}`).toBe(200);
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
parsed: { action: "echo", input: { value: "hello" } },
|
||||
invalidAccepted: false,
|
||||
output: { result: { value: "hello" } },
|
||||
});
|
||||
} finally {
|
||||
await server.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("starts an existing built app with local Workflow data under .eve", async () => {
|
||||
const { buildApplication } = await import("../../src/internal/nitro/host.js");
|
||||
const appRoot = await createMinimalAppRoot("eve-cli-start-health-");
|
||||
|
||||
Reference in New Issue
Block a user