Files
paperclip/server/src/services/plugin-managed-agents.ts
T
Dotta 0096b56a1c [codex] Add LLM Wiki plugin host support (#5597)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - The plugin system needs host contracts and runtime support before
large plugins can integrate cleanly.
> - The source branch mixed the LLM Wiki package with supporting
host/runtime work, managed plugin skills, root-level storage spaces, and
a bookmarks reference plugin.
> - [PAP-9173](/PAP/issues/PAP-9173) asked for the current branch to be
split by file boundary: plugin package separately from everything else.
> - [PAP-9188](/PAP/issues/PAP-9188) clarified that LLM Wiki may have
plugin-local spaces, but Paperclip core should not reorganize top-level
local storage into spaces.
> - Follow-up review clarified that the bookmarks example should not
ship in this PR either.
> - This pull request contains the
non-`packages/plugins/plugin-llm-wiki/` host/runtime work, keeps runtime
state under the selected Paperclip instance root, and no longer includes
the bookmarks example.

## What Changed

- Added/updated plugin host contracts, SDK types, worker RPC plumbing,
managed plugin skill support, and related server tests.
- Removed the bookmarks example plugin package and its
bundled-example/workspace references.
- Removed the root-level local spaces CLI/migration surface and restored
instance-root runtime defaults for config, db, logs, storage, secrets,
workspaces, projects, and adapter homes.
- Replaced shared root `space-paths` helpers with `home-paths` helpers
for core runtime storage.
- Tightened stranded recovery unique-conflict detection so concurrent
recovery scans reuse the raced recovery issue when Postgres errors are
wrapped.
- Kept `packages/plugins/plugin-llm-wiki/` out of this PR diff;
plugin-local spaces remain in the stacked plugin-only PR.

## Verification

- `pnpm exec vitest run cli/src/__tests__/data-dir.test.ts
cli/src/__tests__/home-paths.test.ts cli/src/__tests__/onboard.test.ts
packages/shared/src/home-paths.test.ts
packages/db/src/runtime-config.test.ts
server/src/__tests__/agent-instructions-service.test.ts
server/src/__tests__/claude-local-execute.test.ts
server/src/__tests__/codex-local-execute.test.ts`
- `pnpm exec vitest run packages/db/src/runtime-config.test.ts`
- `pnpm exec vitest run
server/src/__tests__/plugin-routes-authz.test.ts`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run
server/src/__tests__/heartbeat-process-recovery.test.ts -t "reuses the
raced stranded recovery issue"` skipped locally because embedded
Postgres did not initialize on this macOS temp host; the code path was
typechecked and is covered by Linux CI.
- Boundary check: no core references remain for `PAPERCLIP_SPACE_ID`,
`spaces migrate-default`, `@paperclipai/shared/space-paths`,
`registerSpacesCommands`, or the removed bookmarks example.
- Previous PR head `4f23e034` had green GitHub checks: `verify`, all
four serialized server shards, `e2e`, `Canary Dry Run`, `policy`, Snyk,
and `Greptile Review`. Current head `582f466d` is re-running checks
after the bookmarks deletion.

## Risks

- Plugin host changes touch shared runtime paths, so regressions would
most likely appear in adapter startup, plugin loading, or local dev path
defaults.
- Removing the bookmarks example also removes one demonstration of
plugin database namespaces plus local-folder persistence; remaining
plugin examples still cover bundled example discovery and plugin host
flows.
- The plugin package itself is intentionally deferred to the stacked
plugin-only PR, where LLM Wiki plugin-local spaces live.
- Existing installs that tested the transient root-level spaces CLI
should stop using it; this PR intentionally removes that unsupported
migration surface before merge.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI GPT-5 Codex via Codex CLI, tool use and local code execution
enabled; context window not exposed.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass, except where noted above
for host-specific embedded Postgres initialization
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

Stacked follow-up: PR #5592 contains only
`packages/plugins/plugin-llm-wiki/` and targets this branch.

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-05-10 07:34:12 -05:00

563 lines
19 KiB
TypeScript

import { and, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
companies,
pluginEntities,
pluginManagedResources,
} from "@paperclipai/db";
import type {
Agent,
PaperclipPluginManifestV1,
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
} from "@paperclipai/shared";
import { notFound } from "../errors.js";
import { agentService } from "./agents.js";
import { approvalService } from "./approvals.js";
import { logActivity } from "./activity-log.js";
import { agentInstructionsService } from "./agent-instructions.js";
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
interface PluginManagedAgentServiceOptions {
pluginId: string;
pluginKey: string;
manifest?: PaperclipPluginManifestV1 | null;
instructionTemplateVariables?: (companyId: string) => Promise<Record<string, string | null | undefined>>;
}
function bindingExternalId(companyId: string, agentKey: string) {
return `managed:agent:${companyId}:${agentKey}`;
}
function managedMetadata(
pluginId: string,
pluginKey: string,
declaration: PluginManagedAgentDeclaration,
existing?: Record<string, unknown> | null,
) {
return {
...(existing ?? {}),
paperclipManagedResource: {
pluginId,
pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
},
pluginManagedAgent: {
pluginId,
pluginKey,
agentKey: declaration.agentKey,
displayName: declaration.displayName,
instructions: declaration.instructions ?? null,
},
};
}
function normalizeAdapterType(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function fallbackAdapterType(declaration: PluginManagedAgentDeclaration) {
return normalizeAdapterType(declaration.adapterType) ?? DEFAULT_MANAGED_AGENT_ADAPTER_TYPE;
}
function adapterPreference(declaration: PluginManagedAgentDeclaration) {
const seen = new Set<string>();
const preference: string[] = [];
for (const value of declaration.adapterPreference ?? []) {
const adapterType = normalizeAdapterType(value);
if (!adapterType || seen.has(adapterType)) continue;
seen.add(adapterType);
preference.push(adapterType);
}
return preference;
}
function selectPreferredAdapterType(
declaration: PluginManagedAgentDeclaration,
usage: Array<{ adapterType: string; count: number }>,
) {
const fallback = fallbackAdapterType(declaration);
const preference = adapterPreference(declaration);
if (preference.length === 0) return fallback;
const rank = new Map(preference.map((adapterType, index) => [adapterType, index]));
let selected: { adapterType: string; count: number; rank: number } | null = null;
for (const entry of usage) {
const adapterRank = rank.get(entry.adapterType);
if (adapterRank === undefined) continue;
if (
!selected ||
entry.count > selected.count ||
(entry.count === selected.count && adapterRank < selected.rank)
) {
selected = { ...entry, rank: adapterRank };
}
}
return selected?.adapterType ?? fallback;
}
function declarationPatch(declaration: PluginManagedAgentDeclaration, input: { adapterType?: string } = {}) {
return {
name: declaration.displayName,
role: declaration.role ?? "general",
title: declaration.title ?? null,
icon: declaration.icon ?? null,
capabilities: declaration.capabilities ?? null,
adapterType: input.adapterType ?? fallbackAdapterType(declaration),
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
permissions: declaration.permissions ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
};
}
function applyInstructionTemplateVariables(
content: string,
variables: Record<string, string | null | undefined>,
) {
let next = content;
for (const [key, value] of Object.entries(variables)) {
next = next.replaceAll(`{{${key}}}`, value?.trim() || "(not configured)");
}
return next;
}
function declaredInstructionFiles(
declaration: PluginManagedAgentDeclaration,
variables: Record<string, string | null | undefined>,
) {
const instructionDeclaration = declaration.instructions;
if (!instructionDeclaration?.content && !instructionDeclaration?.files) return null;
const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md";
const files = { ...(instructionDeclaration.files ?? {}) };
if (instructionDeclaration.content !== undefined) {
files[entryFile] = instructionDeclaration.content;
}
if (files[entryFile] === undefined) {
files[entryFile] = "";
}
return {
entryFile,
files: Object.fromEntries(
Object.entries(files).map(([filePath, content]) => [
filePath,
applyInstructionTemplateVariables(content, variables),
]),
),
};
}
function rowIsManagedAgent(
row: typeof agents.$inferSelect,
pluginKey: string,
agentKey: string,
) {
const metadata = row.metadata;
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false;
const marker = (metadata as Record<string, unknown>).paperclipManagedResource;
if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false;
const record = marker as Record<string, unknown>;
return (
record.pluginKey === pluginKey
&& record.resourceKind === "agent"
&& record.resourceKey === agentKey
);
}
export function pluginManagedAgentService(
db: Db,
options: PluginManagedAgentServiceOptions,
) {
const agentSvc = agentService(db);
const approvalSvc = approvalService(db);
const instructions = agentInstructionsService();
function declarationFor(agentKey: string) {
const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey);
if (!declaration) {
throw notFound(`Managed agent declaration not found: ${agentKey}`);
}
return declaration;
}
async function getBinding(companyId: string, agentKey: string) {
return db
.select()
.from(pluginEntities)
.where(
and(
eq(pluginEntities.pluginId, options.pluginId),
eq(pluginEntities.entityType, MANAGED_AGENT_ENTITY_TYPE),
eq(pluginEntities.externalId, bindingExternalId(companyId, agentKey)),
),
)
.then((rows) => rows[0] ?? null);
}
async function upsertBinding(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agentId: string,
extraData: Record<string, unknown> = {},
effectiveAdapterType?: string,
) {
const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration));
const defaultsJson = {
agentKey: declaration.agentKey,
displayName: declaration.displayName,
role: declaration.role ?? "general",
title: declaration.title ?? null,
icon: declaration.icon ?? null,
capabilities: declaration.capabilities ?? null,
adapterType,
adapterPreference: declaration.adapterPreference ?? null,
adapterConfig: declaration.adapterConfig ?? {},
runtimeConfig: declaration.runtimeConfig ?? {},
permissions: declaration.permissions ?? {},
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
instructions: declaration.instructions ?? null,
};
const managedResource = await db
.select({ id: pluginManagedResources.id })
.from(pluginManagedResources)
.where(and(
eq(pluginManagedResources.companyId, companyId),
eq(pluginManagedResources.pluginId, options.pluginId),
eq(pluginManagedResources.resourceKind, "agent"),
eq(pluginManagedResources.resourceKey, declaration.agentKey),
))
.then((rows) => rows[0] ?? null);
if (managedResource) {
await db
.update(pluginManagedResources)
.set({ resourceId: agentId, defaultsJson, updatedAt: new Date() })
.where(eq(pluginManagedResources.id, managedResource.id));
} else {
await db.insert(pluginManagedResources).values({
companyId,
pluginId: options.pluginId,
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
resourceId: agentId,
defaultsJson,
});
}
const externalId = bindingExternalId(companyId, declaration.agentKey);
const data = {
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
agentId,
adapterType,
declarationSnapshot: declaration,
lastReconciledAt: new Date().toISOString(),
...extraData,
};
const existing = await getBinding(companyId, declaration.agentKey);
if (existing) {
return db
.update(pluginEntities)
.set({
scopeKind: "company",
scopeId: companyId,
title: declaration.displayName,
status: "resolved",
data,
updatedAt: new Date(),
})
.where(eq(pluginEntities.id, existing.id))
.returning()
.then((rows) => rows[0]);
}
return db
.insert(pluginEntities)
.values({
pluginId: options.pluginId,
entityType: MANAGED_AGENT_ENTITY_TYPE,
scopeKind: "company",
scopeId: companyId,
externalId,
title: declaration.displayName,
status: "resolved",
data,
})
.returning()
.then((rows) => rows[0]);
}
async function findRelinkCandidate(companyId: string, declaration: PluginManagedAgentDeclaration) {
const rows = await db
.select()
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null;
}
async function companyAdapterUsage(companyId: string) {
const rows = await db
.select({ adapterType: agents.adapterType })
.from(agents)
.where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated")));
const counts = new Map<string, number>();
for (const row of rows) {
const adapterType = normalizeAdapterType(row.adapterType);
if (!adapterType) continue;
counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1);
}
return [...counts.entries()]
.map(([adapterType, count]) => ({ adapterType, count }))
.sort((a, b) => b.count - a.count || a.adapterType.localeCompare(b.adapterType))
.slice(0, 10);
}
async function resolveManagedAdapterType(companyId: string, declaration: PluginManagedAgentDeclaration) {
return selectPreferredAdapterType(declaration, await companyAdapterUsage(companyId));
}
async function materializeDeclaredInstructions(
companyId: string,
agent: Agent,
declaration: PluginManagedAgentDeclaration,
materializeOptions: { replaceExisting: boolean },
): Promise<Agent> {
const variables = await optionsForInstructionVariables(companyId);
const declared = declaredInstructionFiles(declaration, variables);
if (!declared) return agent;
const materialized = await instructions.materializeManagedBundle(
agent,
declared.files,
{
entryFile: declared.entryFile,
replaceExisting: materializeOptions.replaceExisting,
clearLegacyPromptTemplate: true,
},
);
const updated = await agentSvc.update(agent.id, {
adapterConfig: materialized.adapterConfig,
}, {
recordRevision: {
source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`,
},
});
return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
}
async function managedInstructionDefaultDrift(
companyId: string,
agent: Agent | null,
declaration: PluginManagedAgentDeclaration,
): Promise<PluginManagedAgentResolution["defaultDrift"]> {
if (!agent) return null;
const variables = await optionsForInstructionVariables(companyId);
const declared = declaredInstructionFiles(declaration, variables);
if (!declared) return null;
let exported: Awaited<ReturnType<typeof instructions.exportFiles>>;
try {
exported = await instructions.exportFiles(agent);
} catch {
return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] };
}
const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]);
const changedFiles = [...paths]
.filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null))
.sort((left, right) => left.localeCompare(right));
if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {
changedFiles.unshift(declared.entryFile);
}
return changedFiles.length > 0 ? { entryFile: declared.entryFile, changedFiles } : null;
}
async function optionsForInstructionVariables(companyId: string) {
return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {};
}
function optionsForRevisionSource() {
return options.pluginKey;
}
async function resolution(
companyId: string,
declaration: PluginManagedAgentDeclaration,
agent: Agent | null,
status: PluginManagedAgentResolution["status"],
approvalId?: string | null,
): Promise<PluginManagedAgentResolution> {
return {
pluginKey: options.pluginKey,
resourceKind: "agent",
resourceKey: declaration.agentKey,
companyId,
agentId: agent?.id ?? null,
agent,
status,
approvalId: approvalId ?? null,
defaultDrift: await managedInstructionDefaultDrift(companyId, agent, declaration),
};
}
async function createManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration) {
const company = await db
.select()
.from(companies)
.where(eq(companies.id, companyId))
.then((rows) => rows[0] ?? null);
if (!company) throw notFound("Company not found");
const requiresApproval = company.requireBoardApprovalForNewAgents;
const adapterType = await resolveManagedAdapterType(companyId, declaration);
let created = await agentSvc.create(companyId, {
...declarationPatch(declaration, { adapterType }),
status: requiresApproval ? "pending_approval" : declaration.status ?? "idle",
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration),
spentMonthlyCents: 0,
lastHeartbeatAt: null,
}) as Agent;
created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true });
let approvalId: string | null = null;
if (requiresApproval) {
const approval = await approvalSvc.create(companyId, {
type: "hire_agent",
requestedByAgentId: null,
requestedByUserId: null,
status: "pending",
payload: {
name: created.name,
role: created.role,
title: created.title,
icon: created.icon,
reportsTo: created.reportsTo,
capabilities: created.capabilities,
adapterType: created.adapterType,
adapterConfig: created.adapterConfig,
runtimeConfig: created.runtimeConfig,
budgetMonthlyCents: created.budgetMonthlyCents,
metadata: created.metadata,
agentId: created.id,
sourcePluginId: options.pluginId,
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
updatedAt: new Date(),
});
approvalId = approval.id;
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "approval.created",
entityType: "approval",
entityId: approval.id,
details: {
type: "hire_agent",
linkedAgentId: created.id,
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
});
}
await upsertBinding(companyId, declaration, created.id, { approvalId }, adapterType);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_agent.created",
entityType: "agent",
entityId: created.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
adapterType,
requiresApproval,
approvalId,
},
});
return resolution(companyId, declaration, created as Agent, "created", approvalId);
}
async function get(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const binding = await getBinding(companyId, agentKey);
const boundAgentId = typeof binding?.data?.agentId === "string" ? binding.data.agentId : null;
if (!boundAgentId) return resolution(companyId, declaration, null, "missing");
const agent = await agentSvc.getById(boundAgentId);
if (!agent || agent.companyId !== companyId || agent.status === "terminated") {
return resolution(companyId, declaration, null, "missing");
}
return resolution(companyId, declaration, agent as Agent, "resolved");
}
async function reconcile(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const current = await get(agentKey, companyId);
if (current.agent) {
await upsertBinding(companyId, declaration, current.agent.id);
return current;
}
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
if (relinkCandidate) {
await upsertBinding(companyId, declaration, relinkCandidate.id);
const agent = await agentSvc.getById(relinkCandidate.id);
return resolution(companyId, declaration, agent as Agent, "relinked");
}
return createManagedAgent(companyId, declaration);
}
async function reset(agentKey: string, companyId: string) {
const declaration = declarationFor(agentKey);
const reconciled = await reconcile(agentKey, companyId);
if (!reconciled.agent) return reconciled;
const currentMetadata = reconciled.agent.metadata && typeof reconciled.agent.metadata === "object"
? reconciled.agent.metadata
: {};
const adapterType = await resolveManagedAdapterType(companyId, declaration);
const updated = await agentSvc.update(reconciled.agent.id, {
...declarationPatch(declaration, { adapterType }),
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
}, {
recordRevision: {
source: `plugin:${options.pluginKey}:managed-agent-reset`,
},
});
if (!updated) throw notFound("Managed agent not found");
const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true });
await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType);
await logActivity(db, {
companyId,
actorType: "plugin",
actorId: options.pluginId,
action: "plugin.managed_agent.reset",
entityType: "agent",
entityId: updatedAgent.id,
details: {
sourcePluginKey: options.pluginKey,
managedResourceKey: declaration.agentKey,
},
});
return resolution(companyId, declaration, updatedAgent, "reset");
}
return {
get,
reconcile,
reset,
};
}