mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
Add built-in agent provisioning foundation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4d50fa9f29
commit
4d5ad7408d
6 files changed
+799
-1
No files matched your search
@@ -0,0 +1,311 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agentConfigRevisions,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { HttpError } from "../errors.ts";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import {
|
||||
builtInAgentService,
|
||||
deriveBuiltInAgentStatus,
|
||||
listBuiltInAgentDefinitions,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
validateBuiltInAgentDefinitions,
|
||||
} from "../services/built-in-agents.ts";
|
||||
import { readBuiltInAgentMarker, withBuiltInAgentMarker } from "../services/built-in-agent-metadata.ts";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function issuePrefix(id: string) {
|
||||
return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres built-in agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("built-in agents", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(agentConfigRevisions);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: issuePrefix(companyId),
|
||||
requireBoardApprovalForNewAgents: true,
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("validates the static registry and rejects invalid definitions", () => {
|
||||
expect(listBuiltInAgentDefinitions().map((definition) => definition.key).sort()).toEqual(["briefs", "learning"]);
|
||||
expect(() => validateBuiltInAgentDefinitions([
|
||||
{
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "One",
|
||||
defaultInstructions: "Do work",
|
||||
defaultRole: "general",
|
||||
},
|
||||
{
|
||||
key: "briefs",
|
||||
displayName: "Duplicate",
|
||||
featureKeys: ["duplicate"],
|
||||
shortPurpose: "Two",
|
||||
defaultInstructions: "Do work",
|
||||
defaultRole: "general",
|
||||
},
|
||||
])).toThrow("Duplicate built-in agent key");
|
||||
expect(() => validateBuiltInAgentDefinitions([
|
||||
{
|
||||
key: "Bad Key",
|
||||
displayName: "Bad",
|
||||
featureKeys: ["bad"],
|
||||
shortPurpose: "Bad",
|
||||
defaultInstructions: "Bad",
|
||||
defaultRole: "general",
|
||||
},
|
||||
])).toThrow("Invalid built-in agent key");
|
||||
});
|
||||
|
||||
it("lazily provisions one agent per company/key and updates the same row on setup", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = builtInAgentService(db);
|
||||
|
||||
const created = await svc.ensure(companyId, "briefs");
|
||||
expect(created.status).toBe("needs_setup");
|
||||
expect(created.agentId).toBeTruthy();
|
||||
expect(created.agent).toMatchObject({
|
||||
companyId,
|
||||
name: "Briefs Agent",
|
||||
adapterConfig: {},
|
||||
status: "idle",
|
||||
});
|
||||
expect(readBuiltInAgentMarker(created.agent?.metadata)).toEqual({
|
||||
key: "briefs",
|
||||
featureKeys: ["briefs"],
|
||||
});
|
||||
|
||||
const configured = await svc.ensure(companyId, "briefs", {
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
});
|
||||
expect(configured.status).toBe("ready");
|
||||
expect(configured.agentId).toBe(created.agentId);
|
||||
expect(configured.agent).toMatchObject({
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
});
|
||||
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("recovers an orphaned marked row instead of creating a duplicate", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const orphanId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: orphanId,
|
||||
companyId,
|
||||
name: "Old Briefs",
|
||||
role: "general",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: withBuiltInAgentMarker({ source: "orphan" }, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
});
|
||||
|
||||
const state = await builtInAgentService(db).ensure(companyId, "briefs");
|
||||
|
||||
expect(state.status).toBe("ready");
|
||||
expect(state.agentId).toBe(orphanId);
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("derives not_provisioned, needs_setup, ready, and paused states", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const builtIns = builtInAgentService(db);
|
||||
|
||||
await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({ status: "not_provisioned" });
|
||||
|
||||
const needsSetup = await builtIns.ensure(companyId, "learning");
|
||||
expect(needsSetup.status).toBe("needs_setup");
|
||||
expect(deriveBuiltInAgentStatus(needsSetup.agent)).toBe("needs_setup");
|
||||
|
||||
const ready = await builtIns.ensure(companyId, "learning", {
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: { model: "claude-sonnet-4" },
|
||||
});
|
||||
expect(ready.status).toBe("ready");
|
||||
|
||||
await agentService(db).pause(ready.agentId!, "manual");
|
||||
await expect(builtIns.get(companyId, "learning")).resolves.toMatchObject({
|
||||
status: "paused",
|
||||
agentId: ready.agentId,
|
||||
pauseReason: "manual",
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks deleting a built-in agent", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const state = await builtInAgentService(db).ensure(companyId, "briefs");
|
||||
|
||||
await expect(agentService(db).remove(state.agentId!)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "built_in_agent_undeletable",
|
||||
key: "briefs",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prevents direct marker add, remove, or mutation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const builtIn = await builtInAgentService(db).ensure(companyId, "briefs");
|
||||
const normal = await agentService(db).create(companyId, {
|
||||
name: "Normal",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
});
|
||||
|
||||
await expect(agentService(db).create(companyId, {
|
||||
name: "Spoof",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
})).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } });
|
||||
|
||||
await expect(agentService(db).update(normal.id, {
|
||||
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
})).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } });
|
||||
|
||||
await expect(agentService(db).update(builtIn.agentId!, {
|
||||
metadata: { other: "metadata" },
|
||||
})).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } });
|
||||
|
||||
await expect(agentService(db).update(builtIn.agentId!, {
|
||||
metadata: withBuiltInAgentMarker({}, { key: "learning", featureKeys: ["learning"] }),
|
||||
})).rejects.toMatchObject({ status: 409, details: { code: "built_in_agent_marker_readonly" } });
|
||||
|
||||
await expect(agentService(db).update(builtIn.agentId!, {
|
||||
metadata: withBuiltInAgentMarker({ note: "allowed" }, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
})).resolves.toMatchObject({
|
||||
id: builtIn.agentId,
|
||||
metadata: {
|
||||
note: "allowed",
|
||||
paperclipBuiltInAgent: { key: "briefs", featureKeys: ["briefs"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs display/default drift for marked rows during startup reconciliation", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const agentId = randomUUID();
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Old Name",
|
||||
role: "engineer",
|
||||
title: "Old title",
|
||||
capabilities: "Old purpose",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["old-briefs"] }),
|
||||
});
|
||||
|
||||
const result = await reconcileBuiltInAgentsOnStartup(db);
|
||||
expect(result).toMatchObject({ scanned: 1, reconciled: 1, unknown: 0, duplicates: 0 });
|
||||
|
||||
const [row] = await db.select().from(agents).where(eq(agents.id, agentId));
|
||||
expect(row).toMatchObject({
|
||||
name: "Briefs Agent",
|
||||
role: "general",
|
||||
title: null,
|
||||
capabilities: "Prepares concise operational briefs for the board and agent company.",
|
||||
});
|
||||
expect(readBuiltInAgentMarker(row?.metadata)).toEqual({ key: "briefs", featureKeys: ["briefs"] });
|
||||
});
|
||||
|
||||
it("reports duplicate active instances for a company/key", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await db.insert(agents).values([
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Briefs One",
|
||||
role: "general",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
},
|
||||
{
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "Briefs Two",
|
||||
role: "general",
|
||||
status: "idle",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(builtInAgentService(db).ensure(companyId, "briefs")).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: {
|
||||
code: "built_in_agent_duplicate_instance",
|
||||
key: "briefs",
|
||||
},
|
||||
} satisfies Partial<HttpError>);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
environmentCustomImageService,
|
||||
heartbeatService,
|
||||
instanceSettingsService,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
reconcileCloudUpstreamRunsOnStartup,
|
||||
reconcileCodexLocalManagedHomesOnStartup,
|
||||
reconcilePersistedRuntimeServicesOnStartup,
|
||||
@@ -779,6 +780,19 @@ export async function startServer(): Promise<StartedServer> {
|
||||
logger.error({ err }, "startup reconciliation of codex_local managed homes failed");
|
||||
});
|
||||
|
||||
void reconcileBuiltInAgentsOnStartup(db as any)
|
||||
.then((result) => {
|
||||
if (result.reconciled > 0 || result.unknown > 0 || result.duplicates > 0) {
|
||||
logger.warn(
|
||||
result,
|
||||
"startup reconciliation of built-in agents complete",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "startup reconciliation of built-in agents failed");
|
||||
});
|
||||
|
||||
// Force the instance onto the Kubernetes sandbox provider when configured via
|
||||
// env (PAPERCLIP_EXECUTION_MODE=kubernetes). Runs BEFORE the heartbeat resumes
|
||||
// queued runs so the policy + managed k8s environments are in place. A bad
|
||||
|
||||
@@ -30,6 +30,10 @@ import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import {
|
||||
builtInAgentMarkersEqual,
|
||||
readBuiltInAgentMarker,
|
||||
} from "./built-in-agent-metadata.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -65,6 +69,11 @@ interface RevisionMetadata {
|
||||
|
||||
interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
}
|
||||
|
||||
interface CreateAgentOptions {
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
@@ -390,6 +399,21 @@ export function agentService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
function assertBuiltInAgentMetadataMutationAllowed(
|
||||
beforeMetadata: unknown,
|
||||
afterMetadata: unknown,
|
||||
options?: { allowBuiltInAgentMetadata?: boolean },
|
||||
) {
|
||||
if (options?.allowBuiltInAgentMetadata) return;
|
||||
const beforeMarker = readBuiltInAgentMarker(beforeMetadata);
|
||||
const afterMarker = readBuiltInAgentMarker(afterMetadata);
|
||||
if (builtInAgentMarkersEqual(beforeMarker, afterMarker)) return;
|
||||
throw conflict("Built-in agent marker is managed by Paperclip and cannot be edited directly", {
|
||||
code: "built_in_agent_marker_readonly",
|
||||
key: beforeMarker?.key ?? afterMarker?.key ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
async function updateAgent(
|
||||
id: string,
|
||||
data: Partial<typeof agents.$inferInsert>,
|
||||
@@ -425,6 +449,10 @@ export function agentService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(data, "metadata")) {
|
||||
assertBuiltInAgentMetadataMutationAllowed(existing.metadata, data.metadata, options);
|
||||
}
|
||||
|
||||
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
|
||||
if (data.permissions !== undefined) {
|
||||
const role = (data.role ?? existing.role) as string;
|
||||
@@ -501,7 +529,8 @@ export function agentService(db: Db) {
|
||||
|
||||
getById,
|
||||
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">) => {
|
||||
create: async (companyId: string, data: Omit<typeof agents.$inferInsert, "companyId">, options?: CreateAgentOptions) => {
|
||||
assertBuiltInAgentMetadataMutationAllowed(null, data.metadata, options);
|
||||
if (data.reportsTo) {
|
||||
await ensureManager(companyId, data.reportsTo);
|
||||
}
|
||||
@@ -645,6 +674,14 @@ export function agentService(db: Db) {
|
||||
remove: async (id: string) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
const builtInMarker = readBuiltInAgentMarker(existing.metadata);
|
||||
if (builtInMarker) {
|
||||
throw conflict("Built-in agents cannot be deleted; pause them instead", {
|
||||
code: "built_in_agent_undeletable",
|
||||
key: builtInMarker.key,
|
||||
featureKeys: builtInMarker.featureKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.update(agents).set({ reportsTo: null }).where(eq(agents.reportsTo, id));
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const BUILT_IN_AGENT_METADATA_KEY = "paperclipBuiltInAgent";
|
||||
|
||||
export interface BuiltInAgentMarker {
|
||||
key: string;
|
||||
featureKeys: string[];
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeFeatureKeys(value: unknown): string[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
const featureKeys = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
|
||||
return featureKeys.length === value.length ? featureKeys : null;
|
||||
}
|
||||
|
||||
export function readBuiltInAgentMarker(metadata: unknown): BuiltInAgentMarker | null {
|
||||
if (!isPlainRecord(metadata)) return null;
|
||||
const marker = metadata[BUILT_IN_AGENT_METADATA_KEY];
|
||||
if (!isPlainRecord(marker)) return null;
|
||||
const key = marker.key;
|
||||
const featureKeys = normalizeFeatureKeys(marker.featureKeys);
|
||||
if (typeof key !== "string" || key.trim().length === 0 || !featureKeys) return null;
|
||||
return { key, featureKeys };
|
||||
}
|
||||
|
||||
export function withBuiltInAgentMarker(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
marker: BuiltInAgentMarker,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...(metadata ?? {}),
|
||||
[BUILT_IN_AGENT_METADATA_KEY]: {
|
||||
key: marker.key,
|
||||
featureKeys: [...marker.featureKeys],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function builtInAgentMarkersEqual(left: BuiltInAgentMarker | null, right: BuiltInAgentMarker | null) {
|
||||
if (!left && !right) return true;
|
||||
if (!left || !right) return false;
|
||||
return left.key === right.key && JSON.stringify(left.featureKeys) === JSON.stringify(right.featureKeys);
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies } from "@paperclipai/db";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { agentService } from "./agents.js";
|
||||
import {
|
||||
readBuiltInAgentMarker,
|
||||
withBuiltInAgentMarker,
|
||||
} from "./built-in-agent-metadata.js";
|
||||
|
||||
export type BuiltInAgentStatus = "not_provisioned" | "needs_setup" | "ready" | "paused";
|
||||
|
||||
export interface BuiltInAgentDefinition {
|
||||
key: string;
|
||||
displayName: string;
|
||||
featureKeys: string[];
|
||||
shortPurpose: string;
|
||||
defaultInstructions: string;
|
||||
defaultRole: string;
|
||||
allowedAdapterTypes?: string[];
|
||||
defaultBudgetMonthlyCents?: number;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentState {
|
||||
definition: BuiltInAgentDefinition;
|
||||
status: BuiltInAgentStatus;
|
||||
agentId: string | null;
|
||||
agent: Agent | null;
|
||||
pauseReason: string | null;
|
||||
}
|
||||
|
||||
export interface BuiltInAgentProvisionInput {
|
||||
adapterType?: string;
|
||||
adapterConfig?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const BUILT_IN_AGENT_KEY_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
const DEFINITIONS = validateBuiltInAgentDefinitions([
|
||||
{
|
||||
key: "briefs",
|
||||
displayName: "Briefs Agent",
|
||||
featureKeys: ["briefs"],
|
||||
shortPurpose: "Prepares concise operational briefs for the board and agent company.",
|
||||
defaultInstructions:
|
||||
"You are Paperclip's built-in Briefs agent. Produce concise, sourced operational briefs that help the board understand current company work, risks, and next actions.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
},
|
||||
{
|
||||
key: "learning",
|
||||
displayName: "Learning Agent",
|
||||
featureKeys: ["learning"],
|
||||
shortPurpose: "Maintains reusable company learning from completed work and recurring patterns.",
|
||||
defaultInstructions:
|
||||
"You are Paperclip's built-in Learning agent. Extract durable lessons from completed work, preserve useful patterns, and keep learning artifacts grounded in source context.",
|
||||
defaultRole: "general",
|
||||
allowedAdapterTypes: ["codex_local", "claude_local", "gemini_local", "opencode_local", "process"],
|
||||
defaultBudgetMonthlyCents: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function uniqueNonEmptyStrings(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function validateBuiltInAgentDefinitions(definitions: BuiltInAgentDefinition[]) {
|
||||
const seenKeys = new Set<string>();
|
||||
for (const definition of definitions) {
|
||||
if (!BUILT_IN_AGENT_KEY_PATTERN.test(definition.key)) {
|
||||
throw new Error(`Invalid built-in agent key: ${definition.key}`);
|
||||
}
|
||||
if (seenKeys.has(definition.key)) {
|
||||
throw new Error(`Duplicate built-in agent key: ${definition.key}`);
|
||||
}
|
||||
seenKeys.add(definition.key);
|
||||
if (!definition.displayName.trim()) {
|
||||
throw new Error(`Built-in agent ${definition.key} requires a displayName`);
|
||||
}
|
||||
if (!definition.shortPurpose.trim()) {
|
||||
throw new Error(`Built-in agent ${definition.key} requires a shortPurpose`);
|
||||
}
|
||||
if (!definition.defaultInstructions.trim()) {
|
||||
throw new Error(`Built-in agent ${definition.key} requires defaultInstructions`);
|
||||
}
|
||||
if (!definition.defaultRole.trim()) {
|
||||
throw new Error(`Built-in agent ${definition.key} requires a defaultRole`);
|
||||
}
|
||||
if (uniqueNonEmptyStrings(definition.featureKeys).length !== definition.featureKeys.length) {
|
||||
throw new Error(`Built-in agent ${definition.key} featureKeys must be unique non-empty strings`);
|
||||
}
|
||||
if (definition.featureKeys.length === 0) {
|
||||
throw new Error(`Built-in agent ${definition.key} requires at least one featureKey`);
|
||||
}
|
||||
if (
|
||||
definition.allowedAdapterTypes
|
||||
&& uniqueNonEmptyStrings(definition.allowedAdapterTypes).length !== definition.allowedAdapterTypes.length
|
||||
) {
|
||||
throw new Error(`Built-in agent ${definition.key} allowedAdapterTypes must be unique non-empty strings`);
|
||||
}
|
||||
if (
|
||||
definition.defaultBudgetMonthlyCents !== undefined
|
||||
&& (!Number.isInteger(definition.defaultBudgetMonthlyCents) || definition.defaultBudgetMonthlyCents < 0)
|
||||
) {
|
||||
throw new Error(`Built-in agent ${definition.key} defaultBudgetMonthlyCents must be a non-negative integer`);
|
||||
}
|
||||
}
|
||||
return definitions.map((definition) => ({
|
||||
...definition,
|
||||
featureKeys: [...definition.featureKeys],
|
||||
allowedAdapterTypes: definition.allowedAdapterTypes ? [...definition.allowedAdapterTypes] : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
export function listBuiltInAgentDefinitions() {
|
||||
return DEFINITIONS.map((definition) => ({ ...definition, featureKeys: [...definition.featureKeys] }));
|
||||
}
|
||||
|
||||
export function getBuiltInAgentDefinition(key: string) {
|
||||
return DEFINITIONS_BY_KEY.get(key) ?? null;
|
||||
}
|
||||
|
||||
export function requireBuiltInAgentDefinition(key: string) {
|
||||
const definition = getBuiltInAgentDefinition(key);
|
||||
if (!definition) throw notFound(`Built-in agent definition not found: ${key}`);
|
||||
return definition;
|
||||
}
|
||||
|
||||
function defaultAdapterType(definition: BuiltInAgentDefinition) {
|
||||
return definition.allowedAdapterTypes?.[0] ?? "process";
|
||||
}
|
||||
|
||||
function assertAdapterAllowed(definition: BuiltInAgentDefinition, adapterType: string) {
|
||||
if (definition.allowedAdapterTypes && !definition.allowedAdapterTypes.includes(adapterType)) {
|
||||
throw unprocessable(`Adapter type ${adapterType} is not allowed for built-in agent ${definition.key}`, {
|
||||
code: "built_in_agent_adapter_not_allowed",
|
||||
key: definition.key,
|
||||
allowedAdapterTypes: definition.allowedAdapterTypes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasCompleteAdapterConfig(adapterType: string, adapterConfig: unknown) {
|
||||
if (!isPlainRecord(adapterConfig)) return false;
|
||||
if (["process", "command"].includes(adapterType)) {
|
||||
return nonEmptyString(adapterConfig.command) || nonEmptyString(adapterConfig.script);
|
||||
}
|
||||
if (adapterType === "http") {
|
||||
return nonEmptyString(adapterConfig.url) || nonEmptyString(adapterConfig.endpoint) || nonEmptyString(adapterConfig.webhookUrl);
|
||||
}
|
||||
if (adapterType === "openclaw_gateway" || adapterType === "hermes_gateway") {
|
||||
return nonEmptyString(adapterConfig.baseUrl) || nonEmptyString(adapterConfig.url);
|
||||
}
|
||||
return nonEmptyString(adapterConfig.model);
|
||||
}
|
||||
|
||||
export function deriveBuiltInAgentStatus(agent: Pick<Agent, "adapterType" | "adapterConfig" | "status" | "pausedAt"> | null): BuiltInAgentStatus {
|
||||
if (!agent) return "not_provisioned";
|
||||
if (agent.status === "paused" || agent.pausedAt) return "paused";
|
||||
return hasCompleteAdapterConfig(agent.adapterType, agent.adapterConfig) ? "ready" : "needs_setup";
|
||||
}
|
||||
|
||||
function builtInMetadata(definition: BuiltInAgentDefinition, existing?: Record<string, unknown> | null) {
|
||||
return withBuiltInAgentMarker(existing, {
|
||||
key: definition.key,
|
||||
featureKeys: definition.featureKeys,
|
||||
});
|
||||
}
|
||||
|
||||
function definitionPatch(definition: BuiltInAgentDefinition, input: BuiltInAgentProvisionInput = {}) {
|
||||
const adapterType = input.adapterType ?? defaultAdapterType(definition);
|
||||
assertAdapterAllowed(definition, adapterType);
|
||||
return {
|
||||
name: definition.displayName,
|
||||
role: definition.defaultRole,
|
||||
title: null,
|
||||
capabilities: definition.shortPurpose,
|
||||
adapterType,
|
||||
adapterConfig: input.adapterConfig ?? {},
|
||||
budgetMonthlyCents: definition.defaultBudgetMonthlyCents ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rowIsBuiltInAgent(row: typeof agents.$inferSelect, key: string) {
|
||||
const marker = readBuiltInAgentMarker(row.metadata);
|
||||
return marker?.key === key;
|
||||
}
|
||||
|
||||
export function builtInAgentService(db: Db) {
|
||||
const agentSvc = agentService(db);
|
||||
|
||||
async function ensureCompany(companyId: string) {
|
||||
const company = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(eq(companies.id, companyId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!company) throw notFound("Company not found");
|
||||
}
|
||||
|
||||
async function findMarkedRows(companyId: string, key: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
|
||||
return rows
|
||||
.filter((row) => rowIsBuiltInAgent(row, key))
|
||||
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
async function findSingleAgent(companyId: string, definition: BuiltInAgentDefinition) {
|
||||
const markedRows = await findMarkedRows(companyId, definition.key);
|
||||
if (markedRows.length > 1) {
|
||||
throw conflict(`Multiple built-in agents found for ${definition.key}`, {
|
||||
code: "built_in_agent_duplicate_instance",
|
||||
key: definition.key,
|
||||
agentIds: markedRows.map((row) => row.id),
|
||||
});
|
||||
}
|
||||
if (markedRows.length === 0) return null;
|
||||
const agent = await agentSvc.getById(markedRows[0]!.id);
|
||||
return agent as Agent | null;
|
||||
}
|
||||
|
||||
function state(definition: BuiltInAgentDefinition, agent: Agent | null): BuiltInAgentState {
|
||||
return {
|
||||
definition,
|
||||
status: deriveBuiltInAgentStatus(agent),
|
||||
agentId: agent?.id ?? null,
|
||||
agent,
|
||||
pauseReason: agent?.pauseReason ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function get(companyId: string, key: string) {
|
||||
const definition = requireBuiltInAgentDefinition(key);
|
||||
await ensureCompany(companyId);
|
||||
return state(definition, await findSingleAgent(companyId, definition));
|
||||
}
|
||||
|
||||
async function ensure(companyId: string, key: string, input: BuiltInAgentProvisionInput = {}) {
|
||||
const definition = requireBuiltInAgentDefinition(key);
|
||||
await ensureCompany(companyId);
|
||||
const existing = await findSingleAgent(companyId, definition);
|
||||
if (existing) {
|
||||
const patch: Partial<typeof agents.$inferInsert> = {
|
||||
metadata: builtInMetadata(definition, existing.metadata),
|
||||
};
|
||||
if (input.adapterType !== undefined || input.adapterConfig !== undefined) {
|
||||
const adapterType = input.adapterType ?? existing.adapterType;
|
||||
assertAdapterAllowed(definition, adapterType);
|
||||
patch.adapterType = adapterType;
|
||||
patch.adapterConfig = input.adapterConfig ?? existing.adapterConfig;
|
||||
}
|
||||
const updated = await agentSvc.update(existing.id, patch, {
|
||||
allowBuiltInAgentMetadata: true,
|
||||
recordRevision: { source: "built-in-agent:ensure" },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
return state(definition, updated as Agent);
|
||||
}
|
||||
|
||||
const created = await agentSvc.create(companyId, {
|
||||
...definitionPatch(definition, input),
|
||||
status: "idle",
|
||||
metadata: builtInMetadata(definition),
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
}, { allowBuiltInAgentMetadata: true }) as Agent;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "system",
|
||||
actorId: "built-in-agents",
|
||||
action: "built_in_agent.provisioned",
|
||||
entityType: "agent",
|
||||
entityId: created.id,
|
||||
details: {
|
||||
key: definition.key,
|
||||
featureKeys: definition.featureKeys,
|
||||
status: deriveBuiltInAgentStatus(created),
|
||||
},
|
||||
});
|
||||
|
||||
return state(definition, created);
|
||||
}
|
||||
|
||||
async function list(companyId: string) {
|
||||
await ensureCompany(companyId);
|
||||
return Promise.all(DEFINITIONS.map(async (definition) => state(definition, await findSingleAgent(companyId, definition))));
|
||||
}
|
||||
|
||||
async function reconcileDefinitionDefaults(companyId: string, key: string) {
|
||||
const definition = requireBuiltInAgentDefinition(key);
|
||||
await ensureCompany(companyId);
|
||||
const existing = await findSingleAgent(companyId, definition);
|
||||
if (!existing) return state(definition, null);
|
||||
const patch = {
|
||||
name: definition.displayName,
|
||||
role: definition.defaultRole,
|
||||
title: null,
|
||||
capabilities: definition.shortPurpose,
|
||||
metadata: builtInMetadata(definition, existing.metadata),
|
||||
};
|
||||
const updated = await agentSvc.update(existing.id, patch, {
|
||||
allowBuiltInAgentMetadata: true,
|
||||
recordRevision: { source: "built-in-agent:reconcile-defaults" },
|
||||
});
|
||||
if (!updated) throw notFound("Built-in agent not found");
|
||||
return state(definition, updated as Agent);
|
||||
}
|
||||
|
||||
return {
|
||||
definitions: listBuiltInAgentDefinitions,
|
||||
get,
|
||||
ensure,
|
||||
list,
|
||||
reconcileDefinitionDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reconcileBuiltInAgentsOnStartup(db: Db) {
|
||||
const svc = builtInAgentService(db);
|
||||
const rows = await db
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
metadata: agents.metadata,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(ne(agents.status, "terminated"));
|
||||
const seen = new Set<string>();
|
||||
let scanned = 0;
|
||||
let reconciled = 0;
|
||||
let unknown = 0;
|
||||
let duplicates = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const marker = readBuiltInAgentMarker(row.metadata);
|
||||
if (!marker) continue;
|
||||
scanned += 1;
|
||||
if (!getBuiltInAgentDefinition(marker.key)) {
|
||||
unknown += 1;
|
||||
continue;
|
||||
}
|
||||
const instanceKey = `${row.companyId}:${marker.key}`;
|
||||
if (seen.has(instanceKey)) {
|
||||
duplicates += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(instanceKey);
|
||||
await svc.reconcileDefinitionDefaults(row.companyId, marker.key);
|
||||
reconciled += 1;
|
||||
}
|
||||
|
||||
return { scanned, reconciled, unknown, duplicates };
|
||||
}
|
||||
@@ -4,6 +4,17 @@ export { companySearchService } from "./company-search.js";
|
||||
export { feedbackService } from "./feedback.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export {
|
||||
builtInAgentService,
|
||||
deriveBuiltInAgentStatus,
|
||||
getBuiltInAgentDefinition,
|
||||
listBuiltInAgentDefinitions,
|
||||
reconcileBuiltInAgentsOnStartup,
|
||||
validateBuiltInAgentDefinitions,
|
||||
type BuiltInAgentDefinition,
|
||||
type BuiltInAgentState,
|
||||
type BuiltInAgentStatus,
|
||||
} from "./built-in-agents.js";
|
||||
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
|
||||
export { assetService } from "./assets.js";
|
||||
export { documentService, extractLegacyPlanBody } from "./documents.js";
|
||||
|
||||
Reference in new issue
Block a user