Add Reflection Coach routine controls

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-09 20:50:34 +00:00
1 parent f2b71590cc
commit 67db22407a
12 files changed
+862 -32

No files matched your search

@@ -98,7 +98,17 @@ function registerModuleMocks() {
}));
}
async function createApp() {
function boardActor() {
return {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
}
async function createApp(actor: Record<string, unknown> = boardActor()) {
const [{ agentRoutes }, { errorHandler }] = await Promise.all([
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
@@ -106,13 +116,7 @@ async function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
(req as any).actor = actor;
next();
});
app.use("/api", agentRoutes({} as any));
@@ -166,6 +170,21 @@ function makeAgent() {
};
}
function makeReflectionCoachAgent(overrides: Record<string, unknown> = {}) {
return {
...makeAgent(),
id: "22222222-2222-4222-8222-222222222222",
name: "Reflection Coach",
metadata: {
paperclipBuiltInAgent: {
key: "reflection-coach",
featureKeys: ["reflection-coach"],
},
},
...overrides,
};
}
describe("agent instructions bundle routes", () => {
beforeEach(() => {
vi.resetModules();
@@ -259,6 +278,79 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
});
it("denies non-privileged agents from reading peer instructions bundles", async () => {
mockAgentService.getById.mockImplementation(async (id: string) => {
if (id === "agent-reader") {
return {
...makeAgent(),
id: "agent-reader",
name: "Reader",
permissions: { canCreateAgents: false },
};
}
return makeAgent();
});
mockAccessService.hasPermission.mockResolvedValue(false);
const res = await requestApp(
await createApp({
type: "agent",
agentId: "agent-reader",
companyId: "company-1",
source: "agent_key",
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Missing permission");
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
});
it("allows agents to read their own instructions bundles", async () => {
const res = await requestApp(
await createApp({
type: "agent",
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
source: "agent_key",
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
});
it("allows the built-in Reflection Coach to read peer instructions bundles", async () => {
mockAgentService.getById.mockImplementation(async (id: string) => {
if (id === "coach-agent") {
return makeReflectionCoachAgent({ id: "coach-agent" });
}
return makeAgent();
});
const res = await requestApp(
await createApp({
type: "agent",
agentId: "coach-agent",
companyId: "company-1",
source: "agent_key",
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
.query({ path: "AGENTS.md" }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
"AGENTS.md",
);
});
it("writes a bundle file and persists compatibility config", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
@@ -7,6 +7,7 @@ const agentId = "11111111-1111-4111-8111-111111111111";
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
canUser: vi.fn(),
}));
const mockInstanceSettingsService = vi.hoisted(() => ({
@@ -15,9 +16,13 @@ const mockInstanceSettingsService = vi.hoisted(() => ({
const mockBuiltInAgentService = vi.hoisted(() => ({
list: vi.fn(),
get: vi.fn(),
ensure: vi.fn(),
provision: vi.fn(),
reset: vi.fn(),
enableRoutineSchedule: vi.fn(),
disableRoutineSchedule: vi.fn(),
runRoutine: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -100,11 +105,16 @@ describe("built-in agent routes", () => {
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue(allowDecision());
mockAccessService.canUser.mockResolvedValue(true);
mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableBuiltInAgents: true });
mockBuiltInAgentService.list.mockResolvedValue([builtInState()]);
mockBuiltInAgentService.get.mockResolvedValue(builtInState());
mockBuiltInAgentService.ensure.mockResolvedValue(builtInState());
mockBuiltInAgentService.provision.mockResolvedValue({ state: builtInState(), approval: null });
mockBuiltInAgentService.reset.mockResolvedValue(builtInState());
mockBuiltInAgentService.enableRoutineSchedule.mockResolvedValue(builtInState());
mockBuiltInAgentService.disableRoutineSchedule.mockResolvedValue(builtInState());
mockBuiltInAgentService.runRoutine.mockResolvedValue({ id: "routine-run-1", source: "manual", status: "queued" });
});
it("lists built-in agent state for actors with company access", async () => {
@@ -232,6 +242,120 @@ describe("built-in agent routes", () => {
expect(mockBuiltInAgentService.provision).not.toHaveBeenCalled();
});
it("enables a built-in routine schedule through the board tasks:assign gate", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/enable`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAccessService.canUser).toHaveBeenCalledWith(companyId, "board-user", "tasks:assign");
expect(mockBuiltInAgentService.enableRoutineSchedule).toHaveBeenCalledWith(
companyId,
"reflection-coach",
"recent-agent-reflection",
{ agentId: null, userId: "board-user", runId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "built_in_agent.routine_schedule_enabled",
entityId: agentId,
details: expect.objectContaining({ routineKey: "recent-agent-reflection" }),
}));
});
it("disables a built-in routine schedule", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/disable`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockBuiltInAgentService.disableRoutineSchedule).toHaveBeenCalledWith(
companyId,
"reflection-coach",
"recent-agent-reflection",
{ agentId: null, userId: "board-user", runId: null },
);
});
it("triggers a built-in routine manual run", async () => {
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(res.body).toMatchObject({ id: "routine-run-1", source: "manual" });
expect(mockBuiltInAgentService.runRoutine).toHaveBeenCalledWith(
companyId,
"reflection-coach",
"recent-agent-reflection",
{ agentId: null, userId: "board-user", runId: null },
);
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
action: "built_in_agent.routine_run_triggered",
details: expect.objectContaining({ routineRunId: "routine-run-1" }),
}));
});
it("denies built-in routine controls when tasks:assign is not allowed", async () => {
mockAccessService.canUser.mockResolvedValue(false);
const app = await createApp({
type: "board",
userId: "board-user",
companyIds: [companyId],
source: "session",
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
});
it("denies agent actors from controlling built-in routines", async () => {
const app = await createApp({
type: "agent",
agentId: "manager-agent",
companyId,
source: "agent_key",
runId: "55555555-5555-4555-8555-555555555555",
});
const res = await request(app)
.post(`/api/companies/${companyId}/built-in-agents/reflection-coach/routines/recent-agent-reflection/run`)
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Only board operators can control built-in routines.");
expect(mockAccessService.canUser).not.toHaveBeenCalled();
expect(mockBuiltInAgentService.runRoutine).not.toHaveBeenCalled();
});
it("returns pending hire approvals instead of provisioning immediately when company policy requires it", async () => {
const approval = {
id: "approval-1",
@@ -288,7 +412,7 @@ describe("built-in agent routes", () => {
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs");
expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {});
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "agent",
+110 -3
View File
@@ -12,6 +12,8 @@ import {
companySkillVersions,
companySkills,
createDb,
issueThreadInteractions,
issues,
principalPermissionGrants,
routines,
routineTriggers,
@@ -59,6 +61,8 @@ describeEmbeddedPostgres("built-in agents", () => {
afterEach(async () => {
await db.delete(routineTriggers);
await db.delete(routines);
await db.delete(issueThreadInteractions);
await db.delete(issues);
await db.delete(builtInManagedResources);
await db.delete(companySkillVersions);
await db.delete(companySkills);
@@ -444,11 +448,14 @@ describeEmbeddedPostgres("built-in agents", () => {
instructionsEntryFile: "AGENTS.md",
});
expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus]).slice(0, 2)).toEqual([
["instructions", "stock_current"],
["skill", "stock_current"],
["routine", "stock_current"],
]);
expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
resourceId: expect.any(String),
scheduleEnabled: false,
});
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
@@ -708,6 +715,106 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(grants.map((grant) => grant.permissionKey)).not.toContain("tasks:assign");
});
it("controls the Reflection Coach routine schedule without enabling it by default", async () => {
const companyId = await seedCompany();
await agentService(db).create(companyId, {
name: "CEO",
role: "ceo",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
});
const builtIns = builtInAgentService(db);
const created = await builtIns.ensure(companyId, "reflection-coach");
expect(created.status).toBe("paused");
expect(created.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
scheduleEnabled: false,
});
const enabled = await builtIns.enableRoutineSchedule(
companyId,
"reflection-coach",
"recent-agent-reflection",
{ userId: "board-user" },
);
expect(enabled.status).toBe("needs_setup");
expect(enabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
scheduleEnabled: true,
});
const [enabledRoutine] = await db.select().from(routines).where(eq(routines.companyId, companyId));
const [enabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, enabledRoutine!.id));
expect(enabledRoutine).toMatchObject({ status: "active" });
expect(enabledTrigger).toMatchObject({ enabled: true });
const disabled = await builtIns.disableRoutineSchedule(
companyId,
"reflection-coach",
"recent-agent-reflection",
{ userId: "board-user" },
);
expect(disabled.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
scheduleEnabled: false,
});
const [disabledRoutine] = await db.select().from(routines).where(eq(routines.id, enabledRoutine!.id));
const [disabledTrigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.id, enabledTrigger!.id));
expect(disabledRoutine).toMatchObject({ status: "paused" });
expect(disabledTrigger).toMatchObject({ enabled: false });
});
it("surfaces pending Reflection Coach proposal interactions on the routine resource", async () => {
const companyId = await seedCompany();
await agentService(db).create(companyId, {
name: "CEO",
role: "ceo",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
});
const created = await builtInAgentService(db).ensure(companyId, "reflection-coach");
const proposalIssueId = randomUUID();
await db.insert(issues).values({
id: proposalIssueId,
companyId,
title: "Review Reflection Coach proposal",
status: "in_review",
priority: "medium",
identifier: `${issuePrefix(companyId)}-42`,
issueNumber: 42,
assigneeAgentId: created.agentId,
createdByAgentId: created.agentId,
});
const interactionId = randomUUID();
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId: proposalIssueId,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
title: "Review proposed coaching change",
summary: "Accept or reject the proposed update.",
createdByAgentId: created.agentId,
payload: {
version: 1,
prompt: "Accept the proposed coaching change?",
acceptLabel: "Accept",
rejectLabel: "Reject",
},
});
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state.resources.find((resource) => resource.resourceKind === "routine")).toMatchObject({
pendingUpdateInteractionId: interactionId,
pendingUpdateIssueId: proposalIssueId,
pendingUpdateIssueIdentifier: `${issuePrefix(companyId)}-42`,
});
});
it("preserves Reflection Coach stock drift until explicit reset", async () => {
const companyId = await seedCompany();
const created = await builtInAgentService(db).ensure(companyId, "reflection-coach");
@@ -742,6 +849,6 @@ describeEmbeddedPostgres("built-in agents", () => {
stockStatus: "operator_modified",
resetAvailable: true,
});
expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("built-in Reflection Coach");
expect((await instructionsSvc.readFile(reset.agent!, "AGENTS.md")).content).toContain("You are Reflection Coach");
});
});
+18 -1
View File
@@ -99,6 +99,7 @@ import { recoveryService } from "../services/recovery/service.js";
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
import { readObject } from "../lib/objects.js";
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@@ -832,7 +833,7 @@ export function agentRoutes(
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
async function assertCanReadAgent(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") {
await assertCanReadConfigurations(req, targetAgent.companyId);
@@ -844,6 +845,22 @@ export function agentRoutes(
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.id === targetAgent.id) return;
const builtInMarker = readBuiltInAgentMarker(actorAgent.metadata);
if (builtInMarker?.key === "reflection-coach") {
return;
}
const allowedByGrant = await access.hasPermission(
targetAgent.companyId,
"agent",
actorAgent.id,
"agents:create",
);
if (allowedByGrant || canCreateAgents(actorAgent)) return;
throw forbidden("Missing permission: can create agents");
}
function assertKnownAdapterType(type: string | null | undefined): string {
+135 -3
View File
@@ -9,6 +9,26 @@ import { authorizationDeniedDetails } from "../services/authorization.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import type { BuiltInAgentState } from "../services/built-in-agents.js";
const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function formatScheduleLabel(trigger: { cronExpression: string; timezone: string } | undefined) {
if (!trigger) return "Weekly schedule";
const parts = trigger.cronExpression.trim().split(/\s+/);
const [minute, hour, , , dayOfWeek] = parts;
const weekdayIndex = dayOfWeek ? Number(dayOfWeek) : Number.NaN;
if (
parts.length === 5
&& /^\d+$/.test(minute ?? "")
&& /^\d+$/.test(hour ?? "")
&& Number.isInteger(weekdayIndex)
&& weekdayIndex >= 0
&& weekdayIndex < WEEKDAY_LABELS.length
) {
return `Weekly · ${WEEKDAY_LABELS[weekdayIndex]} ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${trigger.timezone}`;
}
return `Weekly · ${trigger.timezone}`;
}
function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentState {
const definition = {
...state.definition,
@@ -32,6 +52,7 @@ function redactBuiltInAgentListState(state: BuiltInAgentState): BuiltInAgentStat
title: state.definition.bundle.routine.title,
status: state.definition.bundle.routine.status,
triggerCount: state.definition.bundle.routine.triggers.length,
scheduleLabel: formatScheduleLabel(state.definition.bundle.routine.triggers[0]),
},
}
: undefined,
@@ -72,15 +93,36 @@ export function builtInAgentRoutes(db: Db) {
throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
}
async function assertCanControlBuiltInRoutine(req: Request, companyId: string) {
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board") {
throw forbidden("Only board operators can control built-in routines.");
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
if (!allowed) {
throw forbidden("Missing permission: tasks:assign");
}
}
async function logBuiltInAgentMutation(
req: Request,
input: {
companyId: string;
action: "built_in_agent.provision_requested" | "built_in_agent.reconcile" | "built_in_agent.reset" | "approval.created";
input: {
companyId: string;
action:
| "built_in_agent.provision_requested"
| "built_in_agent.reconcile"
| "built_in_agent.reset"
| "built_in_agent.routine_schedule_enabled"
| "built_in_agent.routine_schedule_disabled"
| "built_in_agent.routine_run_triggered"
| "approval.created";
key: string;
agentId: string | null;
status: string;
approvalId?: string | null;
routineKey?: string | null;
routineRunId?: string | null;
},
) {
const actor = getActorInfo(req);
@@ -97,6 +139,8 @@ export function builtInAgentRoutes(db: Db) {
key: input.key,
status: input.status,
approvalId: input.approvalId ?? null,
routineKey: input.routineKey ?? null,
routineRunId: input.routineRunId ?? null,
},
});
}
@@ -184,5 +228,93 @@ export function builtInAgentRoutes(db: Db) {
res.json(redactBuiltInAgentListState(state));
});
router.post(
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/enable",
validate(builtInAgentEmptyMutationSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const key = req.params.key as string;
const routineKey = req.params.routineKey as string;
await assertBuiltInAgentsEnabled();
assertCompanyAccess(req, companyId);
const current = await svc.get(companyId, key);
await assertCanControlBuiltInRoutine(req, companyId);
const actor = getActorInfo(req);
const state = await svc.enableRoutineSchedule(companyId, key, routineKey, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
});
await logBuiltInAgentMutation(req, {
companyId,
action: "built_in_agent.routine_schedule_enabled",
key,
agentId: state.agentId,
status: state.status,
routineKey,
});
res.json(redactBuiltInAgentListState(state));
},
);
router.post(
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/disable",
validate(builtInAgentEmptyMutationSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const key = req.params.key as string;
const routineKey = req.params.routineKey as string;
await assertBuiltInAgentsEnabled();
assertCompanyAccess(req, companyId);
const current = await svc.get(companyId, key);
await assertCanControlBuiltInRoutine(req, companyId);
const actor = getActorInfo(req);
const state = await svc.disableRoutineSchedule(companyId, key, routineKey, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
});
await logBuiltInAgentMutation(req, {
companyId,
action: "built_in_agent.routine_schedule_disabled",
key,
agentId: state.agentId,
status: state.status,
routineKey,
});
res.json(redactBuiltInAgentListState(state));
},
);
router.post(
"/companies/:companyId/built-in-agents/:key/routines/:routineKey/run",
validate(builtInAgentEmptyMutationSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
const key = req.params.key as string;
const routineKey = req.params.routineKey as string;
await assertBuiltInAgentsEnabled();
assertCompanyAccess(req, companyId);
const current = await svc.get(companyId, key);
await assertCanControlBuiltInRoutine(req, companyId);
const actor = getActorInfo(req);
const run = await svc.runRoutine(companyId, key, routineKey, {
agentId: actor.actorType === "agent" ? actor.actorId : null,
userId: actor.actorType === "user" ? actor.actorId : null,
runId: actor.runId ?? null,
});
await logBuiltInAgentMutation(req, {
companyId,
action: "built_in_agent.routine_run_triggered",
key,
agentId: current.agentId,
status: current.status,
routineKey,
routineRunId: run.id,
});
res.status(202).json(run);
},
);
return router;
}
+27
View File
@@ -10,6 +10,7 @@ import {
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
createAgentKeySchema,
builtInAgentEmptyMutationSchema,
builtInAgentProvisionSchema,
wakeAgentSchema,
resetAgentSessionSchema,
@@ -1189,6 +1190,32 @@ registry.registerPath({
},
});
for (const route of [
["enable", "Enable a built-in routine schedule", 200],
["disable", "Disable a built-in routine schedule", 200],
["run", "Run a built-in routine once", 202],
] as const) {
registry.registerPath({
method: "post",
path: `/api/companies/{companyId}/built-in-agents/{key}/routines/{routineKey}/${route[0]}`,
tags: ["agents"],
summary: route[1],
request: {
params: z.object({ companyId: z.string(), key: z.string(), routineKey: z.string() }),
body: jsonBody(builtInAgentEmptyMutationSchema),
},
responses: {
[route[2]]: r.ok(),
400: r.badRequest,
401: r.unauthorized,
403: r.forbidden,
404: r.notFound,
409: r.conflict,
422: r.unprocessable,
},
});
}
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/agents",
+138 -7
View File
@@ -3,9 +3,9 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { and, eq, ne } from "drizzle-orm";
import { and, desc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, builtInManagedResources, companies, routines, routineTriggers } from "@paperclipai/db";
import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
import type { Agent, Approval, CompanySkill, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
@@ -82,6 +82,10 @@ export interface BuiltInManagedResourceState {
updateAvailable: boolean;
resetAvailable: boolean;
changedFiles?: string[];
scheduleEnabled?: boolean;
pendingUpdateInteractionId?: string | null;
pendingUpdateIssueId?: string | null;
pendingUpdateIssueIdentifier?: string | null;
}
export interface BuiltInAgentBundleDefinition {
@@ -313,6 +317,10 @@ function stockState(input: {
currentHash: string | null;
bindingStockHash: string | null;
changedFiles?: string[];
scheduleEnabled?: boolean;
pendingUpdateInteractionId?: string | null;
pendingUpdateIssueId?: string | null;
pendingUpdateIssueIdentifier?: string | null;
}): BuiltInManagedResourceState {
const status = resourceStatus({
resourceId: input.resourceId,
@@ -331,6 +339,14 @@ function stockState(input: {
updateAvailable: status === "stock_update_available" || status === "operator_modified",
resetAvailable: status !== "stock_current",
...(input.changedFiles && input.changedFiles.length > 0 ? { changedFiles: input.changedFiles } : {}),
...(input.scheduleEnabled !== undefined ? { scheduleEnabled: input.scheduleEnabled } : {}),
...(input.pendingUpdateInteractionId !== undefined
? { pendingUpdateInteractionId: input.pendingUpdateInteractionId }
: {}),
...(input.pendingUpdateIssueId !== undefined ? { pendingUpdateIssueId: input.pendingUpdateIssueId } : {}),
...(input.pendingUpdateIssueIdentifier !== undefined
? { pendingUpdateIssueIdentifier: input.pendingUpdateIssueIdentifier }
: {}),
};
}
@@ -812,7 +828,6 @@ export function builtInAgentService(db: Db) {
return stockHash({
title: routine.title,
description: routine.description,
status: routine.status,
priority: routine.priority,
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
@@ -820,7 +835,6 @@ export function builtInAgentService(db: Db) {
triggers: triggers.map((trigger) => ({
kind: trigger.kind,
label: trigger.label,
enabled: trigger.enabled,
cronExpression: trigger.cronExpression,
timezone: trigger.timezone,
})),
@@ -854,6 +868,73 @@ export function builtInAgentService(db: Db) {
return { binding, routine, triggers };
}
function routineScheduleEnabled(routine: Routine | null, triggers: RoutineTrigger[]) {
return Boolean(
routine?.status === "active"
&& triggers.some((trigger) => trigger.kind === "schedule" && trigger.enabled),
);
}
async function pendingUpdateProposal(agent: Agent | null) {
if (!agent) return null;
return db
.select({
interactionId: issueThreadInteractions.id,
issueId: issues.id,
issueIdentifier: issues.identifier,
})
.from(issueThreadInteractions)
.innerJoin(issues, eq(issueThreadInteractions.issueId, issues.id))
.where(and(
eq(issueThreadInteractions.companyId, agent.companyId),
eq(issueThreadInteractions.createdByAgentId, agent.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
eq(issueThreadInteractions.status, "pending"),
))
.orderBy(desc(issueThreadInteractions.createdAt), desc(issueThreadInteractions.id))
.limit(1)
.then((rows) => rows[0] ?? null);
}
function withRoutineControls(
state: BuiltInManagedResourceState,
input: {
routine: Routine | null;
triggers: RoutineTrigger[];
proposal: Awaited<ReturnType<typeof pendingUpdateProposal>>;
},
) {
if (state.resourceKind !== "routine") return state;
return {
...state,
scheduleEnabled: routineScheduleEnabled(input.routine, input.triggers),
pendingUpdateInteractionId: input.proposal?.interactionId ?? null,
pendingUpdateIssueId: input.proposal?.issueId ?? null,
pendingUpdateIssueIdentifier: input.proposal?.issueIdentifier ?? null,
};
}
async function requireBundleRoutine(companyId: string, key: string, routineKey: string) {
const definition = requireBuiltInAgentDefinition(key);
if (!definition.bundle || definition.bundle.routine.routineKey !== routineKey) {
throw notFound("Built-in routine not found");
}
await ensureCompany(companyId);
const agent = await findSingleAgent(companyId, definition);
if (!agent) throw notFound("Built-in agent is not provisioned");
const current = await getRoutineByBinding(companyId, definition);
if (!current.routine) throw notFound("Built-in routine not found");
const schedule = current.triggers.find((trigger) => trigger.kind === "schedule") ?? null;
return { definition, agent, routine: current.routine, triggers: current.triggers, schedule };
}
async function ensureBuiltInAgentAssignable(agent: Agent) {
if (agent.status !== "paused") return agent;
const resumed = await agentSvc.resume(agent.id);
if (!resumed) throw notFound("Built-in agent not found");
return resumed as Agent;
}
async function createOrResetRoutine(agent: Agent, definition: BuiltInAgentDefinition, existing: Routine | null, mode: "reconcile" | "reset") {
const routine = definition.bundle!.routine;
const actor = { agentId: null, userId: "built-in-bundles" };
@@ -977,7 +1058,8 @@ export function builtInAgentService(db: Db) {
triggerCount: bundle.routine.triggers.length,
},
});
return stockState({
const next = await getRoutineByBinding(agent.companyId, definition);
return withRoutineControls(stockState({
resourceKind: "routine",
resourceKey: bundle.routine.routineKey,
resourceId: nextRoutine.id,
@@ -985,6 +1067,10 @@ export function builtInAgentService(db: Db) {
latestStockHash: stock,
currentHash: shouldWrite ? stock : currentHash,
bindingStockHash: shouldWrite ? stock : binding?.stockHash ?? stock,
}), {
routine: next.routine,
triggers: next.triggers,
proposal: await pendingUpdateProposal(agent),
});
}
@@ -1002,6 +1088,7 @@ export function builtInAgentService(db: Db) {
: await skillSvc.getByKey(companyId, bundle.skill.canonicalKey);
const skillFiles = await getCurrentSkillFiles(companyId, skill, bundle);
const { routine, triggers } = await getRoutineByBinding(companyId, definition);
const proposal = await pendingUpdateProposal(agent);
const instructionHash = Object.values(instructionFiles).every((value) => value !== null)
? stockHash(instructionFiles)
: null;
@@ -1045,7 +1132,7 @@ export function builtInAgentService(db: Db) {
bindingStockHash: skillBinding?.stockHash ?? null,
changedFiles: changedFileList(skillFiles, bundle.skill.files),
}),
stockState({
withRoutineControls(stockState({
resourceKind: "routine",
resourceKey: bundle.routine.routineKey,
resourceId: routine?.id ?? null,
@@ -1053,7 +1140,7 @@ export function builtInAgentService(db: Db) {
latestStockHash: routineDefaultsHash(bundle.routine, bundle.routine.triggers),
currentHash: routineHash,
bindingStockHash: routineBinding?.stockHash ?? null,
}),
}), { routine, triggers, proposal }),
];
}
@@ -1327,6 +1414,37 @@ export function builtInAgentService(db: Db) {
return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources);
}
async function setRoutineSchedule(
companyId: string,
key: string,
routineKey: string,
enabled: boolean,
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
) {
const { definition, agent, routine, schedule } = await requireBundleRoutine(companyId, key, routineKey);
if (!schedule) throw notFound("Built-in routine schedule not found");
if (enabled) {
await ensureBuiltInAgentAssignable(agent);
await routineSvc.update(routine.id, { status: "active" }, actor);
await routineSvc.updateTrigger(schedule.id, { enabled: true }, actor);
} else {
await routineSvc.updateTrigger(schedule.id, { enabled: false }, actor);
await routineSvc.update(routine.id, { status: "paused" }, actor);
}
return state(definition, await agentSvc.getById(agent.id) as Agent);
}
async function runRoutine(
companyId: string,
key: string,
routineKey: string,
actor: { agentId?: string | null; userId?: string | null; runId?: string | null } = {},
) {
const { agent, routine } = await requireBundleRoutine(companyId, key, routineKey);
await ensureBuiltInAgentAssignable(agent);
return routineSvc.runRoutine(routine.id, { source: "manual" }, actor);
}
async function requireBuiltInAgent(companyId: string, key: string): Promise<RequiredBuiltInAgent> {
const current = await get(companyId, key);
if (!current.agent) throw builtInAgentNotConfiguredError(current);
@@ -1356,6 +1474,19 @@ export function builtInAgentService(db: Db) {
provision,
list,
reset,
enableRoutineSchedule: (
companyId: string,
key: string,
routineKey: string,
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
) => setRoutineSchedule(companyId, key, routineKey, true, actor),
disableRoutineSchedule: (
companyId: string,
key: string,
routineKey: string,
actor?: { agentId?: string | null; userId?: string | null; runId?: string | null },
) => setRoutineSchedule(companyId, key, routineKey, false, actor),
runRoutine,
requireBuiltInAgent,
reconcileDefinitionDefaults,
};
+14
View File
@@ -39,6 +39,7 @@ export interface BuiltInAgentBundleMeta {
title: string;
status: "active" | "paused";
triggerCount: number;
scheduleLabel?: string;
};
}
@@ -83,6 +84,13 @@ export interface BuiltInManagedResourceState {
/** True when the resource has drifted and can be reset to the default. */
resetAvailable: boolean;
changedFiles?: string[];
/** True when the managed weekly schedule is active and can create background work. */
scheduleEnabled?: boolean;
/** Pending request_confirmation for a Reflection Coach update proposal, when one exists. */
pendingUpdateInteractionId?: string | null;
/** Issue containing the pending proposal interaction. */
pendingUpdateIssueId?: string | null;
pendingUpdateIssueIdentifier?: string | null;
}
export interface BuiltInAgentState {
@@ -148,4 +156,10 @@ export const builtInAgentsApi = {
*/
reconcile: (companyId: string, key: string) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/reconcile`, {}),
runRoutine: (companyId: string, key: string, routineKey: string) =>
api.post<unknown>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/run`, {}),
enableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/enable`, {}),
disableRoutineSchedule: (companyId: string, key: string, routineKey: string) =>
api.post<BuiltInAgentState>(`/companies/${companyId}/built-in-agents/${key}/routines/${routineKey}/disable`, {}),
};
@@ -20,6 +20,7 @@ vi.mock("@/lib/router", () => ({
function resource(
resourceKind: BuiltInManagedResourceState["resourceKind"],
stockStatus: BuiltInManagedResourceStockStatus,
overrides: Partial<BuiltInManagedResourceState> = {},
): BuiltInManagedResourceState {
return {
resourceKind,
@@ -31,6 +32,7 @@ function resource(
stockStatus,
updateAvailable: stockStatus === "stock_update_available" || stockStatus === "operator_modified",
resetAvailable: stockStatus !== "stock_current",
...overrides,
};
}
@@ -63,6 +65,7 @@ function makeState(
title: "Recent agent reflection",
status: "paused",
triggerCount: 1,
scheduleLabel: "Weekly · Mon 09:00 UTC",
},
},
},
@@ -96,6 +99,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
function render(state: BuiltInAgentState, handlers: Partial<{
onConfigure: () => void;
onResetResource: (kind: BuiltInManagedResourceState["resourceKind"]) => void;
onRunRoutine: (routineKey: string) => void;
onEnableSchedule: (routineKey: string) => void;
onDisableSchedule: (routineKey: string) => void;
}> = {}) {
root = createRoot(container);
flushSync(() => {
@@ -105,6 +111,9 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
agentRef="reflectioncoach"
onConfigure={handlers.onConfigure ?? (() => {})}
onResetResource={handlers.onResetResource ?? (() => {})}
onRunRoutine={handlers.onRunRoutine ?? (() => {})}
onEnableSchedule={handlers.onEnableSchedule ?? (() => {})}
onDisableSchedule={handlers.onDisableSchedule ?? (() => {})}
/>,
);
});
@@ -135,6 +144,36 @@ describe("BuiltInBundlePanel (PAP-13099)", () => {
expect(text).toContain("costs zero tokens by default");
expect(text).toContain("Schedule off");
expect(text).toContain("Ready");
expect(text).toContain("Run once");
expect(text).toContain("Enable weekly");
});
it("shows the active weekly schedule and disable action when enabled", () => {
render(makeState("ready", [
resource("skill", "stock_current"),
resource("instructions", "stock_current"),
resource("routine", "stock_current", { scheduleEnabled: true }),
]));
const text = container.textContent ?? "";
expect(text).toContain("Weekly · Mon 09:00 UTC");
expect(text).toContain("can create background work");
expect(text).toContain("Disable schedule");
expect(text).not.toContain("Enable weekly");
});
it("links to a pending proposal interaction when the routine resource reports one", () => {
render(makeState("ready", [
resource("skill", "stock_current"),
resource("instructions", "stock_current"),
resource("routine", "stock_current", {
pendingUpdateInteractionId: "interaction-1",
pendingUpdateIssueId: "issue-1",
pendingUpdateIssueIdentifier: "PAP-42",
}),
]));
const link = Array.from(container.querySelectorAll("a")).find((anchor) => anchor.textContent === "Review proposal");
expect(container.textContent).toContain("Proposal pending");
expect(link?.getAttribute("href")).toBe("/issues/PAP-42#interaction-interaction-1");
});
it("shows Needs setup for the adapter when the agent is not configured yet", () => {
+117 -9
View File
@@ -134,6 +134,42 @@ function ResourceActionButton({
);
}
function ConfirmActionButton({
title,
body,
triggerLabel,
confirmLabel,
pending,
onConfirm,
}: {
title: string;
body: string;
triggerLabel: string;
confirmLabel: string;
pending: boolean;
onConfirm: () => void;
}) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={pending}>
{pending ? "Working…" : triggerLabel}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>{confirmLabel}</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
interface BundleRowProps {
label: string;
secondary?: string;
@@ -191,8 +227,15 @@ export interface BuiltInBundlePanelProps {
onConfigure: () => void;
/** Scoped reset for one resource (apply update / reset drift / recreate). */
onResetResource: (kind: BuiltInManagedResourceKind) => void;
/** Trigger the managed routine once without enabling its weekly schedule. */
onRunRoutine?: (routineKey: string) => void;
/** Enable the managed routine's weekly schedule. */
onEnableSchedule?: (routineKey: string) => void;
/** Disable the managed routine's weekly schedule. */
onDisableSchedule?: (routineKey: string) => void;
/** The resource kind whose reset is currently in flight, if any. */
resettingResource?: BuiltInManagedResourceKind | null;
routineActionPending?: "run" | "enable" | "disable" | null;
className?: string;
}
@@ -201,7 +244,11 @@ export function BuiltInBundlePanel({
agentRef,
onConfigure,
onResetResource,
onRunRoutine,
onEnableSchedule,
onDisableSchedule,
resettingResource = null,
routineActionPending = null,
className,
}: BuiltInBundlePanelProps) {
const { status, definition, resources } = state;
@@ -224,6 +271,13 @@ export function BuiltInBundlePanel({
const skill = findResource(resources, "skill");
const instructions = findResource(resources, "instructions");
const routine = findResource(resources, "routine");
const scheduleEnabled = routine?.scheduleEnabled === true;
const routineKey = bundle.routine.routineKey;
const scheduleLabel = bundle.routine.scheduleLabel ?? "Weekly schedule";
const proposalIssueRef = routine?.pendingUpdateIssueIdentifier ?? routine?.pendingUpdateIssueId ?? null;
const proposalHref = proposalIssueRef && routine?.pendingUpdateInteractionId
? `/issues/${proposalIssueRef}#interaction-${routine.pendingUpdateInteractionId}`
: null;
const renderResourceRow = (
kind: BuiltInManagedResourceKind,
@@ -303,24 +357,78 @@ export function BuiltInBundlePanel({
secondary={bundle.routine.title}
chips={
<>
<ResourceStatusChip variant="schedule_off" />
<ResourceStatusChip
variant={scheduleEnabled ? "schedule_on" : "schedule_off"}
label={scheduleEnabled ? scheduleLabel : undefined}
/>
{routine && driftVariant(routine) && (
<ResourceStatusChip variant={driftVariant(routine)!} />
)}
</>
}
detail="Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
detail={
scheduleEnabled
? "The weekly schedule is enabled and can create background work."
: "Nothing runs until you enable the weekly schedule — it costs zero tokens by default."
}
actions={
routine && driftVariant(routine) ? (
<ResourceActionButton
resource={routine}
label="routine"
onConfirm={() => onResetResource("routine")}
pending={resettingResource === "routine"}
/>
routine ? (
<>
{onRunRoutine && (
<ConfirmActionButton
title="Run Reflection Coach once?"
body="Paperclip will create one routine task now. This does not enable the weekly schedule or turn on background work."
triggerLabel="Run once"
confirmLabel="Run once"
pending={routineActionPending === "run"}
onConfirm={() => onRunRoutine(routineKey)}
/>
)}
{scheduleEnabled
? onDisableSchedule && (
<ConfirmActionButton
title="Disable the weekly schedule?"
body="Paperclip will stop future scheduled Reflection Coach runs. Manual Run once remains available."
triggerLabel="Disable schedule"
confirmLabel="Disable schedule"
pending={routineActionPending === "disable"}
onConfirm={() => onDisableSchedule(routineKey)}
/>
)
: onEnableSchedule && (
<ConfirmActionButton
title="Enable the weekly schedule?"
body="Paperclip will allow Reflection Coach to create routine tasks on the weekly schedule. It can spend tokens when those tasks run."
triggerLabel="Enable weekly"
confirmLabel="Enable weekly"
pending={routineActionPending === "enable"}
onConfirm={() => onEnableSchedule(routineKey)}
/>
)}
{driftVariant(routine) && (
<ResourceActionButton
resource={routine}
label="routine"
onConfirm={() => onResetResource("routine")}
pending={resettingResource === "routine"}
/>
)}
</>
) : undefined
}
/>
{proposalHref && (
<BundleRow
label="Proposal"
chips={<ResourceStatusChip variant="proposal_pending" />}
detail="A proposed Reflection Coach update is waiting for review."
actions={
<Button asChild variant="link" size="sm">
<Link to={proposalHref}>Review proposal</Link>
</Button>
}
/>
)}
</div>
</section>
);
+36
View File
@@ -754,6 +754,38 @@ export function AgentDetail() {
setActionError(error instanceof Error ? error.message : "Failed to update bundle resource");
},
});
const runBuiltInRoutine = useMutation({
mutationFn: (routineKey: string) =>
builtInAgentsApi.runRoutine(resolvedCompanyId!, builtInState!.definition.key, routineKey),
onSuccess: invalidateBuiltIn,
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to run built-in routine");
},
});
const enableBuiltInSchedule = useMutation({
mutationFn: (routineKey: string) =>
builtInAgentsApi.enableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
onSuccess: invalidateBuiltIn,
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to enable routine schedule");
},
});
const disableBuiltInSchedule = useMutation({
mutationFn: (routineKey: string) =>
builtInAgentsApi.disableRoutineSchedule(resolvedCompanyId!, builtInState!.definition.key, routineKey),
onSuccess: invalidateBuiltIn,
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to disable routine schedule");
},
});
const builtInRoutineActionPending =
runBuiltInRoutine.isPending
? "run"
: enableBuiltInSchedule.isPending
? "enable"
: disableBuiltInSchedule.isPending
? "disable"
: null;
const { data: runtimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
@@ -1161,7 +1193,11 @@ export function AgentDetail() {
agentRef={canonicalAgentRef}
onConfigure={() => setShowBuiltInConfigure(true)}
onResetResource={(kind) => resetBuiltInResource.mutate(kind)}
onRunRoutine={(routineKey) => runBuiltInRoutine.mutate(routineKey)}
onEnableSchedule={(routineKey) => enableBuiltInSchedule.mutate(routineKey)}
onDisableSchedule={(routineKey) => disableBuiltInSchedule.mutate(routineKey)}
resettingResource={resetBuiltInResource.isPending ? resetBuiltInResource.variables ?? null : null}
routineActionPending={builtInRoutineActionPending}
/>
)}
@@ -354,6 +354,9 @@ function BundleCase({ title, state }: { title: string; state: BuiltInAgentState
agentRef="reflectioncoach"
onConfigure={() => {}}
onResetResource={() => {}}
onRunRoutine={() => {}}
onEnableSchedule={() => {}}
onDisableSchedule={() => {}}
/>
</div>
);