@agentuity/migrate 3.0.0-alpha.6 → 3.0.0-beta.0

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/migrate-v3.ts CHANGED
@@ -20,7 +20,14 @@
20
20
  * 8. Print final summary
21
21
  */
22
22
 
23
- import { existsSync, writeFileSync, unlinkSync, mkdirSync, readdirSync } from 'node:fs';
23
+ import {
24
+ existsSync,
25
+ writeFileSync,
26
+ unlinkSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ } from 'node:fs';
24
31
  import { join, resolve, dirname } from 'node:path';
25
32
 
26
33
  import { detectV3 } from './detect-v3';
@@ -42,10 +49,15 @@ import { generateServicesFile } from './transforms/v3/services';
42
49
  import {
43
50
  transformRouteServices,
44
51
  computeServicesRelativePath,
52
+ insertAfterImports,
45
53
  removeRuntimeImports,
54
+ rewriteV2AgentMethods,
55
+ stripAgentuityValidators,
56
+ stubV2HonoContext,
46
57
  } from './transforms/v3/routes';
47
58
  import { transformPackageJsonV3 } from './transforms/v3/package-json';
48
59
  import { generateDevSetup } from './transforms/v3/dev-setup';
60
+ import { schemaToZod } from './transforms/v3/schema-to-zod';
49
61
 
50
62
  // ---------------------------------------------------------------------------
51
63
  // Types
@@ -80,6 +92,14 @@ async function isGitWorktreeClean(projectDir: string): Promise<boolean> {
80
92
  }
81
93
  }
82
94
 
95
+ function readFileSyncSafe(path: string): string | null {
96
+ try {
97
+ return readFileSync(path, 'utf8');
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
83
103
  function isGitRepo(projectDir: string): boolean {
84
104
  try {
85
105
  const result = Bun.spawnSync(['git', 'rev-parse', '--is-inside-work-tree'], {
@@ -255,6 +275,55 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
255
275
  }
256
276
  }
257
277
 
278
+ // ── 5a′. Delete @agentuity/evals files ─────────────────────────────────
279
+ // The evals framework was removed entirely in v3. Any file that imports
280
+ // @agentuity/evals — conventionally '*eval.ts' or '*eval.tsx' alongside
281
+ // an agent — is dropped wholesale. Keeping it would produce unrecoverable
282
+ // typecheck errors because `agent.createEval()` no longer exists on the
283
+ // plain function that replaces the v2 agent.
284
+ {
285
+ const srcDir = join(projectDir, 'src');
286
+ const evalFiles: string[] = [];
287
+ const walkForEvals = (dir: string) => {
288
+ if (!existsSync(dir)) return;
289
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
290
+ const full = join(dir, entry.name);
291
+ if (entry.isDirectory()) {
292
+ if (['node_modules', 'dist', '.agentuity', '.git'].includes(entry.name)) continue;
293
+ walkForEvals(full);
294
+ } else if (
295
+ entry.isFile() &&
296
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))
297
+ ) {
298
+ const content = readFileSyncSafe(full);
299
+ if (content && content.includes("from '@agentuity/evals'")) {
300
+ evalFiles.push(full);
301
+ }
302
+ }
303
+ }
304
+ };
305
+ walkForEvals(srcDir);
306
+
307
+ for (const file of evalFiles) {
308
+ const rel = file.replace(projectDir + '/', '');
309
+ try {
310
+ unlinkSync(file);
311
+ changedFiles.push(rel);
312
+ allChangeSummary.push({
313
+ file: rel,
314
+ changes: ['Deleted — @agentuity/evals removed in v3'],
315
+ });
316
+ } catch {
317
+ // ignore
318
+ }
319
+ }
320
+
321
+ if (evalFiles.length > 0) {
322
+ printStep(`Deleted ${evalFiles.length} @agentuity/evals file(s)`);
323
+ printStepDone();
324
+ }
325
+ }
326
+
258
327
  // ── 5b. Transform agent files ─────────────────────────────────────────
259
328
  if (detection.agentFiles.length > 0) {
260
329
  console.log(`\n Transforming ${detection.agentFiles.length} agent file(s):`);
@@ -335,6 +404,7 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
335
404
  walkForRuntimeImports(srcDir);
336
405
 
337
406
  let removedCount = 0;
407
+ let anyFileNeededEnvType = false;
338
408
  for (const file of runtimeImportFiles) {
339
409
  const relPath = file.replace(projectDir + '/', '');
340
410
  // Skip files we've already modified
@@ -343,13 +413,52 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
343
413
  const src = await Bun.file(file).text();
344
414
  if (!src.includes('@agentuity/runtime')) continue;
345
415
 
346
- const { source: cleaned, removed } = removeRuntimeImports(src);
347
- if (removed) {
416
+ const cleanup = removeRuntimeImports(src);
417
+ if (cleanup.removed) {
418
+ let cleaned = cleanup.source;
419
+ const extra: string[] = [];
420
+
421
+ // If this file imported Env, add a typed import from the
422
+ // local types helper. The helper file itself is emitted
423
+ // once later in this step.
424
+ if (cleanup.needsEnvType) {
425
+ anyFileNeededEnvType = true;
426
+ cleaned = insertAfterImports(
427
+ cleaned,
428
+ "import type { Env } from '../types/hono-env';"
429
+ );
430
+ extra.push("Added: import type { Env } from '../types/hono-env'");
431
+ }
432
+
433
+ // If the file had `validator` imported (or any <agent>.validator()
434
+ // middleware style), strip those call sites too — v3 has no
435
+ // equivalent.
436
+ const stripped = stripAgentuityValidators(cleaned);
437
+ if (stripped.changed) {
438
+ cleaned = stripped.source;
439
+ extra.push(...stripped.changes);
440
+ }
441
+
442
+ // Rewrite v2 agent method invocations (<agent>.run → <agent>,
443
+ // c.req.valid('json') → await c.req.json()).
444
+ const agentRewrite = rewriteV2AgentMethods(cleaned);
445
+ if (agentRewrite.changed) {
446
+ cleaned = agentRewrite.source;
447
+ extra.push(...agentRewrite.changes);
448
+ }
449
+
450
+ // Stub v2 Hono context (c.var.thread, c.var.sessionId).
451
+ const stub = stubV2HonoContext(cleaned);
452
+ if (stub.changed) {
453
+ cleaned = stub.source;
454
+ extra.push(...stub.changes);
455
+ }
456
+
348
457
  writeFileSync(file, cleaned, 'utf8');
349
458
  changedFiles.push(relPath);
350
459
  allChangeSummary.push({
351
460
  file: relPath,
352
- changes: ['Removed @agentuity/runtime imports'],
461
+ changes: ['Removed @agentuity/runtime imports', ...extra],
353
462
  });
354
463
  removedCount++;
355
464
  }
@@ -359,6 +468,48 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
359
468
  printStep(`Removed @agentuity/runtime imports from ${removedCount} additional file(s)`);
360
469
  printStepDone();
361
470
  }
471
+ void anyFileNeededEnvType; // subsumed by post-scan below
472
+ }
473
+ }
474
+
475
+ // ── 5c″. Emit src/types/hono-env.ts if any changed file references it ───
476
+ // Both the route service rewrite (5c) and the runtime import cleanup (5c′)
477
+ // can emit `import type { Env } from '../types/hono-env'`. We create the
478
+ // helper file once here by scanning the final content of changed files.
479
+ {
480
+ let helperNeeded = false;
481
+ for (const rel of changedFiles) {
482
+ try {
483
+ const content = readFileSyncSafe(join(projectDir, rel));
484
+ if (content && /from ['"]\.\.\/types\/hono-env['"]/.test(content)) {
485
+ helperNeeded = true;
486
+ break;
487
+ }
488
+ } catch {
489
+ // ignore
490
+ }
491
+ }
492
+
493
+ if (helperNeeded) {
494
+ const helperPath = join(projectDir, 'src', 'types', 'hono-env.ts');
495
+ if (!existsSync(helperPath)) {
496
+ mkdirSync(dirname(helperPath), { recursive: true });
497
+ const body =
498
+ '/**\n' +
499
+ ' * Hono context variable type for Agentuity services.\n' +
500
+ ' *\n' +
501
+ ' * Generated by @agentuity/migrate during the v2 → v3 migration as the\n' +
502
+ " * replacement for `import type { Env } from '@agentuity/runtime'`.\n" +
503
+ ' */\n' +
504
+ "import type { Services } from '@agentuity/hono';\n\n" +
505
+ 'export type Env = { Variables: Services };\n';
506
+ writeFileSync(helperPath, body, 'utf8');
507
+ changedFiles.push('src/types/hono-env.ts');
508
+ allChangeSummary.push({
509
+ file: 'src/types/hono-env.ts',
510
+ changes: ['Created — replaces `Env` type from @agentuity/runtime'],
511
+ });
512
+ }
362
513
  }
363
514
  }
364
515
 
@@ -451,6 +602,58 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
451
602
  }
452
603
  }
453
604
 
605
+ // ── 5g′. Port @agentuity/schema usage to zod ─────────────────────────
606
+ // Walk all .ts/.tsx files under src/ one more time and rewrite
607
+ // `import { s } from '@agentuity/schema'` + `s.*` calls to the zod
608
+ // equivalents. Track whether the rewrite actually fired anywhere — if
609
+ // it did, we'll add zod (and drop @agentuity/schema) in the package
610
+ // update step below.
611
+ let anyFilePortedToZod = false;
612
+ {
613
+ const srcDir = join(projectDir, 'src');
614
+ const walkAllTs = (dir: string): string[] => {
615
+ if (!existsSync(dir)) return [];
616
+ const out: string[] = [];
617
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
618
+ const full = join(dir, entry.name);
619
+ if (entry.isDirectory()) {
620
+ if (['node_modules', 'dist', '.agentuity', '.git'].includes(entry.name)) continue;
621
+ out.push(...walkAllTs(full));
622
+ } else if (
623
+ entry.isFile() &&
624
+ (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx'))
625
+ ) {
626
+ out.push(full);
627
+ }
628
+ }
629
+ return out;
630
+ };
631
+
632
+ for (const file of walkAllTs(srcDir)) {
633
+ const rel = file.replace(projectDir + '/', '');
634
+ const src = readFileSyncSafe(file);
635
+ if (!src) continue;
636
+ const ported = schemaToZod(src);
637
+ if (ported.changed) {
638
+ writeFileSync(file, ported.source, 'utf8');
639
+ anyFilePortedToZod = true;
640
+ if (!changedFiles.includes(rel)) {
641
+ changedFiles.push(rel);
642
+ allChangeSummary.push({ file: rel, changes: ported.changes });
643
+ } else {
644
+ // Merge into the existing entry so we don't lose earlier changes.
645
+ const entry = allChangeSummary.find((c) => c.file === rel);
646
+ if (entry) entry.changes.push(...ported.changes);
647
+ }
648
+ }
649
+ }
650
+
651
+ if (anyFilePortedToZod) {
652
+ printStep('Ported @agentuity/schema usage to zod');
653
+ printStepDone();
654
+ }
655
+ }
656
+
454
657
  // ── 5h. Update package.json ───────────────────────────────────────────
455
658
  const packageJsonPath = join(projectDir, 'package.json');
456
659
  if (existsSync(packageJsonPath)) {
@@ -465,6 +668,7 @@ export async function migrateV3(opts: MigrateV3Options = {}): Promise<MigrateV3R
465
668
  {
466
669
  removeRuntime: detection.hasRuntimeDep,
467
670
  removeReact: detection.hasReactPackage,
671
+ addZod: anyFilePortedToZod,
468
672
  devScripts,
469
673
  }
470
674
  );
@@ -1,14 +1,21 @@
1
1
  /**
2
2
  * Transform: createAgent() → plain exported functions
3
3
  *
4
+ * v3 is an "eject" — the createAgent() wrapper, ctx.* service magic, thread
5
+ * state, sessions and evals are all replaced by user-visible primitives.
6
+ *
4
7
  * For "simple" agents (handler + optional schema only), we:
5
8
  * 1. Remove the createAgent() wrapper
6
9
  * 2. Extract the handler as a named exported async function
7
10
  * 3. Preserve schema validation if present
8
- * 4. Replace ctx.* service access with imports from services module
11
+ * 4. Replace ctx.* service access with imports from the services module
9
12
  *
10
- * For "complex" agents, we add a prominent migration comment and leave
11
- * the code untouched for manual review.
13
+ * For "complex" agents (setup/shutdown/onEvent/ctx.config), we additionally:
14
+ * 5. Hoist setup() return values into module-level lazy-init singletons
15
+ * so the handler body still compiles after the ctx.config.* → direct
16
+ * reference rewrite.
17
+ * 6. Replace ctx.thread.*, ctx.sessionId, ctx.app.* with TODO comments —
18
+ * these concepts are gone in v3.
12
19
  *
13
20
  * We use a combination of regex and AST analysis — regex for the mechanical
14
21
  * transforms (preserving formatting), AST for detection (already done in detect-v3).
@@ -45,7 +52,7 @@ export function transformAgentFile(
45
52
  servicesRelativePath: string
46
53
  ): AgentTransformResult {
47
54
  if (agentInfo.complexity === 'complex') {
48
- return addManualMigrationComment(source, agentInfo);
55
+ return forceConvertComplexAgent(source, agentInfo, servicesRelativePath);
49
56
  }
50
57
 
51
58
  const changes: string[] = [];
@@ -117,7 +124,270 @@ export function transformAgentFile(
117
124
  }
118
125
 
119
126
  /**
120
- * For complex agents, add a migration comment block.
127
+ * Force-convert a "complex" agent (setup/shutdown/onEvent/ctx.config) to
128
+ * a plain exported async function plus module-level lazy-init singletons.
129
+ *
130
+ * v3 is an eject — we prefer working-but-TODO'd code over compilation errors
131
+ * that block the whole migration. Every hoisted singleton and every dropped
132
+ * feature is annotated so the user can review.
133
+ */
134
+ function forceConvertComplexAgent(
135
+ source: string,
136
+ agentInfo: AgentFile,
137
+ servicesRelativePath: string
138
+ ): AgentTransformResult {
139
+ const changes: string[] = [];
140
+ const sourceFile = ts.createSourceFile(agentInfo.path, source, ts.ScriptTarget.ESNext, true);
141
+
142
+ const extracted = extractHandlerFromCreateAgent(sourceFile, source);
143
+ if (!extracted) {
144
+ // Fall back to the comment-only path so the file at least still parses.
145
+ return addManualMigrationComment(source, agentInfo);
146
+ }
147
+
148
+ const setupInfo = extractSetupFromCreateAgent(sourceFile);
149
+
150
+ let output = source;
151
+
152
+ // Step 1: Remove createAgent import
153
+ output = removeCreateAgentImport(output);
154
+ changes.push('Removed createAgent import from @agentuity/runtime');
155
+
156
+ // Step 2: Add services import if needed
157
+ if (agentInfo.ctxServices.length > 0) {
158
+ const importLine = `import { ${agentInfo.ctxServices.join(', ')} } from '${servicesRelativePath}';`;
159
+ output = insertAfterImports(output, importLine);
160
+ changes.push(`Added services import: ${agentInfo.ctxServices.join(', ')}`);
161
+ }
162
+
163
+ // Step 3: Rewrite handler body to remove ctx.* magic.
164
+ let handlerBody = extracted.handlerBody;
165
+
166
+ // 3a. ctx.config.<name> → (await get_<name>()) — wired to the hoisted
167
+ // module-level lazy init emitted in step 5 below. We wrap with parens so
168
+ // subsequent `.foo.bar` chains still parse correctly.
169
+ const configRefs = new Set<string>();
170
+ for (const ctxName of CTX_PARAM_NAMES) {
171
+ const pattern = new RegExp(`\\b${ctxName}\\.config\\.([A-Za-z_$][A-Za-z0-9_$]*)`, 'g');
172
+ handlerBody = handlerBody.replace(pattern, (_m, id: string) => {
173
+ configRefs.add(id);
174
+ return `(await get_${id}())`;
175
+ });
176
+ }
177
+ if (configRefs.size > 0) {
178
+ changes.push(`Rewrote ctx.config.{${[...configRefs].join(', ')}} → (await get_‹key›())`);
179
+ }
180
+
181
+ // 3b. ctx.logger → logger (expected to come from services barrel).
182
+ for (const ctxName of CTX_PARAM_NAMES) {
183
+ handlerBody = handlerBody.replace(new RegExp(`\\b${ctxName}\\.logger\\b`, 'g'), 'logger');
184
+ }
185
+
186
+ // 3c. Replace ctx.<service> → <service> for all detected services.
187
+ for (const service of agentInfo.ctxServices) {
188
+ for (const ctxName of CTX_PARAM_NAMES) {
189
+ const pattern = new RegExp(`\\b${ctxName}\\.${service}\\b`, 'g');
190
+ handlerBody = handlerBody.replace(pattern, service);
191
+ }
192
+ }
193
+
194
+ // 3d. Replace ctx.thread / ctx.sessionId / ctx.app with obvious stubs.
195
+ // These concepts have no v3 equivalent. We rewrite whole ctx.<x>.*
196
+ // expression chains by scanning the handler AST so we don't mangle
197
+ // surrounding punctuation the way a pure regex would.
198
+ const droppedAccessors = new Set<string>();
199
+ const handlerSf = ts.createSourceFile('handler.ts', handlerBody, ts.ScriptTarget.ESNext, true);
200
+ const edits: Array<{ start: number; end: number; replacement: string }> = [];
201
+
202
+ // Walk expressions that START with a ctx-named identifier and whose full
203
+ // chain matches our pattern. We accumulate all unique full-chain ranges
204
+ // and, later, keep only the outermost chain at each location.
205
+ function visitExpr(node: ts.Node): void {
206
+ if (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) {
207
+ // Find the leftmost identifier by walking .expression left.
208
+ let cur: ts.Node = node;
209
+ while (true) {
210
+ if (ts.isPropertyAccessExpression(cur)) {
211
+ cur = cur.expression;
212
+ } else if (ts.isCallExpression(cur)) {
213
+ cur = cur.expression;
214
+ } else if (ts.isElementAccessExpression(cur)) {
215
+ cur = cur.expression;
216
+ } else {
217
+ break;
218
+ }
219
+ }
220
+ if (ts.isIdentifier(cur) && CTX_PARAM_NAMES.includes(cur.text)) {
221
+ // Need to check the first property access is one of our removed keys
222
+ // by re-walking from the leaf.
223
+ const parent: ts.Node | undefined = cur.parent;
224
+ let firstProp: string | undefined;
225
+ if (parent && ts.isPropertyAccessExpression(parent) && parent.expression === cur) {
226
+ firstProp = parent.name.text;
227
+ }
228
+ if (firstProp && (firstProp === 'thread' || firstProp === 'app')) {
229
+ edits.push({
230
+ start: node.getStart(handlerSf),
231
+ end: node.getEnd(),
232
+ replacement: `(undefined /* v3: ${cur.text}.${firstProp} removed */ as any)`,
233
+ });
234
+ droppedAccessors.add(`${cur.text}.${firstProp}`);
235
+ } else if (firstProp === 'sessionId') {
236
+ edits.push({
237
+ start: node.getStart(handlerSf),
238
+ end: node.getEnd(),
239
+ replacement: "('v3-no-session-id' /* v3: ctx.sessionId removed */)",
240
+ });
241
+ droppedAccessors.add(`${cur.text}.sessionId`);
242
+ }
243
+ }
244
+ }
245
+ ts.forEachChild(node, visitExpr);
246
+ }
247
+ ts.forEachChild(handlerSf, visitExpr);
248
+
249
+ // Keep only the **outermost** ctx.x.y.z chain per location. The AST visitor
250
+ // emits overlapping edits for every nested property access, so sort by
251
+ // length descending and drop anything contained within a larger accepted
252
+ // edit.
253
+ if (edits.length > 0) {
254
+ edits.sort((a, b) => b.end - b.start - (a.end - a.start) || a.start - b.start);
255
+ const kept: Array<{ start: number; end: number; replacement: string }> = [];
256
+ for (const e of edits) {
257
+ const overlaps = kept.some((k) => !(e.end <= k.start || e.start >= k.end));
258
+ if (!overlaps) kept.push(e);
259
+ }
260
+ // Apply right-to-left so earlier offsets stay valid.
261
+ kept.sort((a, b) => b.start - a.start);
262
+ let body = handlerBody;
263
+ for (const e of kept) {
264
+ body = body.slice(0, e.start) + e.replacement + body.slice(e.end);
265
+ }
266
+ handlerBody = body;
267
+ }
268
+ if (droppedAccessors.size > 0) {
269
+ changes.push(`Stubbed out ctx accessors removed in v3: ${[...droppedAccessors].join(', ')}`);
270
+ }
271
+
272
+ // Update extracted payload with rewritten body.
273
+ const rewrittenExtracted = { ...extracted, handlerBody };
274
+
275
+ // Step 4: Replace the createAgent() statement with the plain function.
276
+ output = replaceCreateAgentWithFunction(output, agentInfo, rewrittenExtracted);
277
+ changes.push(`Converted complex agent "${agentInfo.name}" to plain exported async function`);
278
+
279
+ // Step 5: Hoist setup() body to module-level singletons.
280
+ if (setupInfo && configRefs.size > 0) {
281
+ const singletonBlock = buildLazyInitBlock(configRefs, setupInfo);
282
+ output = insertAfterImports(output, singletonBlock);
283
+ changes.push(
284
+ `Hoisted setup() return values to module-level lazy init: ${[...configRefs].join(', ')}`
285
+ );
286
+ }
287
+
288
+ // Step 6: Prepend a review banner so the user knows this was auto-ejected.
289
+ const banner =
290
+ '// ℹ️ v3 migration — complex agent auto-ejected\n' +
291
+ `// Reason: ${agentInfo.complexityReason ?? 'unknown'}\n` +
292
+ '// Review the hoisted singletons, TODO comments, and the stubbed-out\n' +
293
+ '// thread/session accessors — these v2 features have no v3 equivalent.\n';
294
+ output = banner + output;
295
+
296
+ return { source: output, changes, manualRequired: true };
297
+ }
298
+
299
+ /**
300
+ * Build a module-level lazy init block from the setup() body.
301
+ *
302
+ * Output shape (singleton pattern):
303
+ *
304
+ * let _client: ReturnType<typeof __buildSetup>['client'] | undefined;
305
+ * function getClient() {
306
+ * return _client ?? (_client = (() => { ... setup body returns .client ... })());
307
+ * }
308
+ *
309
+ * For simplicity and to avoid having to type-reason about return shape, we
310
+ * emit a single IIFE that runs setup() on first access and caches per-key.
311
+ */
312
+ function buildLazyInitBlock(keys: Set<string>, setup: { setupBodyText: string }): string {
313
+ const keyList = [...keys];
314
+ const lines: string[] = [];
315
+ lines.push(
316
+ '\n// v3: lazy-init singletons hoisted from the former setup() hook.\n' +
317
+ '// Each key is computed lazily on first access so we keep the original\n' +
318
+ '// semantics (constructors run at handler-time, not at module load).'
319
+ );
320
+ lines.push(`let __setupResult: Record<string, unknown> | undefined;`);
321
+ lines.push(`async function __runSetup(): Promise<Record<string, unknown>> {`);
322
+ lines.push(`\tif (__setupResult) return __setupResult;`);
323
+ lines.push(
324
+ `\t// Original setup() body — adjust by hand if it referenced ctx:\n\tconst __result = await (async () => ${setup.setupBodyText})();`
325
+ );
326
+ lines.push(`\t__setupResult = __result as Record<string, unknown>;`);
327
+ lines.push(`\treturn __setupResult;`);
328
+ lines.push(`}`);
329
+ for (const key of keyList) {
330
+ lines.push(
331
+ `async function get_${key}() { return (await __runSetup())[${JSON.stringify(key)}] as any; }`
332
+ );
333
+ // We also expose a sync alias for cases where the original code read ctx.config.X
334
+ // synchronously — the user will need to await. We emit a `const X = await get_X()` stub
335
+ // near the start of the handler via the handler-body rewriter later on.
336
+ }
337
+ return lines.join('\n') + '\n';
338
+ }
339
+
340
+ /**
341
+ * Extract the body of the setup() property/method on the createAgent() config.
342
+ */
343
+ function extractSetupFromCreateAgent(sourceFile: ts.SourceFile): { setupBodyText: string } | null {
344
+ let result: { setupBodyText: string } | null = null;
345
+
346
+ function visit(node: ts.Node) {
347
+ if (result) return;
348
+
349
+ if (
350
+ ts.isCallExpression(node) &&
351
+ ts.isIdentifier(node.expression) &&
352
+ node.expression.text === 'createAgent'
353
+ ) {
354
+ const configArg = node.arguments[1];
355
+ if (!configArg || !ts.isObjectLiteralExpression(configArg)) return;
356
+
357
+ for (const prop of configArg.properties) {
358
+ const name =
359
+ (ts.isPropertyAssignment(prop) || ts.isMethodDeclaration(prop)) &&
360
+ ts.isIdentifier(prop.name)
361
+ ? prop.name.text
362
+ : undefined;
363
+ if (name !== 'setup') continue;
364
+
365
+ let body: ts.Block | ts.Expression | undefined;
366
+ if (ts.isPropertyAssignment(prop)) {
367
+ const init = prop.initializer;
368
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
369
+ body = init.body;
370
+ }
371
+ } else if (ts.isMethodDeclaration(prop)) {
372
+ body = prop.body;
373
+ }
374
+ if (!body) continue;
375
+
376
+ const bodyText = body.getText(sourceFile);
377
+ result = { setupBodyText: bodyText };
378
+ }
379
+ }
380
+
381
+ ts.forEachChild(node, visit);
382
+ }
383
+
384
+ visit(sourceFile);
385
+ return result;
386
+ }
387
+
388
+ /**
389
+ * For complex agents we cannot auto-migrate, add a migration comment block
390
+ * only (fallback if the handler couldn't be extracted).
121
391
  */
122
392
  function addManualMigrationComment(source: string, agentInfo: AgentFile): AgentTransformResult {
123
393
  const comment =
@@ -214,7 +484,12 @@ function extractHandlerFromCreateAgent(
214
484
 
215
485
  if (!body) return;
216
486
 
217
- // Get parameter text (skip ctx/context parameter, keep input)
487
+ // Get parameter text (skip ctx/context parameter, keep input).
488
+ // We also ensure every surviving param has a type annotation — the v2
489
+ // scaffold relied on createAgent<Schema>() to type the handler's input,
490
+ // which is gone in v3. Rather than lose type information, we fall back
491
+ // to `: unknown` + a TODO so the resulting plain function compiles
492
+ // under strict mode. Users can tighten the type by parsing with zod.
218
493
  const paramTexts: string[] = [];
219
494
  if (params) {
220
495
  for (let i = 0; i < params.length; i++) {
@@ -223,7 +498,14 @@ function extractHandlerFromCreateAgent(
223
498
  const paramName = param.name.getText(sourceFile);
224
499
  // Skip the first param if it's ctx/context (agent context)
225
500
  if (i === 0 && CTX_PARAM_NAMES.includes(paramName)) continue;
226
- paramTexts.push(param.getText(sourceFile));
501
+ let paramText = param.getText(sourceFile);
502
+ // If the param has no type annotation (v2 scaffold relied on
503
+ // createAgent generics), inject `: any` so the plain function
504
+ // still compiles.
505
+ if (!param.type) {
506
+ paramText += ': any';
507
+ }
508
+ paramTexts.push(paramText);
227
509
  }
228
510
  }
229
511
 
@@ -10,6 +10,22 @@
10
10
 
11
11
  import { SERVICE_PACKAGE_MAP, type V3OutdatedPackage } from '../../detect-v3';
12
12
 
13
+ /**
14
+ * Packages that existed in v2 but are removed in v3.
15
+ *
16
+ * v3 is a deliberate "eject" — agent framework magic (evals, workbench) and
17
+ * helper facades (frontend, react) are replaced by user-visible primitives.
18
+ * These packages have no v3 counterpart and must be deleted from package.json,
19
+ * not bumped to a non-existent ^3.0.0.
20
+ *
21
+ * Note: @agentuity/react is handled separately via options.removeReact.
22
+ */
23
+ const PACKAGES_REMOVED_IN_V3 = [
24
+ '@agentuity/evals',
25
+ '@agentuity/frontend',
26
+ '@agentuity/workbench',
27
+ ] as const;
28
+
13
29
  export interface V3PackageJsonResult {
14
30
  /** Transformed package.json content, or null if no changes */
15
31
  content: string | null;
@@ -29,6 +45,8 @@ export function transformPackageJsonV3(
29
45
  removeRuntime?: boolean;
30
46
  /** Whether to remove @agentuity/react */
31
47
  removeReact?: boolean;
48
+ /** Whether any source file was ported from @agentuity/schema to zod */
49
+ addZod?: boolean;
32
50
  /** Dev scripts to add (from dev-setup transform) */
33
51
  devScripts?: Record<string, string>;
34
52
  }
@@ -87,8 +105,24 @@ export function transformPackageJsonV3(
87
105
  }
88
106
  }
89
107
 
90
- // ── 5. Bump existing @agentuity/* packages to ^3.0.0 ──────────────────
108
+ // ── 5a. Remove v2-only packages that have no v3 counterpart ────────────
109
+ // (These would otherwise be bumped to ^3.0.0 in step 5b, which fails to
110
+ // resolve because the packages were deleted entirely in v3.)
111
+ for (const removed of PACKAGES_REMOVED_IN_V3) {
112
+ if (deps[removed]) {
113
+ delete deps[removed];
114
+ changes.push(`Removed ${removed} from dependencies (no longer exists in v3)`);
115
+ }
116
+ if (devDeps[removed]) {
117
+ delete devDeps[removed];
118
+ changes.push(`Removed ${removed} from devDependencies (no longer exists in v3)`);
119
+ }
120
+ }
121
+
122
+ // ── 5b. Bump existing @agentuity/* packages to ^3.0.0 ─────────────────
123
+ // Skip packages we just removed — they'd otherwise come back.
91
124
  for (const outdated of outdatedPackages) {
125
+ if ((PACKAGES_REMOVED_IN_V3 as readonly string[]).includes(outdated.name)) continue;
92
126
  const section = outdated.section === 'dependencies' ? deps : devDeps;
93
127
  if (section[outdated.name]) {
94
128
  section[outdated.name] = '^3.0.0';
@@ -96,7 +130,23 @@ export function transformPackageJsonV3(
96
130
  }
97
131
  }
98
132
 
99
- // ── 6. Remove @agentuity/react if requested ───────────────────────────
133
+ // ── 6. Add zod + remove @agentuity/schema when the schema→zod port fired ──
134
+ if (options?.addZod) {
135
+ if (!deps['zod']) {
136
+ deps['zod'] = '^4.0.0';
137
+ changes.push('Added zod@^4.0.0 to dependencies');
138
+ }
139
+ if (deps['@agentuity/schema']) {
140
+ delete deps['@agentuity/schema'];
141
+ changes.push('Removed @agentuity/schema (usage ported to zod)');
142
+ }
143
+ if (devDeps['@agentuity/schema']) {
144
+ delete devDeps['@agentuity/schema'];
145
+ changes.push('Removed @agentuity/schema from devDependencies (usage ported to zod)');
146
+ }
147
+ }
148
+
149
+ // ── 7. Remove @agentuity/react if requested ───────────────────────────
100
150
  if (options?.removeReact) {
101
151
  if (deps['@agentuity/react']) {
102
152
  delete deps['@agentuity/react'];