@assistant-ui/x-generative-compiler 0.0.5 → 0.0.8

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
@@ -37,12 +37,21 @@ const DISTRIBUTION_PACKAGES = [
37
37
  "@assistant-ui/react-native",
38
38
  "@assistant-ui/react-ink",
39
39
  ] as const;
40
+ /** Package that exports the generative UI runtime split by export condition. */
41
+ const GENERATIVE_UI_PACKAGE = "@assistant-ui/react-generative-ui";
40
42
  /**
41
43
  * The class whose instances expose split-by-condition tools (`present()`,
42
44
  * `promptUser()`). A toolkit entry that calls a method on one of these passes
43
45
  * through untouched — the library, not this compiler, routes its halves.
44
46
  */
45
47
  const GENERATIVE_FACTORY = "JSONGenerativeUI";
48
+ /**
49
+ * The factory producing an interactable's complete tool entry. Unlike
50
+ * `JSONGenerativeUI`, its package has no per-target builds, so this compiler
51
+ * splits the inline config: the client keeps `render`, the server drops it.
52
+ * The factory's own `execute` is internal and client-safe (frontend tool).
53
+ */
54
+ const INTERACTABLE_TOOL_FACTORY = "unstable_interactableTool";
46
55
 
47
56
  /** Mutable per-build outcomes the toolkit pass reports back for directive/guard injection. */
48
57
  interface TargetFlags {
@@ -78,7 +87,9 @@ export interface CompileResult {
78
87
  /** Thrown when a `"use generative"` file violates an authoring constraint. */
79
88
  export class GenerativeCompileError extends Error {
80
89
  constructor(message: string, filename?: string) {
81
- super(`[assistant-ui/next]${filename ? ` ${filename}:` : ""} ${message}`);
90
+ super(
91
+ `[assistant-ui/use-generative]${filename ? ` ${filename}:` : ""} ${message}`,
92
+ );
82
93
  this.name = "GenerativeCompileError";
83
94
  }
84
95
  }
@@ -103,11 +114,126 @@ export function isGenerativeModule(code: string): boolean {
103
114
  break;
104
115
  }
105
116
  }
117
+
118
+ const quote = code[i];
119
+ if (quote !== '"' && quote !== "'") return false;
120
+
121
+ const directiveStart = i + 1;
122
+ if (!code.startsWith(DIRECTIVE, directiveStart)) return false;
123
+
124
+ const directiveEnd = directiveStart + DIRECTIVE.length;
125
+ if (code[directiveEnd] !== quote) return false;
126
+
127
+ return hasDirectiveTerminator(code, directiveEnd + 1);
128
+ }
129
+
130
+ function hasDirectiveTerminator(code: string, start: number): boolean {
131
+ let i = start;
132
+ let sawLineTerminator = false;
133
+ for (;;) {
134
+ if (i >= code.length) return true;
135
+
136
+ const char = code.charCodeAt(i);
137
+ if (isSemicolon(char)) return true;
138
+ if (isLineTerminator(char)) {
139
+ sawLineTerminator = true;
140
+ i++;
141
+ continue;
142
+ }
143
+
144
+ if (code.startsWith("//", i)) {
145
+ const lineEnd = nextLineTerminatorIndex(code, i + 2);
146
+ if (lineEnd === -1) return true;
147
+ sawLineTerminator = true;
148
+ i = lineEnd + 1;
149
+ continue;
150
+ }
151
+
152
+ if (code.startsWith("/*", i)) {
153
+ const end = code.indexOf("*/", i + 2);
154
+ if (end === -1) return false;
155
+ sawLineTerminator ||= containsLineTerminator(code, i + 2, end);
156
+ i = end + 2;
157
+ continue;
158
+ }
159
+
160
+ if (/\s/.test(code[i]!)) {
161
+ i++;
162
+ continue;
163
+ }
164
+
165
+ return sawLineTerminator && !startsExpressionContinuation(code, i);
166
+ }
167
+ }
168
+
169
+ function nextLineTerminatorIndex(code: string, start: number): number {
170
+ for (let i = start; i < code.length; i++) {
171
+ if (isLineTerminator(code.charCodeAt(i))) return i;
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ function containsLineTerminator(
177
+ code: string,
178
+ start: number,
179
+ end: number,
180
+ ): boolean {
181
+ for (let i = start; i < end; i++) {
182
+ if (isLineTerminator(code.charCodeAt(i))) return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ function startsExpressionContinuation(code: string, start: number): boolean {
188
+ switch (code[start]) {
189
+ case ".":
190
+ case "+":
191
+ case "-":
192
+ case "*":
193
+ case "/":
194
+ case "%":
195
+ case "<":
196
+ case ">":
197
+ case "=":
198
+ case "!":
199
+ case "&":
200
+ case "|":
201
+ case "^":
202
+ case "?":
203
+ case ":":
204
+ case ",":
205
+ case "[":
206
+ case "(":
207
+ case "`":
208
+ return true;
209
+ }
210
+
106
211
  return (
107
- code.startsWith(`"${DIRECTIVE}"`, i) || code.startsWith(`'${DIRECTIVE}'`, i)
212
+ startsKeywordContinuation(code, start, "as") ||
213
+ startsKeywordContinuation(code, start, "in") ||
214
+ startsKeywordContinuation(code, start, "instanceof") ||
215
+ startsKeywordContinuation(code, start, "satisfies")
108
216
  );
109
217
  }
110
218
 
219
+ function startsKeywordContinuation(
220
+ code: string,
221
+ start: number,
222
+ keyword: string,
223
+ ): boolean {
224
+ if (!code.startsWith(keyword, start)) return false;
225
+ const next = code[start + keyword.length];
226
+ return next === undefined || !/[\p{ID_Continue}$]/u.test(next);
227
+ }
228
+
229
+ function isSemicolon(char: number): boolean {
230
+ return char === 59;
231
+ }
232
+
233
+ function isLineTerminator(char: number): boolean {
234
+ return char === 10 || char === 13 || char === 0x2028 || char === 0x2029;
235
+ }
236
+
111
237
  /**
112
238
  * Rewrites a `"use generative"` module for a single build target, keeping only the
113
239
  * regions that target needs and pruning the imports the dropped regions used.
@@ -140,7 +266,12 @@ export function compileGenerative(
140
266
  // such entries pass through.
141
267
  ensureDefaultExport(ast, filename);
142
268
  const generativeInstances = collectGenerativeInstances(ast);
143
- const safeToolkitSpreads = collectSafeToolkitSpreads(ast, filename);
269
+ const interactableToolImports = collectInteractableToolImports(ast);
270
+ const toolkitSpreadNames = collectToolkitSpreadNames(
271
+ ast,
272
+ filename,
273
+ createToolkitNameContext(),
274
+ );
144
275
 
145
276
  const flags: TargetFlags = { keptRender: false, keptBackendExecute: false };
146
277
 
@@ -177,7 +308,8 @@ export function compileGenerative(
177
308
  object,
178
309
  target,
179
310
  generativeInstances,
180
- safeToolkitSpreads,
311
+ interactableToolImports,
312
+ toolkitSpreadNames,
181
313
  flags,
182
314
  filename,
183
315
  );
@@ -470,14 +602,22 @@ function unwrapToCall(node: t.Node, name: string): t.CallExpression | null {
470
602
  */
471
603
  function collectGenerativeInstances(ast: t.File): Set<string> {
472
604
  const names = new Set<string>();
605
+ const generativeFactories = collectGenerativeFactoryImports(ast);
473
606
  for (const statement of ast.program.body) {
474
- if (!t.isVariableDeclaration(statement)) continue;
475
- for (const declaration of statement.declarations) {
607
+ const variableDeclaration = t.isVariableDeclaration(statement)
608
+ ? statement
609
+ : t.isExportNamedDeclaration(statement) &&
610
+ t.isVariableDeclaration(statement.declaration)
611
+ ? statement.declaration
612
+ : null;
613
+ if (!variableDeclaration) continue;
614
+ for (const declaration of variableDeclaration.declarations) {
476
615
  const { id, init } = declaration;
477
616
  if (
478
617
  t.isIdentifier(id) &&
479
618
  t.isNewExpression(init) &&
480
- t.isIdentifier(init.callee, { name: GENERATIVE_FACTORY })
619
+ t.isIdentifier(init.callee) &&
620
+ generativeFactories.has(init.callee.name)
481
621
  ) {
482
622
  names.add(id.name);
483
623
  }
@@ -486,8 +626,85 @@ function collectGenerativeInstances(ast: t.File): Set<string> {
486
626
  return names;
487
627
  }
488
628
 
629
+ function collectGenerativeFactoryImports(ast: t.File): Set<string> {
630
+ const names = new Set<string>();
631
+ for (const statement of ast.program.body) {
632
+ if (
633
+ !t.isImportDeclaration(statement) ||
634
+ statement.source.value !== GENERATIVE_UI_PACKAGE
635
+ ) {
636
+ continue;
637
+ }
638
+
639
+ for (const specifier of statement.specifiers) {
640
+ if (
641
+ t.isImportSpecifier(specifier) &&
642
+ t.isIdentifier(specifier.imported, { name: GENERATIVE_FACTORY })
643
+ ) {
644
+ names.add(specifier.local.name);
645
+ }
646
+ }
647
+ }
648
+ return names;
649
+ }
650
+
651
+ type ToolkitStaticNames = readonly string[] | null;
652
+ type ToolkitSpreadNames = Map<string, ToolkitStaticNames>;
653
+
654
+ interface ToolkitNameContext {
655
+ importedToolkitNamesByFile: Map<string, ToolkitStaticNames | undefined>;
656
+ resolvingImportedToolkitNames: Set<string>;
657
+ }
658
+
659
+ /**
660
+ * Collects the local names `unstable_interactableTool` is imported under from a
661
+ * distribution package, so toolkit entries calling it can be recognized
662
+ * (and a same-named local function can't smuggle an arbitrary call through).
663
+ */
664
+ function collectInteractableToolImports(ast: t.File): Set<string> {
665
+ const names = new Set<string>();
666
+ for (const statement of ast.program.body) {
667
+ if (!t.isImportDeclaration(statement)) continue;
668
+ if (!packageNameFromSpecifier(statement.source.value)) continue;
669
+ for (const specifier of statement.specifiers) {
670
+ if (
671
+ t.isImportSpecifier(specifier) &&
672
+ t.isIdentifier(specifier.imported, { name: INTERACTABLE_TOOL_FACTORY })
673
+ ) {
674
+ names.add(specifier.local.name);
675
+ }
676
+ }
677
+ }
678
+ return names;
679
+ }
680
+
681
+ function createToolkitNameContext(): ToolkitNameContext {
682
+ return {
683
+ importedToolkitNamesByFile: new Map(),
684
+ resolvingImportedToolkitNames: new Set(),
685
+ };
686
+ }
687
+
489
688
  /**
490
- * Toolkit identifiers that are safe to spread into a `defineToolkit({ ... })`.
689
+ * The inline config of an `unstable_interactableTool({ ... })` toolkit entry, or `null`
690
+ * when the entry is some other expression.
691
+ */
692
+ function interactableToolConfig(
693
+ value: t.Node,
694
+ imports: Set<string>,
695
+ ): t.ObjectExpression | null {
696
+ return t.isCallExpression(value) &&
697
+ t.isIdentifier(value.callee) &&
698
+ imports.has(value.callee.name) &&
699
+ t.isObjectExpression(value.arguments[0])
700
+ ? value.arguments[0]
701
+ : null;
702
+ }
703
+
704
+ /**
705
+ * Toolkit identifiers that are safe to spread into a `defineToolkit({ ... })`,
706
+ * paired with the static tool names they contain. A `null` name list means the
707
+ * spread is safe, but its names are not statically known for duplicate checks.
491
708
  *
492
709
  * Two kinds qualify:
493
710
  *
@@ -500,19 +717,21 @@ function collectGenerativeInstances(ast: t.File): Set<string> {
500
717
  * export crosses the generative-module boundary, so named imports don't
501
718
  * qualify — they would be `undefined` once that module is build-split.
502
719
  */
503
- function collectSafeToolkitSpreads(
720
+ function collectToolkitSpreadNames(
504
721
  ast: t.File,
505
722
  filename: string | undefined,
506
- ): Set<string> {
507
- const names = new Set<string>();
508
- const generativeBySource = new Map<string, boolean>();
723
+ context: ToolkitNameContext,
724
+ ): ToolkitSpreadNames {
725
+ const spreadNames: ToolkitSpreadNames = new Map();
726
+ const localToolkitCalls = new Map<string, t.CallExpression>();
509
727
 
510
728
  for (const statement of ast.program.body) {
511
729
  if (t.isVariableDeclaration(statement)) {
512
730
  for (const declaration of statement.declarations) {
513
731
  const { id, init } = declaration;
514
- if (t.isIdentifier(id) && init && unwrapToToolkitCall(init)) {
515
- names.add(id.name);
732
+ if (t.isIdentifier(id) && init) {
733
+ const toolkitCall = unwrapToToolkitCall(init);
734
+ if (toolkitCall) localToolkitCalls.set(id.name, toolkitCall);
516
735
  }
517
736
  }
518
737
  continue;
@@ -525,17 +744,40 @@ function collectSafeToolkitSpreads(
525
744
  );
526
745
  if (!defaultSpecifier) continue;
527
746
 
528
- const source = statement.source.value;
529
- let isGenerative = generativeBySource.get(source);
530
- if (isGenerative === undefined) {
531
- isGenerative = isGenerativeImport(source, filename);
532
- generativeBySource.set(source, isGenerative);
747
+ const names = getGenerativeImportToolkitNames(
748
+ statement.source.value,
749
+ filename,
750
+ context,
751
+ );
752
+ if (names !== undefined) {
753
+ spreadNames.set(defaultSpecifier.local.name, names);
533
754
  }
534
- if (isGenerative) names.add(defaultSpecifier.local.name);
535
755
  }
536
756
  }
537
757
 
538
- return names;
758
+ const resolveLocal = (name: string): ToolkitStaticNames | undefined => {
759
+ if (spreadNames.has(name)) return spreadNames.get(name);
760
+
761
+ const call = localToolkitCalls.get(name);
762
+ if (!call) return undefined;
763
+
764
+ const object = t.isObjectExpression(call.arguments[0])
765
+ ? call.arguments[0]
766
+ : null;
767
+ const names = object
768
+ ? collectToolkitObjectNames(object, spreadNames)
769
+ : null;
770
+ const publicNames = uniqueToolkitNames(names);
771
+
772
+ spreadNames.set(name, publicNames);
773
+ return publicNames;
774
+ };
775
+
776
+ for (const name of localToolkitCalls.keys()) {
777
+ resolveLocal(name);
778
+ }
779
+
780
+ return spreadNames;
539
781
  }
540
782
 
541
783
  const MODULE_EXTENSIONS = [
@@ -553,26 +795,78 @@ const MODULE_EXTENSIONS = [
553
795
  const REWRITABLE_JS_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".cjs"]);
554
796
 
555
797
  /**
556
- * Whether an import specifier resolves on disk to a `"use generative"` module.
557
- * Relative specifiers and `tsconfig` path aliases (e.g. `@/tools`) are resolved;
558
- * anything else (a bare package, an unresolvable alias) is treated as
559
- * non-generative, and thus an unsafe spread.
798
+ * Reads the static tool names from the default export of an imported
799
+ * `"use generative"` module. Relative specifiers and `tsconfig` path aliases
800
+ * (e.g. `@/tools`) are resolved; anything else (a bare package, an unresolvable
801
+ * alias) is treated as non-generative, and thus an unsafe spread.
560
802
  */
561
- function isGenerativeImport(
803
+ function getGenerativeImportToolkitNames(
562
804
  source: string,
563
805
  filename: string | undefined,
564
- ): boolean {
806
+ context: ToolkitNameContext,
807
+ ): ToolkitStaticNames | undefined {
565
808
  const cleanFilename = cleanAbsoluteFilename(filename);
566
- if (!cleanFilename) return false;
809
+ if (!cleanFilename) return undefined;
567
810
 
568
811
  const resolved = resolveImportedModuleFile(source, cleanFilename);
569
- if (!resolved) return false;
812
+ if (!resolved) return undefined;
813
+
814
+ if (context.importedToolkitNamesByFile.has(resolved)) {
815
+ return context.importedToolkitNamesByFile.get(resolved);
816
+ }
817
+ if (context.resolvingImportedToolkitNames.has(resolved)) return null;
570
818
 
819
+ let code: string;
571
820
  try {
572
- return isGenerativeModule(readFileSync(resolved, "utf8"));
821
+ code = readFileSync(resolved, "utf8");
573
822
  } catch {
574
- return false;
823
+ context.importedToolkitNamesByFile.set(resolved, undefined);
824
+ return undefined;
825
+ }
826
+
827
+ if (!isGenerativeModule(code)) {
828
+ context.importedToolkitNamesByFile.set(resolved, undefined);
829
+ return undefined;
830
+ }
831
+
832
+ context.resolvingImportedToolkitNames.add(resolved);
833
+ try {
834
+ const importedAst = parse(code, {
835
+ sourceType: "module",
836
+ plugins: ["typescript", "jsx", "explicitResourceManagement"],
837
+ });
838
+ const names = getDefaultExportToolkitNames(importedAst, resolved, context);
839
+ context.importedToolkitNamesByFile.set(resolved, names);
840
+ return names;
841
+ } catch (error) {
842
+ if (error instanceof GenerativeCompileError) throw error;
843
+ context.importedToolkitNamesByFile.set(resolved, null);
844
+ return null;
845
+ } finally {
846
+ context.resolvingImportedToolkitNames.delete(resolved);
847
+ }
848
+ }
849
+
850
+ function getDefaultExportToolkitNames(
851
+ ast: t.File,
852
+ filename: string | undefined,
853
+ context: ToolkitNameContext,
854
+ ): ToolkitStaticNames {
855
+ const def = ast.program.body.find(
856
+ (stmt): stmt is t.ExportDefaultDeclaration =>
857
+ t.isExportDefaultDeclaration(stmt),
858
+ );
859
+ if (!def) return null;
860
+
861
+ const toolkitCall = unwrapToToolkitCall(def.declaration);
862
+ if (!toolkitCall || !t.isObjectExpression(toolkitCall.arguments[0])) {
863
+ return null;
575
864
  }
865
+
866
+ const spreadNames = collectToolkitSpreadNames(ast, filename, context);
867
+ return uniqueToolkitNames(
868
+ collectToolkitObjectNames(toolkitCall.arguments[0], spreadNames),
869
+ );
576
870
  }
577
871
 
578
872
  /** Resolves an import specifier (relative or `tsconfig`-aliased) to a file on disk. */
@@ -797,12 +1091,90 @@ function unwrapToToolkitCall(node: t.Node): t.CallExpression | null {
797
1091
  );
798
1092
  }
799
1093
 
1094
+ function collectToolkitObjectNames(
1095
+ object: t.ObjectExpression,
1096
+ toolkitSpreadNames: ToolkitSpreadNames,
1097
+ ): ToolkitStaticNames {
1098
+ const names: string[] = [];
1099
+
1100
+ for (const entry of object.properties) {
1101
+ const entryNames = toolkitEntryNames(entry, toolkitSpreadNames);
1102
+ if (!entryNames) return null;
1103
+ names.push(...entryNames);
1104
+ }
1105
+
1106
+ return names;
1107
+ }
1108
+
1109
+ function uniqueToolkitNames(names: ToolkitStaticNames): ToolkitStaticNames {
1110
+ return names ? [...new Set(names)] : names;
1111
+ }
1112
+
1113
+ function toolkitEntryNames(
1114
+ entry: t.ObjectExpression["properties"][number],
1115
+ toolkitSpreadNames: ToolkitSpreadNames,
1116
+ ): ToolkitStaticNames {
1117
+ if (t.isSpreadElement(entry)) {
1118
+ if (t.isIdentifier(entry.argument)) {
1119
+ return toolkitSpreadNames.get(entry.argument.name) ?? null;
1120
+ }
1121
+
1122
+ const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
1123
+ if (
1124
+ directMcpToolkit &&
1125
+ t.isObjectExpression(directMcpToolkit.arguments[0])
1126
+ ) {
1127
+ return collectToolkitObjectNames(
1128
+ directMcpToolkit.arguments[0],
1129
+ toolkitSpreadNames,
1130
+ );
1131
+ }
1132
+
1133
+ return null;
1134
+ }
1135
+
1136
+ if (t.isObjectProperty(entry) || t.isObjectMethod(entry)) {
1137
+ const name = memberName(entry.key, entry.computed);
1138
+ return name ? [name] : [];
1139
+ }
1140
+
1141
+ return [];
1142
+ }
1143
+
1144
+ function warnDuplicateToolkitNames(
1145
+ object: t.ObjectExpression,
1146
+ toolkitSpreadNames: ToolkitSpreadNames,
1147
+ filename: string | undefined,
1148
+ ): void {
1149
+ const names = collectToolkitObjectNames(object, toolkitSpreadNames);
1150
+ if (!names) return;
1151
+
1152
+ const seen = new Set<string>();
1153
+ const warned = new Set<string>();
1154
+ for (const name of names) {
1155
+ if (seen.has(name)) {
1156
+ if (!warned.has(name)) {
1157
+ console.warn(
1158
+ new GenerativeCompileError(
1159
+ `Duplicate tool name "${name}" while composing toolkits. ` +
1160
+ "JavaScript object spread keeps the last definition.",
1161
+ filename,
1162
+ ).message,
1163
+ );
1164
+ warned.add(name);
1165
+ }
1166
+ continue;
1167
+ }
1168
+ seen.add(name);
1169
+ }
1170
+ }
1171
+
800
1172
  function isSafeToolkitSpread(
801
1173
  entry: t.SpreadElement,
802
- safeToolkitSpreads: Set<string>,
1174
+ toolkitSpreadNames: ToolkitSpreadNames,
803
1175
  ): boolean {
804
1176
  if (t.isIdentifier(entry.argument)) {
805
- return safeToolkitSpreads.has(entry.argument.name);
1177
+ return toolkitSpreadNames.has(entry.argument.name);
806
1178
  }
807
1179
 
808
1180
  const directMcpToolkit = unwrapToCall(entry.argument, MCP_TOOLKIT_WRAPPER);
@@ -811,6 +1183,30 @@ function isSafeToolkitSpread(
811
1183
  );
812
1184
  }
813
1185
 
1186
+ function genericUnsafeToolkitEntryMessage(): string {
1187
+ return (
1188
+ "each tool must be an inline object literal (`name: { ... }`) or a " +
1189
+ "compiler-visible toolkit spread / generative tool (e.g. " +
1190
+ "`...defineMcpToolkit(...)`, `...baseToolkit`, " +
1191
+ "`generative.present()`, or `unstable_interactableTool(...)`) so its " +
1192
+ "`execute` can be routed"
1193
+ );
1194
+ }
1195
+
1196
+ function describeUnsafeToolkitEntry(entry: Entry): string {
1197
+ if (!t.isObjectProperty(entry)) return genericUnsafeToolkitEntryMessage();
1198
+
1199
+ const toolName = memberName(entry.key, entry.computed);
1200
+ const raw = entryRawValue(entry);
1201
+ if (!toolName || !raw) return genericUnsafeToolkitEntryMessage();
1202
+
1203
+ return (
1204
+ `tool "${toolName}" cannot be \`${generate(raw).code}\`; use an inline ` +
1205
+ `object literal (\`${toolName}: { ... }\`) or a compiler-visible toolkit ` +
1206
+ "spread / generative tool so its `execute` can be routed"
1207
+ );
1208
+ }
1209
+
814
1210
  /** The `JSONGenerativeUI` methods that produce a split-by-condition tool. */
815
1211
  const GENERATIVE_TOOL_METHODS = new Set(["present", "promptUser"]);
816
1212
 
@@ -871,10 +1267,17 @@ function compileToolkit(
871
1267
  object: t.ObjectExpression,
872
1268
  target: Target,
873
1269
  instances: Set<string>,
874
- safeToolkitSpreads: Set<string>,
1270
+ interactableToolImports: Set<string>,
1271
+ toolkitSpreadNames: ToolkitSpreadNames,
875
1272
  flags: TargetFlags,
876
1273
  filename: string | undefined,
877
1274
  ): void {
1275
+ // Split builds compile both targets; emit target-independent warnings from
1276
+ // the client pass so each duplicate is logged once.
1277
+ if (target === "client") {
1278
+ warnDuplicateToolkitNames(object, toolkitSpreadNames, filename);
1279
+ }
1280
+
878
1281
  const nextProperties: t.ObjectExpression["properties"] = [];
879
1282
 
880
1283
  for (const entry of object.properties) {
@@ -882,7 +1285,7 @@ function compileToolkit(
882
1285
  if (!value) {
883
1286
  if (
884
1287
  t.isSpreadElement(entry) &&
885
- isSafeToolkitSpread(entry, safeToolkitSpreads)
1288
+ isSafeToolkitSpread(entry, toolkitSpreadNames)
886
1289
  ) {
887
1290
  nextProperties.push(entry);
888
1291
  continue;
@@ -896,27 +1299,36 @@ function compileToolkit(
896
1299
  nextProperties.push(entry);
897
1300
  continue;
898
1301
  }
1302
+ const config =
1303
+ raw && interactableToolConfig(raw, interactableToolImports);
1304
+ if (config) {
1305
+ if (target === "client") flags.keptRender = true;
1306
+ else removeMember(config, "render");
1307
+ nextProperties.push(entry);
1308
+ continue;
1309
+ }
899
1310
  throw new GenerativeCompileError(
900
- "each tool must be an inline object literal (`name: { ... }`) or a " +
901
- "compiler-visible toolkit spread / generative tool (e.g. " +
902
- "`...defineMcpToolkit(...)`, `...baseToolkit`, or " +
903
- "`generative.present()`) so its `execute` can be routed",
1311
+ describeUnsafeToolkitEntry(entry),
904
1312
  filename,
905
1313
  );
906
1314
  }
907
1315
 
908
1316
  // Nature is inferred from `execute` (see inferToolType), not an authored
909
1317
  // `type`. The resolved type is written back below so the runtime keeps it.
1318
+ const toolName = t.isObjectProperty(entry)
1319
+ ? memberName(entry.key, entry.computed)
1320
+ : undefined;
910
1321
  const execute = findMember(value, "execute");
911
1322
  const isStub = execute ? executeIsStubTool(execute) : false;
912
1323
  const isExternal = execute ? executeIsExternalTool(execute) : false;
913
- const type = inferToolType(value, filename);
1324
+ const type = inferToolType(value, toolName, filename);
914
1325
  const hasRender = !!findMember(value, "render");
915
1326
  const hasRenderText = !!findMember(value, "renderText");
916
1327
 
917
1328
  if (type === "frontend" && !hasRender && !hasRenderText) {
918
1329
  throw new GenerativeCompileError(
919
- "a frontend tool must declare a `render` or `renderText` " +
1330
+ `${typedToolSubject("frontend", toolName)} must declare a ` +
1331
+ "`render` or `renderText` " +
920
1332
  "(it has no server execute to show otherwise)",
921
1333
  filename,
922
1334
  );
@@ -924,19 +1336,21 @@ function compileToolkit(
924
1336
 
925
1337
  if (type === "human" && !hasRender) {
926
1338
  throw new GenerativeCompileError(
927
- "a human tool must declare a `render` so it can collect input",
1339
+ `${typedToolSubject("human", toolName)} must declare a ` +
1340
+ "`render` so it can collect input",
928
1341
  filename,
929
1342
  );
930
1343
  }
931
1344
 
932
1345
  if (type === "provider" && execute) {
933
- applyProviderToolConfig(value, execute, filename);
1346
+ applyProviderToolConfig(value, execute, toolName, filename);
934
1347
  }
935
1348
 
936
1349
  if (isExternal) {
937
1350
  if (!hasRender && !hasRenderText) {
938
1351
  throw new GenerativeCompileError(
939
- "an external tool must declare a `render` or `renderText` " +
1352
+ `${typedToolSubject("external", toolName)} must declare a ` +
1353
+ "`render` or `renderText` " +
940
1354
  "(assistant-ui only renders calls for tools defined elsewhere)",
941
1355
  filename,
942
1356
  );
@@ -972,6 +1386,7 @@ function compileToolkit(
972
1386
  function applyProviderToolConfig(
973
1387
  object: t.ObjectExpression,
974
1388
  execute: t.ObjectProperty | t.ObjectMethod,
1389
+ toolName: string | undefined,
975
1390
  filename: string | undefined,
976
1391
  ): void {
977
1392
  if (
@@ -1019,8 +1434,9 @@ function applyProviderToolConfig(
1019
1434
  );
1020
1435
  }
1021
1436
  if (existingNames.has(name) || configNames.has(name)) {
1437
+ const toolLabel = toolName ? ` for "${toolName}"` : "";
1022
1438
  throw new GenerativeCompileError(
1023
- "`providerTool(...)` config cannot duplicate tool properties",
1439
+ `\`providerTool(...)\` config${toolLabel} duplicates "${name}"`,
1024
1440
  filename,
1025
1441
  );
1026
1442
  }
@@ -1151,13 +1567,14 @@ function stripUseClient(member: t.ObjectProperty | t.ObjectMethod): void {
1151
1567
  */
1152
1568
  function inferToolType(
1153
1569
  object: t.ObjectExpression,
1570
+ toolName: string | undefined,
1154
1571
  filename: string | undefined,
1155
1572
  ): ToolType {
1156
1573
  const execute = findMember(object, "execute");
1157
1574
  if (!execute) {
1158
1575
  throw new GenerativeCompileError(
1159
- "every tool must declare an `execute`; use `humanTool()` for a " +
1160
- "human-in-the-loop tool",
1576
+ `${toolSubject(toolName)} must declare an \`execute\`; use ` +
1577
+ "`humanTool()` for a human-in-the-loop tool",
1161
1578
  filename,
1162
1579
  );
1163
1580
  }
@@ -1168,6 +1585,16 @@ function inferToolType(
1168
1585
  return executeIsClient(execute) ? "frontend" : "backend";
1169
1586
  }
1170
1587
 
1588
+ function toolSubject(toolName: string | undefined): string {
1589
+ return toolName ? `tool "${toolName}"` : "every tool";
1590
+ }
1591
+
1592
+ function typedToolSubject(type: string, toolName: string | undefined): string {
1593
+ if (toolName) return `${type} tool "${toolName}"`;
1594
+ const article = /^[aeiou]/i.test(type) ? "an" : "a";
1595
+ return `${article} ${type} tool`;
1596
+ }
1597
+
1171
1598
  function stripExternalToolMetadata(object: t.ObjectExpression): void {
1172
1599
  // Mirror BackendTool's forbidden metadata fields: execute is stripped by the
1173
1600
  // main routing loop, while streamCall is also removed because there is no