@assistant-ui/x-generative-compiler 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compile.ts CHANGED
@@ -1,7 +1,11 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import * as nodePath from "node:path";
1
4
  import { parse } from "@babel/parser";
2
5
  import _traverse, { type NodePath } from "@babel/traverse";
3
6
  import _generate from "@babel/generator";
4
7
  import * as t from "@babel/types";
8
+ import { satisfies } from "semver";
5
9
  import { DIRECTIVE, type Target } from "./constants";
6
10
 
7
11
  // @babel/traverse and @babel/generator are CJS; their default export is the
@@ -13,7 +17,39 @@ const generate = (
13
17
  typeof _generate === "function" ? _generate : (_generate as any).default
14
18
  ) as typeof _generate;
15
19
 
16
- export type ToolType = "frontend" | "backend" | "human";
20
+ export type ToolType = "frontend" | "backend" | "human" | "provider";
21
+
22
+ /** The required wrapper around a toolkit's tools (stripped at build time). */
23
+ const TOOLKIT_WRAPPER = "defineToolkit";
24
+ /** The helper that produces MCP-only toolkit fragments. */
25
+ const MCP_TOOLKIT_WRAPPER = "defineMcpToolkit";
26
+ /** The required wrapper around a generative-UI library (stripped at build time). */
27
+ const COMPONENTS_WRAPPER = "defineGenerativeComponents";
28
+ /** The core package whose metadata declares supported compiler versions. */
29
+ const CORE_PACKAGE = "@assistant-ui/core";
30
+ /** This package, checked against core's compatibility range. */
31
+ const COMPILER_PACKAGE = "@assistant-ui/x-generative-compiler";
32
+ /** Packages that re-export core's generative markers. */
33
+ const DISTRIBUTION_PACKAGES = [
34
+ CORE_PACKAGE,
35
+ "@assistant-ui/react",
36
+ "@assistant-ui/react-native",
37
+ "@assistant-ui/react-ink",
38
+ ] as const;
39
+ /**
40
+ * The class whose instances expose split-by-condition tools (`present()`,
41
+ * `promptUser()`). A toolkit entry that calls a method on one of these passes
42
+ * through untouched — the library, not this compiler, routes its halves.
43
+ */
44
+ const GENERATIVE_FACTORY = "JSONGenerativeUI";
45
+
46
+ /** Mutable per-build outcomes the toolkit pass reports back for directive/guard injection. */
47
+ interface TargetFlags {
48
+ /** A `render` survived on a client build (→ emit `"use client"`). */
49
+ keptRender: boolean;
50
+ /** A backend `execute` survived on a server build (→ emit `import "server-only"`). */
51
+ keptBackendExecute: boolean;
52
+ }
17
53
 
18
54
  export interface CompileOptions {
19
55
  /** Which build target to emit. */
@@ -93,52 +129,64 @@ export function compileGenerative(
93
129
  );
94
130
  }
95
131
 
96
- const object = findDefaultExportObject(ast, filename);
132
+ ensureCompilerCompatibleWithCore(ast, filename);
97
133
 
98
- let keptRender = false;
99
- let keptBackendExecute = false;
134
+ // A module may hold several `defineToolkit(...)` / `defineGenerativeComponents(...)`
135
+ // calls anywhere (e.g. a library built inside `new JSONGenerativeUI(...)` plus
136
+ // the toolkit that exposes it). Tools may reference a `JSONGenerativeUI`
137
+ // instance's `present()`/`promptUser()`, which the library — not this compiler —
138
+ // splits across builds via export conditions; collect those instance names so
139
+ // such entries pass through.
140
+ ensureDefaultExport(ast, filename);
141
+ const generativeInstances = collectGenerativeInstances(ast);
142
+ const safeToolkitSpreads = collectSafeToolkitSpreads(ast);
100
143
 
101
- for (const entry of object.properties) {
102
- const value = entryValue(entry);
103
- if (!value) {
104
- // A non-inline tool (spread, method, or a call like `makeTool()`) can't be
105
- // analyzed, so its `execute` would pass through to the client unstripped.
106
- throw new GenerativeCompileError(
107
- "each tool must be an inline object literal (`name: { ... }`) so its " +
108
- "`execute` can be routed",
109
- filename,
110
- );
111
- }
144
+ const flags: TargetFlags = { keptRender: false, keptBackendExecute: false };
112
145
 
113
- // Nature is inferred from `execute` (see inferToolType), not an authored
114
- // `type`. The resolved type is written back below so the runtime keeps it.
115
- const type = inferToolType(value, filename);
116
- const hasRender = !!findMember(value, "render");
117
- const execute = findMember(value, "execute");
146
+ traverse(ast, {
147
+ CallExpression(path: NodePath<t.CallExpression>) {
148
+ const callee = path.node.callee;
149
+ const object = t.isObjectExpression(path.node.arguments[0])
150
+ ? path.node.arguments[0]
151
+ : null;
118
152
 
119
- if ((type === "frontend" || type === "human") && !hasRender) {
120
- throw new GenerativeCompileError(
121
- `a ${type} tool must declare a \`render\` (it has no server execute to show otherwise)`,
122
- filename,
123
- );
124
- }
153
+ if (t.isIdentifier(callee, { name: COMPONENTS_WRAPPER })) {
154
+ if (!object) {
155
+ throw new GenerativeCompileError(
156
+ `${COMPONENTS_WRAPPER}() takes an inline object literal of components`,
157
+ filename,
158
+ );
159
+ }
160
+ compileComponents(object, target, flags, filename);
161
+ // Unwrap the authoring helper to the bare library object so its import
162
+ // can be pruned.
163
+ path.replaceWith(object);
164
+ path.skip();
165
+ return;
166
+ }
125
167
 
126
- if (target === "client") {
127
- // A frontend execute stays (its `"use client"` marker is no longer needed
128
- // once the module is client); a backend execute and a human `hitl()`
129
- // sentinel are both dropped.
130
- if (execute && type === "frontend") stripUseClient(execute);
131
- else if (execute) removeMember(value, "execute");
132
- if (hasRender) keptRender = true;
133
- } else {
134
- // server: render is never needed; only a backend execute survives.
135
- if (hasRender) removeMember(value, "render");
136
- if (execute && type !== "backend") removeMember(value, "execute");
137
- if (execute && type === "backend") keptBackendExecute = true;
138
- }
168
+ if (t.isIdentifier(callee, { name: TOOLKIT_WRAPPER })) {
169
+ if (!object) {
170
+ throw new GenerativeCompileError(
171
+ `${TOOLKIT_WRAPPER}() takes an inline object literal of tools`,
172
+ filename,
173
+ );
174
+ }
175
+ compileToolkit(
176
+ object,
177
+ target,
178
+ generativeInstances,
179
+ safeToolkitSpreads,
180
+ flags,
181
+ filename,
182
+ );
183
+ path.replaceWith(object);
184
+ path.skip();
185
+ }
186
+ },
187
+ });
139
188
 
140
- setToolType(value, type);
141
- }
189
+ const { keptRender, keptBackendExecute } = flags;
142
190
 
143
191
  pruneUnused(ast);
144
192
 
@@ -174,61 +222,543 @@ export function compileGenerative(
174
222
  return { code: result.code, map: result.map };
175
223
  }
176
224
 
177
- function findDefaultExportObject(
225
+ interface PackageJson {
226
+ name?: string;
227
+ version?: string;
228
+ optionalDevDependencies?: Record<string, string>;
229
+ }
230
+
231
+ const checkedCorePackageJsonPaths = new Set<string>();
232
+ let compilerPackageVersion: string | undefined;
233
+
234
+ function ensureCompilerCompatibleWithCore(
178
235
  ast: t.File,
179
236
  filename: string | undefined,
180
- ): t.ObjectExpression {
181
- let object: t.ObjectExpression | null = null;
182
- let sawDefault = false;
237
+ ): void {
238
+ const corePackageJsonPath = resolveCorePackageJson(ast, filename);
239
+ if (
240
+ !corePackageJsonPath ||
241
+ checkedCorePackageJsonPaths.has(corePackageJsonPath)
242
+ ) {
243
+ return;
244
+ }
183
245
 
184
- for (const stmt of ast.program.body) {
185
- if (!t.isExportDefaultDeclaration(stmt)) continue;
186
- sawDefault = true;
187
- object = unwrapDefineToolkit(stmt.declaration);
188
- // Emit the bare object literal, dropping the `defineToolkit(...)` wrapper
189
- // (and the import it pulled).
190
- if (object) stmt.declaration = object;
246
+ const corePackageJson = readPackageJson(corePackageJsonPath);
247
+ const range = corePackageJson?.optionalDevDependencies?.[COMPILER_PACKAGE];
248
+ if (!range) {
249
+ checkedCorePackageJsonPaths.add(corePackageJsonPath);
250
+ return;
191
251
  }
192
252
 
193
- if (!sawDefault) {
253
+ const compilerVersion = getCompilerPackageVersion();
254
+ let compatible = false;
255
+ try {
256
+ compatible = satisfies(compilerVersion, range, {
257
+ includePrerelease: true,
258
+ });
259
+ } catch {
260
+ throw new GenerativeCompileError(
261
+ `${CORE_PACKAGE}@${corePackageJson.version ?? "unknown"} declares an ` +
262
+ `invalid optionalDevDependencies range for ${COMPILER_PACKAGE}: ` +
263
+ JSON.stringify(range),
264
+ filename,
265
+ );
266
+ }
267
+
268
+ if (!compatible) {
269
+ throw new GenerativeCompileError(
270
+ `${CORE_PACKAGE}@${corePackageJson.version ?? "unknown"} requires ` +
271
+ `${COMPILER_PACKAGE} ${range}, but the current compiler is ` +
272
+ `${compilerVersion}. Update @assistant-ui/next or @assistant-ui/vite ` +
273
+ "so their compiler satisfies the core package's " +
274
+ "optionalDevDependencies range.",
275
+ filename,
276
+ );
277
+ }
278
+
279
+ checkedCorePackageJsonPaths.add(corePackageJsonPath);
280
+ }
281
+
282
+ function getCompilerPackageVersion(): string {
283
+ if (compilerPackageVersion) return compilerPackageVersion;
284
+
285
+ const packageJsonPath = findPackageJson(import.meta.url, COMPILER_PACKAGE);
286
+ const packageJson = packageJsonPath ? readPackageJson(packageJsonPath) : null;
287
+ if (!packageJson?.version) {
288
+ throw new GenerativeCompileError(
289
+ `could not determine ${COMPILER_PACKAGE}'s package version`,
290
+ );
291
+ }
292
+ compilerPackageVersion = packageJson.version;
293
+ return compilerPackageVersion;
294
+ }
295
+
296
+ function resolveCorePackageJson(
297
+ ast: t.File,
298
+ filename: string | undefined,
299
+ ): string | null {
300
+ for (const packageName of collectImportedDistributionPackages(ast)) {
301
+ const packageJsonPath = resolvePackageJson(packageName, filename);
302
+ if (!packageJsonPath) continue;
303
+ if (packageName === CORE_PACKAGE) return packageJsonPath;
304
+
305
+ const corePackageJsonPath = resolvePackageJson(
306
+ CORE_PACKAGE,
307
+ packageJsonPath,
308
+ );
309
+ if (corePackageJsonPath) return corePackageJsonPath;
310
+ }
311
+
312
+ return null;
313
+ }
314
+
315
+ function collectImportedDistributionPackages(ast: t.File): Set<string> {
316
+ const packages = new Set<string>();
317
+
318
+ for (const statement of ast.program.body) {
319
+ const source =
320
+ (t.isImportDeclaration(statement) ||
321
+ t.isExportNamedDeclaration(statement) ||
322
+ t.isExportAllDeclaration(statement)) &&
323
+ statement.source
324
+ ? statement.source.value
325
+ : null;
326
+ if (!source) continue;
327
+
328
+ const packageName = packageNameFromSpecifier(source);
329
+ if (packageName) packages.add(packageName);
330
+ }
331
+
332
+ return packages;
333
+ }
334
+
335
+ function packageNameFromSpecifier(specifier: string): string | null {
336
+ for (const packageName of DISTRIBUTION_PACKAGES) {
337
+ if (specifier === packageName || specifier.startsWith(`${packageName}/`)) {
338
+ return packageName;
339
+ }
340
+ }
341
+
342
+ return null;
343
+ }
344
+
345
+ function resolvePackageJson(
346
+ packageName: string,
347
+ filename: string | undefined,
348
+ ): string | null {
349
+ const requirePath = normalizeRequirePath(filename);
350
+ const require = createRequire(requirePath);
351
+
352
+ try {
353
+ return findPackageJson(require.resolve(packageName), packageName);
354
+ } catch {
355
+ return findPackageJsonFromNodeModules(packageName, requirePath);
356
+ }
357
+ }
358
+
359
+ function normalizeRequirePath(filename: string | undefined): string {
360
+ if (filename) {
361
+ const cleanFilename = filename.split(/[?#]/, 1)[0]!;
362
+ if (nodePath.isAbsolute(cleanFilename)) return cleanFilename;
363
+ }
364
+ return import.meta.url;
365
+ }
366
+
367
+ function findPackageJson(
368
+ fromPathOrUrl: string,
369
+ packageName: string,
370
+ ): string | null {
371
+ let current = nodePath.dirname(
372
+ fromPathOrUrl.startsWith("file:")
373
+ ? new URL(fromPathOrUrl).pathname
374
+ : nodePath.resolve(fromPathOrUrl),
375
+ );
376
+
377
+ for (;;) {
378
+ const packageJsonPath = nodePath.join(current, "package.json");
379
+ const packageJson = readPackageJson(packageJsonPath);
380
+ if (packageJson?.name === packageName) return packageJsonPath;
381
+
382
+ const parent = nodePath.dirname(current);
383
+ if (parent === current) return null;
384
+ current = parent;
385
+ }
386
+ }
387
+
388
+ function findPackageJsonFromNodeModules(
389
+ packageName: string,
390
+ fromPathOrUrl: string,
391
+ ): string | null {
392
+ const parts = packageName.split("/");
393
+ let current = nodePath.dirname(
394
+ fromPathOrUrl.startsWith("file:")
395
+ ? new URL(fromPathOrUrl).pathname
396
+ : nodePath.resolve(fromPathOrUrl),
397
+ );
398
+
399
+ for (;;) {
400
+ const packageJsonPath = nodePath.join(
401
+ current,
402
+ "node_modules",
403
+ ...parts,
404
+ "package.json",
405
+ );
406
+ const packageJson = readPackageJson(packageJsonPath);
407
+ if (packageJson?.name === packageName) return packageJsonPath;
408
+
409
+ const parent = nodePath.dirname(current);
410
+ if (parent === current) return null;
411
+ current = parent;
412
+ }
413
+ }
414
+
415
+ function readPackageJson(packageJsonPath: string): PackageJson | null {
416
+ if (!existsSync(packageJsonPath)) return null;
417
+ try {
418
+ return JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson;
419
+ } catch (error) {
420
+ throw new GenerativeCompileError(
421
+ `could not parse package metadata at ${packageJsonPath}: ${
422
+ error instanceof Error ? error.message : String(error)
423
+ }`,
424
+ );
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Errors unless the module's default export is the toolkit — a `defineToolkit(...)`
430
+ * call (through `satisfies`/`as`/parens). This is the security boundary: the
431
+ * default export is what the runtime registers, so it must be wrapped (and thus
432
+ * split). A bare `export default { ... }` would ship a backend `execute` to the
433
+ * client even if some *other* `defineToolkit(...)` exists elsewhere in the file.
434
+ */
435
+ function ensureDefaultExport(ast: t.File, filename: string | undefined): void {
436
+ const def = ast.program.body.find(
437
+ (stmt): stmt is t.ExportDefaultDeclaration =>
438
+ t.isExportDefaultDeclaration(stmt),
439
+ );
440
+ if (!def) {
194
441
  throw new GenerativeCompileError("missing a default export", filename);
195
442
  }
196
- if (!object) {
443
+ if (!unwrapToCall(def.declaration, TOOLKIT_WRAPPER)) {
197
444
  throw new GenerativeCompileError(
198
- "the default export must be `defineToolkit({ ... })` (imported from " +
445
+ `the default export must be ${TOOLKIT_WRAPPER}({ ... }) (imported from ` +
199
446
  '"@assistant-ui/react"); wrapping is required so a backend `execute` ' +
200
447
  "can't be authored in a way that reaches the client",
201
448
  filename,
202
449
  );
203
450
  }
204
- return object;
205
451
  }
206
452
 
207
453
  /**
208
- * Unwraps the required `defineToolkit({ ... })` wrapper (through `satisfies`/`as`
209
- * and parens) to the underlying object literal. Anything else — a bare object, a
210
- * `satisfies Toolkit` without the wrapper, some other call — yields `null` so the
211
- * caller errors.
454
+ * Unwraps a node through `satisfies`/`as`/parens to a call of the named function,
455
+ * or returns `null`.
212
456
  */
213
- function unwrapDefineToolkit(node: t.Node): t.ObjectExpression | null {
457
+ function unwrapToCall(node: t.Node, name: string): t.CallExpression | null {
214
458
  if (t.isTSSatisfiesExpression(node) || t.isTSAsExpression(node)) {
215
- return unwrapDefineToolkit(node.expression);
459
+ return unwrapToCall(node.expression, name);
216
460
  }
217
461
  if (t.isParenthesizedExpression(node)) {
218
- return unwrapDefineToolkit(node.expression);
462
+ return unwrapToCall(node.expression, name);
463
+ }
464
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee, { name })) {
465
+ return node;
466
+ }
467
+ return null;
468
+ }
469
+
470
+ /**
471
+ * Collects the names bound to `new JSONGenerativeUI(...)` (e.g.
472
+ * `const generative = new JSONGenerativeUI({ library })`). A toolkit entry that
473
+ * calls a method on one of these is a generative tool whose halves the library
474
+ * routes by export condition, so it passes through the toolkit pass untouched.
475
+ */
476
+ function collectGenerativeInstances(ast: t.File): Set<string> {
477
+ const names = new Set<string>();
478
+ for (const statement of ast.program.body) {
479
+ if (!t.isVariableDeclaration(statement)) continue;
480
+ for (const declaration of statement.declarations) {
481
+ const { id, init } = declaration;
482
+ if (
483
+ t.isIdentifier(id) &&
484
+ t.isNewExpression(init) &&
485
+ t.isIdentifier(init.callee, { name: GENERATIVE_FACTORY })
486
+ ) {
487
+ names.add(id.name);
488
+ }
489
+ }
490
+ }
491
+ return names;
492
+ }
493
+
494
+ /**
495
+ * Local toolkit variables whose initializer is visible to this compiler pass
496
+ * are safe to spread. `defineToolkit(...)` initializers are compiled in-place
497
+ * before a later spread reads them; `defineMcpToolkit(...)` entries cannot
498
+ * contain executable code.
499
+ */
500
+ function collectSafeToolkitSpreads(ast: t.File): Set<string> {
501
+ const names = new Set<string>();
502
+ for (const statement of ast.program.body) {
503
+ if (!t.isVariableDeclaration(statement)) continue;
504
+ for (const declaration of statement.declarations) {
505
+ const { id, init } = declaration;
506
+ if (!t.isIdentifier(id) || !init) continue;
507
+ if (unwrapToToolkitCall(init)) names.add(id.name);
508
+ }
219
509
  }
510
+ return names;
511
+ }
512
+
513
+ function unwrapToToolkitCall(node: t.Node): t.CallExpression | null {
514
+ return (
515
+ unwrapToCall(node, TOOLKIT_WRAPPER) ??
516
+ unwrapToCall(node, MCP_TOOLKIT_WRAPPER)
517
+ );
518
+ }
519
+
520
+ function isSafeToolkitSpread(
521
+ entry: t.SpreadElement,
522
+ safeToolkitSpreads: Set<string>,
523
+ ): boolean {
524
+ if (t.isIdentifier(entry.argument)) {
525
+ return safeToolkitSpreads.has(entry.argument.name);
526
+ }
527
+
528
+ const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
529
+ return (
530
+ !!directMcpToolkit && t.isObjectExpression(directMcpToolkit.arguments[0])
531
+ );
532
+ }
533
+
534
+ /** The `JSONGenerativeUI` methods that produce a split-by-condition tool. */
535
+ const GENERATIVE_TOOL_METHODS = new Set(["present", "promptUser"]);
536
+
537
+ /**
538
+ * Whether a toolkit entry's value is a call to a tool-producing method on a
539
+ * collected `JSONGenerativeUI` instance (`generative.present()`), which passes
540
+ * through. The method name is checked too, so a typo like `generative.presnt()`
541
+ * is a compile error here rather than a pass-through that fails at runtime.
542
+ */
543
+ function isGenerativeToolEntry(value: t.Node, instances: Set<string>): boolean {
544
+ return (
545
+ t.isCallExpression(value) &&
546
+ t.isMemberExpression(value.callee) &&
547
+ !value.callee.computed &&
548
+ t.isIdentifier(value.callee.object) &&
549
+ instances.has(value.callee.object.name) &&
550
+ t.isIdentifier(value.callee.property) &&
551
+ GENERATIVE_TOOL_METHODS.has(value.callee.property.name)
552
+ );
553
+ }
554
+
555
+ /**
556
+ * Splits a `defineGenerativeComponents({ ... })` library for a build target:
557
+ * a component's `render` (and the client imports it alone uses) is dropped on
558
+ * the server; `properties`/`description` stay on both, since they drive the tool
559
+ * schema either way. Mutates the object in place.
560
+ */
561
+ function compileComponents(
562
+ object: t.ObjectExpression,
563
+ target: Target,
564
+ flags: TargetFlags,
565
+ filename: string | undefined,
566
+ ): void {
567
+ for (const entry of object.properties) {
568
+ const value = entryValue(entry);
569
+ if (!value) {
570
+ throw new GenerativeCompileError(
571
+ `each component in ${COMPONENTS_WRAPPER}() must be an inline object ` +
572
+ "literal (`name: { ... }`) so its `render` can be routed",
573
+ filename,
574
+ );
575
+ }
576
+ if (!findMember(value, "render")) continue;
577
+ // The client keeps `render` (and so needs the module marked `"use client"`);
578
+ // the server drops it, since only the schema reaches the model there.
579
+ if (target === "client") flags.keptRender = true;
580
+ else removeMember(value, "render");
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Splits a `defineToolkit({ ... })` for a build target. Each inline tool is
586
+ * routed by inferred type (see the per-entry logic); a generative entry like
587
+ * `generative.present()` passes through, the library having already split it.
588
+ * Mutates the object in place and records outcomes in {@link TargetFlags}.
589
+ */
590
+ function compileToolkit(
591
+ object: t.ObjectExpression,
592
+ target: Target,
593
+ instances: Set<string>,
594
+ safeToolkitSpreads: Set<string>,
595
+ flags: TargetFlags,
596
+ filename: string | undefined,
597
+ ): void {
598
+ const nextProperties: t.ObjectExpression["properties"] = [];
599
+
600
+ for (const entry of object.properties) {
601
+ const value = entryValue(entry);
602
+ if (!value) {
603
+ if (
604
+ t.isSpreadElement(entry) &&
605
+ isSafeToolkitSpread(entry, safeToolkitSpreads)
606
+ ) {
607
+ nextProperties.push(entry);
608
+ continue;
609
+ }
610
+ // A generative tool (`generative.present()`) is split by the library's
611
+ // export conditions, so it is safe to keep verbatim. Anything else — a
612
+ // method or opaque call like `makeTool()` — can't be analyzed, so its
613
+ // `execute` could reach the client unstripped.
614
+ const raw = entryRawValue(entry);
615
+ if (raw && isGenerativeToolEntry(raw, instances)) {
616
+ nextProperties.push(entry);
617
+ continue;
618
+ }
619
+ throw new GenerativeCompileError(
620
+ "each tool must be an inline object literal (`name: { ... }`) or a " +
621
+ "compiler-visible toolkit spread / generative tool (e.g. " +
622
+ "`...defineMcpToolkit(...)`, `...baseToolkit`, or " +
623
+ "`generative.present()`) so its `execute` can be routed",
624
+ filename,
625
+ );
626
+ }
627
+
628
+ // Nature is inferred from `execute` (see inferToolType), not an authored
629
+ // `type`. The resolved type is written back below so the runtime keeps it.
630
+ const execute = findMember(value, "execute");
631
+ const isStub = execute ? executeIsStubTool(execute) : false;
632
+ const isExternal = execute ? executeIsExternalTool(execute) : false;
633
+ const type = inferToolType(value, filename);
634
+ const hasRender = !!findMember(value, "render");
635
+ const hasRenderText = !!findMember(value, "renderText");
636
+
637
+ if (type === "frontend" && !hasRender && !hasRenderText) {
638
+ throw new GenerativeCompileError(
639
+ "a frontend tool must declare a `render` or `renderText` " +
640
+ "(it has no server execute to show otherwise)",
641
+ filename,
642
+ );
643
+ }
644
+
645
+ if (type === "human" && !hasRender) {
646
+ throw new GenerativeCompileError(
647
+ "a human tool must declare a `render` so it can collect input",
648
+ filename,
649
+ );
650
+ }
651
+
652
+ if (type === "provider" && execute) {
653
+ applyProviderToolConfig(value, execute, filename);
654
+ }
655
+
656
+ if (isExternal) {
657
+ if (!hasRender && !hasRenderText) {
658
+ throw new GenerativeCompileError(
659
+ "an external tool must declare a `render` or `renderText` " +
660
+ "(assistant-ui only renders calls for tools defined elsewhere)",
661
+ filename,
662
+ );
663
+ }
664
+ if (target === "server") continue;
665
+ stripExternalToolMetadata(value);
666
+ }
667
+
668
+ if (target === "client") {
669
+ // A non-stub frontend execute stays (its `"use client"` marker is no longer needed
670
+ // once the module is client); backend, sentinel, and stub executes are dropped.
671
+ if (execute && type === "frontend" && !isStub) stripUseClient(execute);
672
+ else if (execute) removeMember(value, "execute");
673
+ if (hasRender || hasRenderText) flags.keptRender = true;
674
+ } else {
675
+ // server: render is never needed; only a non-external backend execute survives.
676
+ if (hasRender) removeMember(value, "render");
677
+ if (hasRenderText) removeMember(value, "renderText");
678
+ if (execute) {
679
+ if (type === "backend" && !isExternal) flags.keptBackendExecute = true;
680
+ else removeMember(value, "execute");
681
+ }
682
+ }
683
+
684
+ setToolType(value, type);
685
+ setBackendDefault(value, target, type);
686
+ nextProperties.push(entry);
687
+ }
688
+
689
+ object.properties = nextProperties;
690
+ }
691
+
692
+ function applyProviderToolConfig(
693
+ object: t.ObjectExpression,
694
+ execute: t.ObjectProperty | t.ObjectMethod,
695
+ filename: string | undefined,
696
+ ): void {
220
697
  if (
221
- t.isCallExpression(node) &&
222
- t.isIdentifier(node.callee, { name: "defineToolkit" }) &&
223
- t.isObjectExpression(node.arguments[0])
698
+ !t.isObjectProperty(execute) ||
699
+ !t.isCallExpression(execute.value) ||
700
+ execute.value.arguments.length !== 1 ||
701
+ !t.isObjectExpression(execute.value.arguments[0])
224
702
  ) {
225
- return node.arguments[0];
703
+ throw new GenerativeCompileError(
704
+ "`providerTool(...)` must receive an inline object literal",
705
+ filename,
706
+ );
707
+ }
708
+
709
+ const existingNames = new Set(
710
+ object.properties.flatMap((prop) => {
711
+ if (!t.isObjectProperty(prop) && !t.isObjectMethod(prop)) return [];
712
+ const name = memberName(prop.key, prop.computed);
713
+ return name ? [name] : [];
714
+ }),
715
+ );
716
+ const configNames = new Set<string>();
717
+
718
+ for (const prop of execute.value.arguments[0].properties) {
719
+ if (!t.isObjectProperty(prop)) {
720
+ throw new GenerativeCompileError(
721
+ "`providerTool(...)` config can only contain object properties",
722
+ filename,
723
+ );
724
+ }
725
+ const name = memberName(prop.key, prop.computed);
726
+ if (!name) {
727
+ throw new GenerativeCompileError(
728
+ "`providerTool(...)` config can only contain static property names",
729
+ filename,
730
+ );
731
+ }
732
+ if (
733
+ t.isFunctionExpression(prop.value) ||
734
+ t.isArrowFunctionExpression(prop.value)
735
+ ) {
736
+ throw new GenerativeCompileError(
737
+ "`providerTool(...)` config cannot contain function-valued properties",
738
+ filename,
739
+ );
740
+ }
741
+ if (existingNames.has(name) || configNames.has(name)) {
742
+ throw new GenerativeCompileError(
743
+ "`providerTool(...)` config cannot duplicate tool properties",
744
+ filename,
745
+ );
746
+ }
747
+ configNames.add(name);
748
+ object.properties.push(prop);
226
749
  }
227
- return null;
228
750
  }
229
751
 
230
752
  type Entry = t.ObjectExpression["properties"][number];
231
753
 
754
+ /** The raw AST value of an entry (any expression), or null for spreads/methods. */
755
+ function entryRawValue(entry: Entry): t.Expression | null {
756
+ if (t.isObjectProperty(entry) && t.isExpression(entry.value)) {
757
+ return entry.value;
758
+ }
759
+ return null;
760
+ }
761
+
232
762
  function entryValue(entry: Entry): t.ObjectExpression | null {
233
763
  if (t.isObjectProperty(entry) && t.isObjectExpression(entry.value)) {
234
764
  return entry.value;
@@ -280,15 +810,47 @@ function executeIsClient(member: t.ObjectProperty | t.ObjectMethod): boolean {
280
810
  );
281
811
  }
282
812
 
283
- /** Whether an `execute` is the `hitl()` human-in-the-loop sentinel. */
284
- function executeIsHitl(member: t.ObjectProperty | t.ObjectMethod): boolean {
813
+ function executeIsSentinel(
814
+ member: t.ObjectProperty | t.ObjectMethod,
815
+ name: string,
816
+ ): boolean {
285
817
  return (
286
818
  t.isObjectProperty(member) &&
287
819
  t.isCallExpression(member.value) &&
288
- t.isIdentifier(member.value.callee, { name: "hitl" })
820
+ t.isIdentifier(member.value.callee, { name })
821
+ );
822
+ }
823
+
824
+ /** Whether an `execute` is the human-in-the-loop sentinel. */
825
+ function executeIsHumanTool(
826
+ member: t.ObjectProperty | t.ObjectMethod,
827
+ ): boolean {
828
+ return (
829
+ executeIsSentinel(member, "humanTool") ||
830
+ executeIsSentinel(member, "hitlTool") ||
831
+ executeIsSentinel(member, "hitl")
289
832
  );
290
833
  }
291
834
 
835
+ /** Whether an `execute` is the provider-tool sentinel. */
836
+ function executeIsProviderTool(
837
+ member: t.ObjectProperty | t.ObjectMethod,
838
+ ): boolean {
839
+ return executeIsSentinel(member, "providerTool");
840
+ }
841
+
842
+ /** Whether an `execute` is the local override sentinel. */
843
+ function executeIsStubTool(member: t.ObjectProperty | t.ObjectMethod): boolean {
844
+ return executeIsSentinel(member, "stubTool");
845
+ }
846
+
847
+ /** Whether an `execute` is the externally-defined backend tool sentinel. */
848
+ function executeIsExternalTool(
849
+ member: t.ObjectProperty | t.ObjectMethod,
850
+ ): boolean {
851
+ return executeIsSentinel(member, "externalTool");
852
+ }
853
+
292
854
  /** Drops the `"use client"` directive from an `execute` body (kept frontend). */
293
855
  function stripUseClient(member: t.ObjectProperty | t.ObjectMethod): void {
294
856
  const body = executeBody(member);
@@ -301,9 +863,11 @@ function stripUseClient(member: t.ObjectProperty | t.ObjectMethod): void {
301
863
 
302
864
  /**
303
865
  * The tool's nature, inferred from its (mandatory) `execute` rather than an
304
- * authored `type`: `hitl()` → `human`; `"use client"` → `frontend`; otherwise
305
- * `backend`. The loader writes the result back as a `type` field (see
306
- * {@link setToolType}) so the runtime keeps it.
866
+ * authored `type`: `humanTool()` → `human`; `providerTool(...)` → `provider`;
867
+ * `stubTool()` → `frontend`; `externalTool()` → `backend`; `"use client"` →
868
+ * `frontend`; otherwise `backend`.
869
+ * The loader writes the result back as a `type` field (see {@link setToolType})
870
+ * so the runtime keeps it.
307
871
  */
308
872
  function inferToolType(
309
873
  object: t.ObjectExpression,
@@ -312,15 +876,31 @@ function inferToolType(
312
876
  const execute = findMember(object, "execute");
313
877
  if (!execute) {
314
878
  throw new GenerativeCompileError(
315
- "every tool must declare an `execute`; use `hitl()` for a " +
879
+ "every tool must declare an `execute`; use `humanTool()` for a " +
316
880
  "human-in-the-loop tool",
317
881
  filename,
318
882
  );
319
883
  }
320
- if (executeIsHitl(execute)) return "human";
884
+ if (executeIsHumanTool(execute)) return "human";
885
+ if (executeIsProviderTool(execute)) return "provider";
886
+ if (executeIsStubTool(execute)) return "frontend";
887
+ if (executeIsExternalTool(execute)) return "backend";
321
888
  return executeIsClient(execute) ? "frontend" : "backend";
322
889
  }
323
890
 
891
+ function stripExternalToolMetadata(object: t.ObjectExpression): void {
892
+ // Mirror BackendTool's forbidden metadata fields: execute is stripped by the
893
+ // main routing loop, while streamCall is also removed because there is no
894
+ // assistant-ui executor to stream from.
895
+ removeMember(object, "description");
896
+ removeMember(object, "parameters");
897
+ removeMember(object, "disabled");
898
+ removeMember(object, "toModelOutput");
899
+ removeMember(object, "experimental_onSchemaValidationError");
900
+ removeMember(object, "providerOptions");
901
+ removeMember(object, "streamCall");
902
+ }
903
+
324
904
  /** Writes the resolved `type` back onto the tool object (replacing any author's). */
325
905
  function setToolType(object: t.ObjectExpression, type: ToolType): void {
326
906
  removeMember(object, "type");
@@ -330,6 +910,26 @@ function setToolType(object: t.ObjectExpression, type: ToolType): void {
330
910
  );
331
911
  }
332
912
 
913
+ function setBackendDefault(
914
+ object: t.ObjectExpression,
915
+ target: Target,
916
+ type: ToolType,
917
+ ): void {
918
+ // Always strip any hand-authored marker first; only re-add it for client
919
+ // frontend/human tools whose schema is already known by the backend.
920
+ removeMember(object, "unstable_backendDefault");
921
+ if (target !== "client" || (type !== "frontend" && type !== "human")) return;
922
+
923
+ object.properties.push(
924
+ t.objectProperty(
925
+ t.identifier("unstable_backendDefault"),
926
+ t.objectExpression([
927
+ t.objectProperty(t.identifier("parameters"), t.booleanLiteral(true)),
928
+ ]),
929
+ ),
930
+ );
931
+ }
932
+
333
933
  function memberName(
334
934
  key: t.Node,
335
935
  computed: boolean | undefined,