diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index 47379332d3..9e151fcfc2 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -1172,6 +1172,74 @@ describe.sequential("agent skill routes", () => { ); }); + it("gives a CEO hire the core paperclip skills when none are requested", async () => { + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "First Lead", + role: "ceo", + adapterType: "claude_local", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockAgentService.create).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + paperclipSkillSync: expect.objectContaining({ + desiredSkills: expect.arrayContaining([ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", + ]), + }), + }), + }), + expect.anything(), + ); + }); + + it("unions requested skills with the CEO defaults instead of replacing them", async () => { + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "First Lead", + role: "ceo", + adapterType: "claude_local", + desiredSkills: ["paperclip"], + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + const createInput = mockAgentService.create.mock.calls[0]?.[1] as { + adapterConfig: { paperclipSkillSync: { desiredSkills: string[] } }; + }; + const desired = createInput.adapterConfig.paperclipSkillSync.desiredSkills; + // "paperclip" resolves to its canonical key and dedupes with the default. + expect(desired).toHaveLength(5); + expect(desired).toContain("paperclipai/paperclip/paperclip"); + }); + + it("does not add default skills to non-CEO hires", async () => { + const res = await request(await createApp(createDb(true))) + .post("/api/companies/company-1/agent-hires") + .send({ + name: "QA Agent", + role: "engineer", + adapterType: "claude_local", + adapterConfig: {}, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + const createInput = mockAgentService.create.mock.calls[0]?.[1] as { + adapterConfig: Record; + }; + expect(createInput.adapterConfig.paperclipSkillSync).toBeUndefined(); + }); + it("rejects version pins in agent hires while beta skills are disabled", async () => { const res = await request(await createApp(createDb(true))) .post("/api/companies/company-1/agent-hires") diff --git a/server/src/__tests__/onboarding-seed-route.test.ts b/server/src/__tests__/onboarding-seed-route.test.ts index 16059d91dd..384066e545 100644 --- a/server/src/__tests__/onboarding-seed-route.test.ts +++ b/server/src/__tests__/onboarding-seed-route.test.ts @@ -85,6 +85,20 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () => // The seed's free-text role is a job title; the structural role stays `ceo`. expect(companyAgents[0]?.title).toBe("Chief of Staff"); expect(companyAgents[0]?.role).toBe("ceo"); + // A seeded CEO arrives with the core paperclip skills enabled. Skills only + // reach an agent's runtime through its own desired set, and the default + // CEO instructions assume this toolkit. + expect(companyAgents[0]?.adapterConfig).toMatchObject({ + paperclipSkillSync: { + desiredSkills: expect.arrayContaining([ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", + ]), + }, + }); const companyIssues = await ctx.db.select().from(issues).where(eq(issues.companyId, companyId)); expect(companyIssues).toHaveLength(1); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 5b290d4152..d4dd14b6fc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -60,6 +60,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { PAPERCLIP_CORE_SKILL_KEYS } from "../services/company-skills.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js"; @@ -2213,6 +2214,33 @@ export function agentRoutes( }; } + // The default CEO instructions assume the core paperclip skills (board + // coordination, planning, hiring, memory). Union them into every + // skills-capable CEO hire/create so a fresh CEO never starts with an empty + // desired-skill set that contradicts its own instructions. Callers can still + // remove any of them afterwards via the per-agent skills sync. + function defaultRoleSkillSelections( + role: string | null | undefined, + adapterType: string, + ): AgentDesiredSkillEntry[] | undefined { + if (role !== "ceo") return undefined; + const adapter = findActiveServerAdapter(adapterType); + if (!adapter?.listSkills && !adapter?.syncSkills) return undefined; + return PAPERCLIP_CORE_SKILL_KEYS.map((key) => ({ key, versionId: null })); + } + + function withDefaultRoleSkillSelections( + requested: AgentDesiredSkillEntry[] | undefined, + defaults: AgentDesiredSkillEntry[] | undefined, + ): AgentDesiredSkillEntry[] | undefined { + if (!defaults) return requested; + if (!requested) return defaults; + const merged = new Map(defaults.map((entry) => [entry.key, entry])); + // An explicit request wins over a default for the same key (version pins). + for (const entry of requested) merged.set(entry.key, entry); + return Array.from(merged.values()); + } + function normalizeDesiredSkillSelections( requestedDesiredSkills: Array | undefined, ): AgentDesiredSkillEntry[] | undefined { @@ -3335,7 +3363,10 @@ export function agentRoutes( companyId, hireInput.adapterType, requestedAdapterConfig, - normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + withDefaultRoleSkillSelections( + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + defaultRoleSkillSelections(hireInput.role, hireInput.adapterType), + ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ @@ -3551,7 +3582,10 @@ export function agentRoutes( companyId, createInput.adapterType, requestedAdapterConfig, - normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + withDefaultRoleSkillSelections( + normalizeDesiredSkillSelections(Array.isArray(requestedDesiredSkills) ? requestedDesiredSkills : undefined), + defaultRoleSkillSelections(createInput.role, createInput.adapterType), + ), "add", ); const normalizedAdapterConfig = await normalizeMediatedAdapterConfigForPersistence({ diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 22348d8764..cfa474d477 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -618,6 +618,23 @@ function readCanonicalSkillKey(frontmatter: Record, metadata: R ); } +/** + * The bundled operating skills the default agent instructions assume every + * lead agent has (coordination, board usage, planning, hiring, memory). A + * seeded or hired CEO must arrive with these enabled: an agent's runtime only + * receives skills in its own desired set, so a CEO with an empty set + * truthfully reports these as not installed while its instructions tell it to + * use them. Mirrors the repo-root `skills/` bundle that + * `ensureSkillInventoryCurrent` imports into every company library. + */ +export const PAPERCLIP_CORE_SKILL_KEYS = [ + "paperclipai/paperclip/paperclip", + "paperclipai/paperclip/paperclip-board", + "paperclipai/paperclip/paperclip-converting-plans-to-tasks", + "paperclipai/paperclip/paperclip-create-agent", + "paperclipai/paperclip/para-memory-files", +] as const; + function deriveCanonicalSkillKey( companyId: string, input: Pick, diff --git a/server/src/services/onboarding-seed.ts b/server/src/services/onboarding-seed.ts index eba8a0d143..ae7b7fcb3a 100644 --- a/server/src/services/onboarding-seed.ts +++ b/server/src/services/onboarding-seed.ts @@ -2,7 +2,10 @@ import { and, eq, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companyOnboardingSeeds, goals, issues, projects } from "@paperclipai/db"; import type { ApplyOnboardingSeed } from "@paperclipai/shared"; +import { writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils"; +import { findActiveServerAdapter } from "../adapters/registry.js"; import { agentService } from "./agents.js"; +import { PAPERCLIP_CORE_SKILL_KEYS } from "./company-skills.js"; import { goalService } from "./goals.js"; import { projectService } from "./projects.js"; import { issueService } from "./issues.js"; @@ -36,6 +39,23 @@ function seededAgentAdapterType() { || FALLBACK_SEEDED_AGENT_ADAPTER_TYPE; } +/** + * Adapter config for the seeded CEO. The default CEO instructions tell the + * agent to use the core paperclip skills (hiring, memory, coordination), and + * an agent's runtime only receives skills listed in its own desired set — so + * a seeded CEO with an empty adapter config arrives with zero skills and + * truthfully reports its own toolkit as not installed. Enable the core set + * whenever the seeded adapter supports skill sync. + */ +function seededAgentAdapterConfig(adapterType: string): Record { + const adapter = findActiveServerAdapter(adapterType); + if (!adapter?.listSkills && !adapter?.syncSkills) return {}; + return writePaperclipSkillSyncPreference( + {}, + PAPERCLIP_CORE_SKILL_KEYS.map((key) => ({ key, versionId: null })), + ); +} + /** * Split a free-text mission into a goal title + description the same way the * first-run wizard's `parseOnboardingGoalInput` does: first line is the title, @@ -223,7 +243,7 @@ export function onboardingSeedService(db: Db) { role: SEEDED_AGENT_ROLE, title: agentRole, adapterType: seededAgentAdapterType(), - adapterConfig: {}, + adapterConfig: seededAgentAdapterConfig(seededAgentAdapterType()), runtimeConfig: {}, permissions: {}, status: "idle",