@app-vitals/agent-admin 0.15.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 +2 -2
- package/src/account-agent-create.ts +3 -3
- package/src/admin-boot.ts +12 -1
- package/src/admin-extension.ts +29 -0
- package/src/admin-extension.unit.test.ts +19 -0
- package/src/admin-local-agent.smoke.test.ts +1 -1
- package/src/admin-ui-pages.ts +3 -2
- package/src/admin-ui-pages.unit.test.ts +32 -6
- package/src/admin-ui.ts +5 -0
- package/src/agent-cron-jobs.integration.test.ts +94 -0
- package/src/agent-cron-jobs.ts +14 -4
- package/src/agent-cron-jobs.unit.test.ts +115 -0
- package/src/agents-api.ts +6 -1
- package/src/agents.ts +13 -3
- package/src/agents.unit.test.ts +70 -0
- package/src/specialty-defaults.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "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.
|
|
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
|
|
7
|
-
* THEIR account (never a client-supplied
|
|
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
|
-
|
|
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,
|
package/src/admin-extension.ts
CHANGED
|
@@ -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
|
|
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",
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
4263
|
-
const
|
|
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
|
+
});
|
package/src/agent-cron-jobs.ts
CHANGED
|
@@ -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
|
-
|
|
498
|
-
|
|
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[] =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
954
|
+
for (const pluginName of extensionDefaults?.plugins ??
|
|
955
|
+
manifest?.plugins ??
|
|
956
|
+
[]) {
|
|
947
957
|
await deps.agentPluginService.add(
|
|
948
958
|
agent.id,
|
|
949
959
|
pluginName,
|
package/src/agents.unit.test.ts
CHANGED
|
@@ -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;
|