@app-vitals/agent-admin 0.14.0 → 0.16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-vitals/agent-admin",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/app-vitals/agent.git",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anthropic-ai/sdk": "0.135.0",
46
- "@app-vitals/agent-lib": "0.14.0",
46
+ "@app-vitals/agent-lib": "0.16.0",
47
47
  "@hono/zod-openapi": "^1.0.0",
48
48
  "@prisma/adapter-pg": "^7.10.0",
49
49
  "@prisma/client": "^7.10.0",
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Shared rules for self-serve agent creation by account users (SSP-4.2),
5
5
  * used by both the admin UI form handler and the JSON API route so the two
6
- * cannot drift: account users always create "coding" agents stamped with
7
- * THEIR account (never a client-supplied one), and quota / account-status
8
- * failures surface as clear messages.
6
+ * cannot drift: account users always create the self-serve specialty (see
7
+ * getSelfServeSpecialty) stamped with THEIR account (never a client-supplied
8
+ * one), and quota / account-status failures surface as clear messages.
9
9
  */
10
10
 
11
11
  import type { CallerScope } from "./caller-scope.ts";
package/src/admin-boot.ts CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  type AdminExtension,
66
66
  type AdminExtensionContext,
67
67
  collectServiceIntegrations,
68
+ collectSpecialtyDefaults,
68
69
  mountRouteContributions,
69
70
  resolveAdminExtensions,
70
71
  startBackgroundPasses,
@@ -178,7 +179,15 @@ export async function startAdmin(
178
179
  undefined,
179
180
  anthropicFederationEnabled,
180
181
  );
181
- const agentCronJobService = new AgentCronJobService(prisma);
182
+ // Extension-supplied specialty defaults (crons/tools/plugins), consulted by
183
+ // the cron reconcile and create-time seeding before the manifest.
184
+ const specialtyDefaults = collectSpecialtyDefaults(extensions);
185
+ const agentCronJobService = new AgentCronJobService(
186
+ prisma,
187
+ undefined,
188
+ undefined,
189
+ specialtyDefaults,
190
+ );
182
191
  const agentCronRunService = new AgentCronRunService(prisma);
183
192
  const agentToolService = new AgentToolService(prisma);
184
193
  const agentTokenService = new AgentTokenService(prisma);
@@ -450,6 +459,7 @@ export async function startAdmin(
450
459
  agentMemberService,
451
460
  callerScopeResolver,
452
461
  specialtyRegistry: new SpecialtyRegistry(),
462
+ specialtyDefaults,
453
463
  agentChatTokenService,
454
464
  agentGitHubInstallationsService,
455
465
  prisma,
@@ -541,6 +551,7 @@ export async function startAdmin(
541
551
  agentService,
542
552
  provisioner,
543
553
  anthropicFederationEnabled,
554
+ specialtyDefaults,
544
555
  serviceIntegrations,
545
556
  chatService: deletionChatService,
546
557
  slack: slackClient,
@@ -24,6 +24,12 @@
24
24
  * agentDetailCards(ctx) — extra cards on the agent detail page, each placed
25
25
  * in the header or the Configuration group
26
26
  * backgroundPasses(ctx) — interval loops, started after the platform sweepers
27
+ * specialtyDefaults(name) — a specialty's default crons/tools/plugins. Like
28
+ * serviceIntegrations it needs no context and is
29
+ * collected before the services are built; the cron
30
+ * reconcile and create-time seeding consult it
31
+ * before the specialty manifest, which remains the
32
+ * fallback when no extension returns anything
27
33
  *
28
34
  * openApi() is static (no context): scripts/generate-admin-spec.ts calls it to
29
35
  * fold extension routes/schemas into admin/openapi.json without booting.
@@ -52,6 +58,10 @@ import type { AgentPhaseMethodologyServiceLike } from "./api.ts";
52
58
  import type { createCallerScopeResolver } from "./caller-scope.ts";
53
59
  import type { PushService } from "./push-service.ts";
54
60
  import type { ServiceIntegration } from "./service-integration.ts";
61
+ import type {
62
+ SpecialtyDefaults,
63
+ SpecialtyDefaultsResolver,
64
+ } from "./specialty-defaults.ts";
55
65
 
56
66
  /** The platform services an extension may build on. */
57
67
  export interface AdminPlatformServices {
@@ -168,6 +178,8 @@ export interface AdminExtension {
168
178
  name?: string;
169
179
  /** See the header: env-only, resolved before the provisioner is built. */
170
180
  serviceIntegrations?(env: NodeJS.ProcessEnv): ServiceIntegration[];
181
+ /** See the header: undefined means "no opinion" — the manifest applies. */
182
+ specialtyDefaults?(specialty: string): SpecialtyDefaults | undefined;
171
183
  services?(ctx: AdminExtensionContext): AdminExtensionServices;
172
184
  apiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
173
185
  adminUiRoutes?(ctx: AdminExtensionContext): AdminRouteContribution[];
@@ -344,6 +356,23 @@ export function collectServiceIntegrations(
344
356
  return extensions.flatMap((ext) => ext.serviceIntegrations?.(env) ?? []);
345
357
  }
346
358
 
359
+ /**
360
+ * Builds the resolver the cron reconcile and create-time seeding consult
361
+ * before the manifest. The first extension returning defaults wins; with none
362
+ * registered (or none answering) it returns undefined and the manifest applies.
363
+ */
364
+ export function collectSpecialtyDefaults(
365
+ extensions: readonly AdminExtension[],
366
+ ): SpecialtyDefaultsResolver {
367
+ return (specialty) => {
368
+ for (const ext of extensions) {
369
+ const defaults = ext.specialtyDefaults?.(specialty);
370
+ if (defaults) return defaults;
371
+ }
372
+ return undefined;
373
+ };
374
+ }
375
+
347
376
  /** Mounts each contribution on root, in order. */
348
377
  export function mountRouteContributions(
349
378
  root: AnyHono,
@@ -9,6 +9,7 @@ import {
9
9
  applyOpenApiContributions,
10
10
  collectOpenApiContributions,
11
11
  collectServiceIntegrations,
12
+ collectSpecialtyDefaults,
12
13
  mountRouteContributions,
13
14
  renderAgentDetailCardSlots,
14
15
  renderAgentDetailCards,
@@ -530,3 +531,21 @@ describe("renderAgentDetailCardSlots", () => {
530
531
  expect(rendered).toBe(1);
531
532
  });
532
533
  });
534
+
535
+ describe("collectSpecialtyDefaults", () => {
536
+ it("returns undefined with no extensions or hookless extensions", () => {
537
+ expect(collectSpecialtyDefaults([])("coding")).toBeUndefined();
538
+ expect(
539
+ collectSpecialtyDefaults([{ name: "bare" }])("coding"),
540
+ ).toBeUndefined();
541
+ });
542
+
543
+ it("returns the first extension's defaults that answers for the specialty", () => {
544
+ const resolve = collectSpecialtyDefaults([
545
+ { specialtyDefaults: (s) => (s === "a" ? { tools: ["A"] } : undefined) },
546
+ { specialtyDefaults: () => ({ tools: ["B"] }) },
547
+ ]);
548
+ expect(resolve("a")).toEqual({ tools: ["A"] });
549
+ expect(resolve("b")).toEqual({ tools: ["B"] });
550
+ });
551
+ });
@@ -1523,7 +1523,7 @@ describe("admin UI — account users create / delete agents (SSP-4.2)", () => {
1523
1523
  expect(res.status).toBe(403);
1524
1524
  });
1525
1525
 
1526
- it("POST /admin/agents — account user's agent is stamped with their account and forced to coding", async () => {
1526
+ it("POST /admin/agents — account user's agent is stamped with their account and forced to the self-serve specialty", async () => {
1527
1527
  const { deps, created } = depsCapturingCreate("acct-a");
1528
1528
  const res = await createAdminUIApp(deps).request(
1529
1529
  "/admin/agents",
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { PROGRESS_LABELS } from "@app-vitals/agent-lib/progress-phases";
14
+ import { getSelfServeSpecialty } from "./account-agent-create.ts";
14
15
  import { renderAdminPage } from "./admin-ui-layout.ts";
15
16
  import {
16
17
  BREAKPOINT_MOBILE_MAX,
@@ -18,7 +19,6 @@ import {
18
19
  renderAdminToolbar,
19
20
  } from "./admin-ui-styles.ts";
20
21
  import type { ManualStep } from "./agent-deletion-checklist.ts";
21
- import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
22
22
  import { isAudioFilename } from "./attachment-validation.ts";
23
23
  import { parseChatMarkers } from "./chat-markers.ts";
24
24
  import type {
@@ -28,6 +28,7 @@ import type {
28
28
  ThreadStats,
29
29
  } from "./http-chat-client.ts";
30
30
  import { renderPushToggle } from "./push-toggle.ts";
31
+ import type { SpecialtyOption } from "./specialty-manifest-loader.ts";
31
32
 
32
33
  // ─── Helpers ──────────────────────────────────────────────────────────────────
33
34
 
@@ -566,7 +567,7 @@ export function renderNewLocalAgentPage(
566
567
  </div>`
567
568
  : "";
568
569
  const typeFieldHtml = selfServe
569
- ? `<input type="hidden" name="type" value="coding" />`
570
+ ? `<input type="hidden" name="type" value="${escapeHtml(getSelfServeSpecialty())}" />`
570
571
  : `<div class="form-group">
571
572
  <label class="form-label" for="type">Specialty <span style="color:#ef4444">*</span></label>
572
573
  <select id="type" name="type" class="form-input" required>
@@ -6,7 +6,7 @@
6
6
  * No I/O, no Hono, no HTTP — pure string → string.
7
7
  */
8
8
 
9
- import { describe, expect, test } from "bun:test";
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
10
  import { renderZeroQuotaNotice } from "./admin-ui-account-pages.ts";
11
11
  import {
12
12
  type AgentDetail,
@@ -4259,12 +4259,38 @@ describe("renderNewLocalAgentPage — self-serve account user (SSP-4.2)", () =>
4259
4259
  expect(html).toContain("Claude API key");
4260
4260
  });
4261
4261
 
4262
- test("forces the coding type via a hidden input instead of a type select", () => {
4263
- const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4264
- selfServe,
4262
+ describe("hidden type input", () => {
4263
+ const saved = {
4264
+ selfServe: process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE,
4265
+ defaultType: process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE,
4266
+ };
4267
+ const restore = (key: string, value: string | undefined) => {
4268
+ if (value === undefined) delete process.env[key];
4269
+ else process.env[key] = value;
4270
+ };
4271
+ afterEach(() => {
4272
+ restore("SHIPWRIGHT_SELF_SERVE_AGENT_TYPE", saved.selfServe);
4273
+ restore("SHIPWRIGHT_DEFAULT_AGENT_TYPE", saved.defaultType);
4274
+ });
4275
+
4276
+ test("carries the SHIPWRIGHT_SELF_SERVE_AGENT_TYPE value instead of a type select", () => {
4277
+ process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE = "base";
4278
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4279
+ selfServe,
4280
+ });
4281
+ expect(html).toContain('<input type="hidden" name="type" value="base"');
4282
+ expect(html).not.toContain('name="type" value="coding"');
4283
+ expect(html).not.toMatch(/<select[^>]*name="type"/);
4284
+ });
4285
+
4286
+ test("falls back to the resolved default specialty when the env is unset", () => {
4287
+ delete process.env.SHIPWRIGHT_SELF_SERVE_AGENT_TYPE;
4288
+ delete process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE;
4289
+ const html = renderNewLocalAgentPage(USER_NAME, [CODING_TYPE], {
4290
+ selfServe,
4291
+ });
4292
+ expect(html).toContain('<input type="hidden" name="type" value="coding"');
4265
4293
  });
4266
- expect(html).toContain('<input type="hidden" name="type" value="coding"');
4267
- expect(html).not.toMatch(/<select[^>]*name="type"/);
4268
4294
  });
4269
4295
 
4270
4296
  test("at zero remaining, explains the limit and points at the contact email", () => {
package/src/admin-ui.ts CHANGED
@@ -78,6 +78,7 @@ import {
78
78
  type SpecialtyManifestResolver,
79
79
  SpecialtyRegistry,
80
80
  } from "./specialty-manifest-loader.ts";
81
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
81
82
  import type { AgentService } from "./agents.ts";
82
83
  import { createAgent, describeRepoNameCollision } from "./agents.ts";
83
84
  import { AnthropicCredentialsBlockedError } from "./anthropic-credential-guard.ts";
@@ -352,6 +353,8 @@ export interface AdminUIDeps {
352
353
  * already used by agents-api.ts's AdminDeps.
353
354
  */
354
355
  specialtyRegistry?: SpecialtyManifestResolver;
356
+ /** Extension-supplied specialty defaults, consulted before the manifest. */
357
+ specialtyDefaults?: SpecialtyDefaultsResolver;
355
358
  /**
356
359
  * Cleanup deps for the full delete-agent orchestration (POST
357
360
  * /admin/agents/:id/delete → deleteAgentFully()). Matches
@@ -782,6 +785,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
782
785
  accountCreatedNotifier,
783
786
  accountServices,
784
787
  specialtyRegistry = new SpecialtyRegistry(),
788
+ specialtyDefaults,
785
789
  callerScopeResolver = memberOnlyCallerScopeResolver(agentMemberService),
786
790
  serviceIntegrations,
787
791
  chatService,
@@ -1557,6 +1561,7 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
1557
1561
  agentCronJobService,
1558
1562
  provisioner,
1559
1563
  specialtyRegistry,
1564
+ specialtyDefaults,
1560
1565
  anthropicFederationEnabled: deps.anthropicFederationEnabled,
1561
1566
  },
1562
1567
  {
@@ -11,6 +11,10 @@
11
11
 
12
12
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
13
13
  import type { PrismaClient } from "../prisma/client/client.ts";
14
+ import {
15
+ type AdminExtension,
16
+ collectSpecialtyDefaults,
17
+ } from "./admin-extension.ts";
14
18
  import { AgentCronJobService } from "./agent-cron-jobs.ts";
15
19
  import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
16
20
  import { createAdminPrismaClient } from "./prisma-client.ts";
@@ -581,3 +585,93 @@ describeOrSkip("AgentCronJobService (integration)", () => {
581
585
  expect(await service.list(agentId)).toEqual([]);
582
586
  });
583
587
  });
588
+
589
+ // A fixture extension supplying defaults, wired the way admin-boot.ts does.
590
+ const fixtureExtension: AdminExtension = {
591
+ name: "fixture",
592
+ specialtyDefaults: (specialty) =>
593
+ specialty === FIXTURE_TYPE
594
+ ? {
595
+ crons: [
596
+ {
597
+ name: "ext-loop",
598
+ schedule: "*/10 * * * *",
599
+ prompt: "/ext:loop",
600
+ enabled: true,
601
+ },
602
+ {
603
+ name: "ext-child",
604
+ schedule: "5 * * * *",
605
+ prompt: "/ext:child",
606
+ enabled: false,
607
+ parentCron: "ext-loop",
608
+ },
609
+ ],
610
+ }
611
+ : undefined,
612
+ };
613
+
614
+ describeOrSkip("AgentCronJobService specialtyDefaults (integration)", () => {
615
+ let prisma: PrismaClient;
616
+ let service: AgentCronJobService;
617
+
618
+ beforeEach(async () => {
619
+ prisma = makePrisma();
620
+ await prisma.agentToken.deleteMany();
621
+ await prisma.agentCronJob.deleteMany();
622
+ await prisma.agentTool.deleteMany();
623
+ await prisma.agentEnv.deleteMany();
624
+ await prisma.agent.deleteMany();
625
+ service = new AgentCronJobService(
626
+ prisma,
627
+ undefined,
628
+ fixtureResolver,
629
+ collectSpecialtyDefaults([fixtureExtension]),
630
+ );
631
+ });
632
+
633
+ afterEach(async () => {
634
+ await prisma.$disconnect();
635
+ });
636
+
637
+ it("reconcile seeds the extension's crons instead of the manifest's", async () => {
638
+ const agentId = await createAgent(prisma);
639
+ const result = await service.reconcileSystemCrons(agentId);
640
+ expect(result).toEqual({ created: 2, updated: 0, deleted: 0 });
641
+ const jobs = await service.list(agentId);
642
+ expect(jobs.map((j) => j.name).sort()).toEqual(["ext-child", "ext-loop"]);
643
+ const loop = jobs.find((j) => j.name === "ext-loop");
644
+ const child = jobs.find((j) => j.name === "ext-child");
645
+ expect(child?.parentCronId).toBe(loop?.id as string);
646
+ expect(child?.enabled).toBe(false);
647
+ });
648
+
649
+ it("re-reconcile keeps ids and enabled state and deletes orphaned system crons", async () => {
650
+ const agentId = await createAgent(prisma);
651
+ await service.reconcileSystemCrons(agentId);
652
+ const before = await service.list(agentId);
653
+ const loop = before.find((j) => j.name === "ext-loop");
654
+ await prisma.agentCronJob.update({
655
+ where: { id: loop?.id as string },
656
+ data: { enabled: false },
657
+ });
658
+ await prisma.agentCronJob.create({
659
+ data: {
660
+ agentId,
661
+ name: "stale-system",
662
+ system: true,
663
+ schedule: "* * * * *",
664
+ prompt: "x",
665
+ },
666
+ });
667
+
668
+ const result = await service.reconcileSystemCrons(agentId);
669
+
670
+ expect(result).toEqual({ created: 0, updated: 2, deleted: 1 });
671
+ const after = await service.list(agentId);
672
+ const loopAfter = after.find((j) => j.name === "ext-loop");
673
+ expect(loopAfter?.id).toBe(loop?.id as string);
674
+ expect(loopAfter?.enabled).toBe(false);
675
+ expect(after.some((j) => j.name === "stale-system")).toBe(false);
676
+ });
677
+ });
@@ -15,6 +15,10 @@ import {
15
15
  resolveDefaultSpecialtyName,
16
16
  resolveSpecialtiesDir,
17
17
  } from "./specialty-manifest-loader.ts";
18
+ import type {
19
+ SpecialtyDefaultCron,
20
+ SpecialtyDefaultsResolver,
21
+ } from "./specialty-defaults.ts";
18
22
  import type { SpecialtyManifest } from "./specialty-registry.ts";
19
23
  import { type Clock, SystemClock } from "./clock.ts";
20
24
  import { NotFoundError, UnprocessableEntityError } from "./errors.ts";
@@ -87,6 +91,7 @@ export class AgentCronJobService {
87
91
  private prisma: PrismaClient,
88
92
  private clock: Clock = SystemClock(),
89
93
  private specialtyRegistry: SpecialtyManifestResolver = new SpecialtyRegistry(),
94
+ private specialtyDefaults: SpecialtyDefaultsResolver = () => undefined,
90
95
  ) {}
91
96
 
92
97
  /**
@@ -494,8 +499,12 @@ export class AgentCronJobService {
494
499
  throw new NotFoundError(`agent ${agentId} not found`);
495
500
  }
496
501
  const { specialty } = agent;
497
- const manifest = this.specialtyRegistry.tryGetManifest(specialty);
498
- if (!manifest) {
502
+ // Extension-supplied crons win; the manifest is the fallback.
503
+ const extensionCrons = this.specialtyDefaults(specialty)?.crons;
504
+ const manifest = extensionCrons
505
+ ? undefined
506
+ : this.specialtyRegistry.tryGetManifest(specialty);
507
+ if (!extensionCrons && !manifest) {
499
508
  const defaultName = resolveDefaultSpecialtyName();
500
509
  if (specialty === defaultName) {
501
510
  throw missingDefaultSpecialtyError(
@@ -509,7 +518,8 @@ export class AgentCronJobService {
509
518
  );
510
519
  return { created: 0, updated: 0, deleted: 0 };
511
520
  }
512
- const manifestCrons: readonly ManifestCron[] = manifest.crons;
521
+ const manifestCrons: readonly (ManifestCron | SpecialtyDefaultCron)[] =
522
+ extensionCrons ?? manifest?.crons ?? [];
513
523
 
514
524
  // Fetch all existing system crons for this agent
515
525
  const existingSystemCrons = await this.prisma.agentCronJob.findMany({
@@ -566,7 +576,7 @@ export class AgentCronJobService {
566
576
  preCheck: systemCron.preCheck ?? null,
567
577
  channel: null,
568
578
  user: null,
569
- enabled: systemCron.enabled,
579
+ enabled: systemCron.enabled ?? true,
570
580
  },
571
581
  });
572
582
  idByName.set(systemCron.name, createdCron.id);
@@ -422,3 +422,118 @@ describe("reconcileSystemCrons (unit, injected registry)", () => {
422
422
  expect(Array.from(rows.values())[0]?.name).toBe("shipwright-loop");
423
423
  });
424
424
  });
425
+
426
+ describe("reconcileSystemCrons (specialtyDefaults hook)", () => {
427
+ const extCron = {
428
+ name: "ext-cron",
429
+ schedule: "*/5 * * * *",
430
+ prompt: "/ext",
431
+ enabled: false,
432
+ };
433
+
434
+ test("hook crons win over the manifest and the manifest is not consulted", async () => {
435
+ const registry = fakeRegistry({ coding: [loopCron] });
436
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
437
+ const service = new AgentCronJobService(
438
+ prisma as unknown as PrismaClient,
439
+ undefined,
440
+ registry,
441
+ (specialty) =>
442
+ specialty === "coding" ? { crons: [extCron] } : undefined,
443
+ );
444
+
445
+ const result = await service.reconcileSystemCrons("agent-1");
446
+
447
+ expect(result.created).toBe(1);
448
+ expect(registry.lookups).toEqual([]);
449
+ const [row] = Array.from(rows.values());
450
+ expect(row?.name).toBe("ext-cron");
451
+ expect(row?.enabled).toBe(false);
452
+ });
453
+
454
+ test("hook returning nothing falls back to the manifest", async () => {
455
+ const registry = fakeRegistry({ coding: [loopCron] });
456
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
457
+ const service = new AgentCronJobService(
458
+ prisma as unknown as PrismaClient,
459
+ undefined,
460
+ registry,
461
+ () => undefined,
462
+ );
463
+
464
+ await service.reconcileSystemCrons("agent-1");
465
+
466
+ expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
467
+ "shipwright-loop",
468
+ ]);
469
+ });
470
+
471
+ test("defaults without crons fall back to the manifest crons", async () => {
472
+ const registry = fakeRegistry({ coding: [loopCron] });
473
+ const { prisma, rows } = makeFakePrisma({ specialty: "coding" });
474
+ const service = new AgentCronJobService(
475
+ prisma as unknown as PrismaClient,
476
+ undefined,
477
+ registry,
478
+ () => ({ tools: ["Read"] }),
479
+ );
480
+
481
+ await service.reconcileSystemCrons("agent-1");
482
+
483
+ expect(Array.from(rows.values()).map((r) => r.name)).toEqual([
484
+ "shipwright-loop",
485
+ ]);
486
+ });
487
+
488
+ test("reconciles hook crons without any manifest for the specialty", async () => {
489
+ const registry = fakeRegistry({});
490
+ const { prisma, rows } = makeFakePrisma({ specialty: "ext-only" });
491
+ const service = new AgentCronJobService(
492
+ prisma as unknown as PrismaClient,
493
+ undefined,
494
+ registry,
495
+ () => ({ crons: [extCron] }),
496
+ );
497
+
498
+ const result = await service.reconcileSystemCrons("agent-1");
499
+
500
+ expect(result.created).toBe(1);
501
+ expect(Array.from(rows.values())[0]?.name).toBe("ext-cron");
502
+ });
503
+
504
+ test("preserves row id and enabled state, links parents, and deletes orphans", async () => {
505
+ const registry = fakeRegistry({});
506
+ const { prisma, rows } = makeFakePrisma({
507
+ specialty: "coding",
508
+ existing: [
509
+ { id: "stable-id", name: "ext-cron", enabled: true },
510
+ { id: "orphan", name: "removed-cron" },
511
+ ],
512
+ });
513
+ const service = new AgentCronJobService(
514
+ prisma as unknown as PrismaClient,
515
+ undefined,
516
+ registry,
517
+ () => ({
518
+ crons: [
519
+ extCron,
520
+ {
521
+ name: "ext-child",
522
+ schedule: "* * * * *",
523
+ prompt: "/c",
524
+ parentCron: "ext-cron",
525
+ },
526
+ ],
527
+ }),
528
+ );
529
+
530
+ const result = await service.reconcileSystemCrons("agent-1");
531
+
532
+ expect(result).toEqual({ created: 1, updated: 1, deleted: 1 });
533
+ expect(rows.get("stable-id")?.enabled).toBe(true);
534
+ expect(rows.has("orphan")).toBe(false);
535
+ const child = Array.from(rows.values()).find((r) => r.name === "ext-child");
536
+ expect(child?.parentCronId).toBe("stable-id");
537
+ expect(child?.enabled).toBe(true);
538
+ });
539
+ });
package/src/agents-api.ts CHANGED
@@ -156,6 +156,7 @@ import {
156
156
  UpsertChatTokenDailyBodySchema,
157
157
  } from "./openapi-schemas.ts";
158
158
  import type { SelfServeConfig } from "./self-serve-config.ts";
159
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
159
160
  import type { SpecialtyManifestResolver } from "./specialty-manifest-loader.ts";
160
161
  import { managedReconcileTargets } from "./startup-policy-reconcile.ts";
161
162
 
@@ -225,6 +226,8 @@ export interface AdminDeps {
225
226
  * Required (not optional) because the route needs it unconditionally.
226
227
  */
227
228
  specialtyRegistry: SpecialtyManifestResolver;
229
+ /** Extension-supplied specialty defaults, consulted before the manifest. */
230
+ specialtyDefaults?: SpecialtyDefaultsResolver;
228
231
  agentChatTokenService: Pick<
229
232
  AgentChatTokenService,
230
233
  "upsertDailyByModel" | "queryStats"
@@ -1522,6 +1525,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1522
1525
  agentPluginService,
1523
1526
  agentMemberService,
1524
1527
  specialtyRegistry,
1528
+ specialtyDefaults,
1525
1529
  agentChatTokenService,
1526
1530
  agentGitHubInstallationsService,
1527
1531
  prisma,
@@ -1930,7 +1934,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1930
1934
  // SSP-4.2: platform admins keep today's behavior (and may create
1931
1935
  // unassigned agents). A session user with an account (flag on) may create
1932
1936
  // agents in THEIR account only: accountId comes from the resolved scope —
1933
- // never the body — and the type is forced to "coding".
1937
+ // never the body — and the type is forced to the self-serve specialty.
1934
1938
  let accountId: string | undefined;
1935
1939
  if (c.get("isAdmin") !== true) {
1936
1940
  const email = c.get("callerEmail");
@@ -1965,6 +1969,7 @@ export function createAdminApp(deps: AdminDeps): OpenAPIHono<AdminAuthEnv> {
1965
1969
  agentCronJobService,
1966
1970
  provisioner,
1967
1971
  specialtyRegistry,
1972
+ specialtyDefaults,
1968
1973
  anthropicFederationEnabled: deps.anthropicFederationEnabled,
1969
1974
  },
1970
1975
  {
package/src/agents.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type SpecialtyManifestResolver,
24
24
  SpecialtyRegistry,
25
25
  } from "./specialty-manifest-loader.ts";
26
+ import type { SpecialtyDefaultsResolver } from "./specialty-defaults.ts";
26
27
  import { ANTHROPIC_CREDENTIALS_BLOCKED_CODE } from "./anthropic-credential-guard.ts";
27
28
  import type { PrismaTransactionClient } from "./prisma-tx.ts";
28
29
 
@@ -784,6 +785,8 @@ export interface CreateAgentDeps {
784
785
  agentCronJobService: Pick<AgentCronJobService, "reconcileSystemCrons">;
785
786
  provisioner: Pick<AgentProvisioner, "canProvision" | "provision">;
786
787
  specialtyRegistry: Pick<SpecialtyManifestResolver, "tryGetManifest">;
788
+ /** Extension-supplied tools/plugins, consulted before the manifest's. */
789
+ specialtyDefaults?: SpecialtyDefaultsResolver;
787
790
  /** WIF-2.1: Anthropic federation is enabled; defaults to false. */
788
791
  anthropicFederationEnabled?: boolean;
789
792
  }
@@ -878,7 +881,10 @@ export async function createAgent(
878
881
  const manifest = specialty
879
882
  ? deps.specialtyRegistry.tryGetManifest(specialty)
880
883
  : undefined;
881
- if (!specialty || !manifest) {
884
+ const extensionDefaults = specialty
885
+ ? deps.specialtyDefaults?.(specialty)
886
+ : undefined;
887
+ if (!specialty || (!manifest && !extensionDefaults)) {
882
888
  return { ok: false, errorCode: "invalid_type" };
883
889
  }
884
890
 
@@ -940,10 +946,14 @@ export async function createAgent(
940
946
  // handling below, and the manifest's members/repos arrays are empty
941
947
  // for every specialty that exists today.
942
948
  try {
943
- for (const pattern of manifest.tools) {
949
+ for (const pattern of extensionDefaults?.tools ??
950
+ manifest?.tools ??
951
+ []) {
944
952
  await deps.agentToolService.add(agent.id, pattern, tx);
945
953
  }
946
- for (const pluginName of manifest.plugins) {
954
+ for (const pluginName of extensionDefaults?.plugins ??
955
+ manifest?.plugins ??
956
+ []) {
947
957
  await deps.agentPluginService.add(
948
958
  agent.id,
949
959
  pluginName,
@@ -1319,6 +1319,76 @@ function baseCreateAgentInput(
1319
1319
  };
1320
1320
  }
1321
1321
 
1322
+ describe("createAgent() specialtyDefaults hook", () => {
1323
+ it("seeds tools and plugins from the hook instead of the manifest", async () => {
1324
+ const { deps, calls } = makeCreateAgentHarness();
1325
+ deps.specialtyDefaults = () => ({
1326
+ tools: ["Grep"],
1327
+ plugins: ["ext-plugin"],
1328
+ });
1329
+
1330
+ const result = await createAgent(deps, baseCreateAgentInput());
1331
+
1332
+ expect(result.ok).toBe(true);
1333
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Grep"]);
1334
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1335
+ });
1336
+
1337
+ it("falls back to the manifest when the hook returns nothing", async () => {
1338
+ const { deps, calls } = makeCreateAgentHarness();
1339
+ deps.specialtyDefaults = () => undefined;
1340
+
1341
+ await createAgent(deps, baseCreateAgentInput());
1342
+
1343
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1344
+ CODING_MANIFEST.tools,
1345
+ );
1346
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(
1347
+ CODING_MANIFEST.plugins,
1348
+ );
1349
+ });
1350
+
1351
+ it("falls back per field when the hook omits tools or plugins", async () => {
1352
+ const { deps, calls } = makeCreateAgentHarness();
1353
+ deps.specialtyDefaults = () => ({ plugins: ["ext-plugin"] });
1354
+
1355
+ await createAgent(deps, baseCreateAgentInput());
1356
+
1357
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(
1358
+ CODING_MANIFEST.tools,
1359
+ );
1360
+ expect(calls.pluginsAdded.map((p) => p.name)).toEqual(["ext-plugin"]);
1361
+ });
1362
+
1363
+ it("accepts a specialty known only to the hook", async () => {
1364
+ const { deps, calls } = makeCreateAgentHarness();
1365
+ deps.specialtyDefaults = (s) =>
1366
+ s === "ext-only" ? { tools: ["Read"] } : undefined;
1367
+
1368
+ const result = await createAgent(
1369
+ deps,
1370
+ baseCreateAgentInput({ specialty: "ext-only" }),
1371
+ );
1372
+
1373
+ expect(result.ok).toBe(true);
1374
+ expect(calls.toolsAdded.map((t) => t.pattern)).toEqual(["Read"]);
1375
+ expect(calls.pluginsAdded).toEqual([]);
1376
+ });
1377
+
1378
+ it("still rejects a specialty unknown to both hook and manifest", async () => {
1379
+ const { deps, calls } = makeCreateAgentHarness();
1380
+ deps.specialtyDefaults = () => undefined;
1381
+
1382
+ const result = await createAgent(
1383
+ deps,
1384
+ baseCreateAgentInput({ specialty: "nope" }),
1385
+ );
1386
+
1387
+ expect(result).toEqual({ ok: false, errorCode: "invalid_type" });
1388
+ expect(calls.created).toHaveLength(0);
1389
+ });
1390
+ });
1391
+
1322
1392
  describe("createAgent()", () => {
1323
1393
  it("passes accountId through to agentService.create and tolerates an undefined tx (SSP-4.1)", async () => {
1324
1394
  const { deps, calls } = makeCreateAgentHarness();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * admin/src/specialty-defaults.ts
3
+ *
4
+ * The shape of a specialty's default crons, tools and plugins as supplied by
5
+ * an AdminExtension's specialtyDefaults() hook (admin-extension.ts). The cron
6
+ * reconcile (agent-cron-jobs.ts) and create-time seeding (agents.ts) consult
7
+ * the hook first and fall back to the specialty manifest when it returns
8
+ * nothing. Kept in its own module so those services can name the types without
9
+ * importing admin-extension.ts.
10
+ */
11
+
12
+ /** A default cron template; same fields as a manifest cron, defaults optional. */
13
+ export interface SpecialtyDefaultCron {
14
+ name: string;
15
+ schedule: string;
16
+ prompt: string;
17
+ preCheck?: string;
18
+ silent?: boolean;
19
+ /** Initial enabled state for a newly created row; defaults to true. */
20
+ enabled?: boolean;
21
+ /** Name of another cron in this same list. */
22
+ parentCron?: string;
23
+ }
24
+
25
+ /** Any field left undefined falls back to the manifest for that field. */
26
+ export interface SpecialtyDefaults {
27
+ crons?: readonly SpecialtyDefaultCron[];
28
+ tools?: readonly string[];
29
+ plugins?: readonly string[];
30
+ }
31
+
32
+ /** Resolves a specialty name to extension-supplied defaults, if any. */
33
+ export type SpecialtyDefaultsResolver = (
34
+ specialty: string,
35
+ ) => SpecialtyDefaults | undefined;