feat(server): CEO agents get the core paperclip skills by default (#12138)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Each agent's runtime only receives skills listed in its own
desired-skill set; the company library alone does nothing for an agent
> - Every CEO creation path (first-run wizard hire, New Agent
first-agent flow, cloud onboarding seed) creates the CEO with an empty
desired-skill set
> - The default CEO instructions tell the agent to use the core
paperclip skills, so a fresh CEO contradicts its own instructions and
reports its toolkit as "not installed"
> - This pull request unions the core skill keys into every
skills-capable CEO hire/create and into the onboarding-seeded CEO's
adapter config
> - The benefit is that a new CEO can actually do what its instructions
describe, and stops telling users that installed skills do not exist
## Linked Issues or Issue Description
**What existing behavior does this improve?**
Creating the first lead agent (role `ceo`) via hire, create, or the
cloud onboarding seed.
**Subsystem affected**
Server — agent hire/create routes (`server/src/routes/agents.ts`),
onboarding seed (`server/src/services/onboarding-seed.ts`), company
skills service constant (`server/src/services/company-skills.ts`).
**Current behavior**
A CEO created by the wizard, the New Agent page, or the onboarding seed
has no `paperclipSkillSync` block. Its runtime mounts zero skills. Its
default instructions (`server/src/onboarding-assets/ceo/AGENTS.md`,
`HEARTBEAT.md`) tell it to use `paperclip-create-agent`,
`para-memory-files`, and the paperclip coordination skill. The agent
then reports these skills as not installed.
**Proposed behavior**
When the new agent's role is `ceo` and its adapter supports skill sync,
the hire and create routes union the five bundled
`paperclipai/paperclip/*` skill keys into the requested desired-skill
set. The onboarding seed writes the same preference into the seeded
CEO's adapter config. Explicit requests win over defaults for the same
key. Non-CEO agents are unchanged. Any default stays removable through
`POST /agents/:id/skills/sync`.
**Breaking changes**
None. The default is additive, applies only to role `ceo` on
skills-capable adapters, and the bundled skills are guaranteed present
in every company library by `ensureSkillInventoryCurrent`.
## What Changed
- New exported constant `PAPERCLIP_CORE_SKILL_KEYS` in
`server/src/services/company-skills.ts` (the five bundled
`paperclipai/paperclip/*` keys).
- `defaultRoleSkillSelections` + `withDefaultRoleSkillSelections`
helpers in `server/src/routes/agents.ts`, applied in both the hire and
create routes before `resolveDesiredSkillAssignment(..., "add")`.
- `server/src/services/onboarding-seed.ts` builds the seeded CEO's
adapter config with `writePaperclipSkillSyncPreference` instead of `{}`
when the seeded adapter supports skills.
## Verification
- `cd server && npx vitest run
src/__tests__/agent-skills-routes.test.ts` — 32 tests pass (three new:
CEO default set, union with a requested skill, non-CEO untouched).
- `cd server && npx vitest run
src/__tests__/onboarding-seed-route.test.ts` — 14 tests pass (seeded CEO
adapter config assertion added).
- `cd server && npx vitest run
src/__tests__/agent-permissions-routes.test.ts` — 54 tests pass.
- `cd server && pnpm run typecheck` — clean.
## Risks
- Existing CEOs are not modified; only newly created ones get the
defaults. An operator who wants a minimal CEO can remove the skills
after creation with the skills sync (mode `remove`), and that removal
sticks. Adapters without skill support are skipped, so the change is
inert there.
## Model Used
- Claude Fable 5 (`claude-fable-5`, Anthropic) with extended thinking
and tool use, via Claude Code.
## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
parent
d2b9765cc8
commit
5af49cb477
|
|
@ -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<string, unknown>;
|
||||
};
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<string | AgentDesiredSkillEntry> | 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({
|
||||
|
|
|
|||
|
|
@ -618,6 +618,23 @@ function readCanonicalSkillKey(frontmatter: Record<string, unknown>, 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<ImportedSkill, "slug" | "sourceType" | "sourceLocator" | "metadata">,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
||||
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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue