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
5 changes: 5 additions & 0 deletions .changeset/describe-compile-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Compile `describe.tool` previews against only the definitions a tool references, drop the compiler's per-call retained graph, and fall back to `unknown` for schemas over a node limit. Describing a tool from a large OpenAPI spec no longer burns seconds of CPU or leaks memory in the shared session isolate.
7 changes: 6 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5724,12 +5724,17 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
for (const def of definitionRows) defs.set(def.name, decodeJsonColumn(def.schema));

const referenced = collectReferencedDefinitions([inputSchema, effectiveOutputSchema], defs);
// Compile against the referenced subgraph only. The compiler walks
// every definition it is handed (its parser scans the whole `$defs`
// map per node), so passing the connection's full component set made a
// single describe of a large spec cost seconds of CPU on the shared
// session isolate. Unreferenced definitions never appear in the output.
const preview = yield* Effect.tryPromise({
try: () =>
buildToolTypeScriptPreview({
inputSchema,
outputSchema: effectiveOutputSchema,
defs,
defs: new Map(Object.entries(referenced)),
}),
catch: (cause) =>
storageFailureFromUnknown("Failed to build tool TypeScript preview", cause),
Expand Down
33 changes: 33 additions & 0 deletions packages/core/sdk/src/schema-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest";
import { Schema } from "effect";

import {
MAX_PREVIEW_SCHEMA_NODES,
buildToolTypeScriptPreview,
schemaToTypeScriptPreview,
schemaToTypeScriptPreviewWithDefs,
Expand Down Expand Up @@ -385,6 +386,38 @@ describe("schema-types", () => {
});
});

it("falls back to unknown instead of compiling a schema over the node limit", async () => {
const defs = new Map<string, unknown>();
// One definition per node-limit slice, all referenced from the input, so the
// wrapped schema handed to the compiler is guaranteed to cross the cap.
const properties: Record<string, unknown> = {};
for (let i = 0; i < MAX_PREVIEW_SCHEMA_NODES / 2; i++) {
defs.set(`D${i}`, { type: "object", properties: { v: { type: "string" } } });
properties[`p${i}`] = { $ref: `#/$defs/D${i}` };
}
const preview = await buildToolTypeScriptPreview({
inputSchema: { type: "object", properties },
outputSchema: { type: "string" },
defs,
});
expect(preview).toEqual({ inputTypeScript: "unknown", outputTypeScript: "unknown" });
});

it("only compiles the definitions a tool references", async () => {
// A big pile of unrelated definitions must not change the output or the
// time it takes: the caller passes the referenced subgraph, and this pins
// the contract that the preview is a pure function of that subgraph.
const referenced = new Map<string, unknown>([
["Person", { type: "object", properties: { name: { type: "string" } }, required: ["name"] }],
]);
const preview = await buildToolTypeScriptPreview({
inputSchema: { type: "object", properties: { who: { $ref: "#/$defs/Person" } } },
defs: referenced,
});
expect(preview.inputTypeScript).toBe("{ who?: Person; }");
expect(preview.typeScriptDefinitions).toEqual({ Person: "{ name: string; }" });
});

it("renders unconstrained schemas as unknown", async () => {
await expect(
buildToolTypeScriptPreview({
Expand Down
38 changes: 34 additions & 4 deletions packages/core/sdk/src/schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,31 @@ export type ToolTypeScriptPreview = {
typeScriptDefinitions?: Record<string, string>;
};

/**
* Upper bound on the number of schema nodes handed to the compiler for one
* tool preview. The compiler is fully synchronous, so nothing can interrupt
* it once it starts; a pre-check is the only protection a shared isolate has
* against a pathological schema. Sized well above any real operation (a
* PostHog or Stripe tool with its referenced definitions is a few thousand
* nodes) and well below where the compile would take seconds of CPU.
*/
export const MAX_PREVIEW_SCHEMA_NODES = 50_000;

/** Count object/array nodes in a schema, stopping early once `limit` is hit. */
const exceedsNodeLimit = (root: unknown, limit: number): boolean => {
let count = 0;
const stack: unknown[] = [root];
while (stack.length > 0) {
const node = stack.pop();
if (node === null || typeof node !== "object") continue;
if (++count > limit) return true;
for (const value of Array.isArray(node) ? node : Object.values(node as object)) {
if (value !== null && typeof value === "object") stack.push(value);
}
}
return false;
};

export const buildToolTypeScriptPreview = async (input: {
inputSchema?: unknown;
outputSchema?: unknown;
Expand All @@ -806,14 +831,19 @@ export const buildToolTypeScriptPreview = async (input: {
return {};
}

const unknownPreview: ToolTypeScriptPreview = {
...(input.inputSchema !== undefined ? { inputTypeScript: "unknown" } : {}),
...(input.outputSchema !== undefined ? { outputTypeScript: "unknown" } : {}),
};

const wrappedSchema = buildWrappedObjectSchema(properties, input.defs);
if (exceedsNodeLimit(wrappedSchema, MAX_PREVIEW_SCHEMA_NODES)) {
return unknownPreview;
}
return Promise.resolve()
.then(() => compile(wrappedSchema, ROOT_WRAPPER_NAME, compilerOptionsFrom(input.options ?? {})))
.then(
(source) => previewToolFromCompiledTypeScript(source),
() => ({
...(input.inputSchema !== undefined ? { inputTypeScript: "unknown" } : {}),
...(input.outputSchema !== undefined ? { outputTypeScript: "unknown" } : {}),
}),
() => unknownPreview,
);
};
15 changes: 12 additions & 3 deletions packages/core/sdk/src/vendor/json-schema-to-typescript/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,21 @@ export const findKey = <T>(
return undefined;
};

/**
* Memoize on object identity. The cache is a WeakMap so a memoized function
* installed at module scope (`generateType`, `getDefinitionsMemoized`) does
* not pin every AST and dereferenced schema it has ever seen for the lifetime
* of the isolate. Upstream uses a strong Map, which is harmless in a one-shot
* CLI but leaks the whole compiled graph per call inside a long-lived worker.
*/
export const memoize = <F extends (arg: any, ...rest: any[]) => any>(fn: F): F => {
const cache = new Map<Parameters<F>[0], ReturnType<F>>();
// Every caller keys on a schema or AST node, which is always an object.
const cache = new WeakMap<object, ReturnType<F>>();
return ((arg: Parameters<F>[0], ...rest: unknown[]) => {
if (cache.has(arg)) return cache.get(arg);
const key = arg as object;
if (cache.has(key)) return cache.get(key);
const value = fn(arg, ...rest);
cache.set(arg, value);
cache.set(key, value);
return value;
}) as F;
};
Expand Down
Loading