From 367065a3d72b48fe6587bf9bddf67c6ea767d50c Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 13 Apr 2026 07:33:05 -0500 Subject: [PATCH] Auto-enable mentioned skills for heartbeat runs --- .../__tests__/heartbeat-project-env.test.ts | 55 ++++++++- server/src/services/heartbeat.ts | 109 +++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 85f2b051703..8490b04e418 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; -import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { + applyRunScopedMentionedSkillKeys, + extractMentionedSkillIdsFromSources, + resolveExecutionRunAdapterConfig, +} from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { it("overlays project env on top of agent env and unions secret keys", async () => { @@ -63,3 +68,51 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveEnvBindings).not.toHaveBeenCalled(); }); }); + +describe("extractMentionedSkillIdsFromSources", () => { + it("collects explicit skill mention ids across issue sources", () => { + const releaseHref = buildSkillMentionHref("skill-1", "release-changelog"); + const browserHref = buildSkillMentionHref("skill-2", "agent-browser"); + + expect( + extractMentionedSkillIdsFromSources([ + `Please use [/release-changelog](${releaseHref})`, + `And also [/agent-browser](${browserHref})`, + `Duplicate mention [/release-changelog](${releaseHref})`, + ]), + ).toEqual(["skill-1", "skill-2"]); + }); +}); + +describe("applyRunScopedMentionedSkillKeys", () => { + it("adds mentioned skills without mutating the original config", () => { + const originalConfig = { + command: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclipai/paperclip/paperclip"], + }, + }; + + const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [ + "company/company-1/release-changelog", + "paperclipai/paperclip/paperclip", + "company/company-1/release-changelog", + ]); + + expect(updatedConfig).toEqual({ + command: "codex", + paperclipSkillSync: { + desiredSkills: [ + "paperclipai/paperclip/paperclip", + "company/company-1/release-changelog", + ], + }, + }); + expect(originalConfig).toEqual({ + command: "codex", + paperclipSkillSync: { + desiredSkills: ["paperclipai/paperclip/paperclip"], + }, + }); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index e0e0a317cec..f2ce72b7c0d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10,6 +10,7 @@ import { agentRuntimeState, agentTaskSessions, agentWakeupRequests, + companySkills as companySkillsTable, heartbeatRunEvents, heartbeatRuns, issueComments, @@ -67,6 +68,11 @@ import { resolveSessionCompactionPolicy, type SessionCompactionPolicy, } from "@paperclipai/adapter-utils"; +import { + readPaperclipSkillSyncPreference, + writePaperclipSkillSyncPreference, +} from "@paperclipai/adapter-utils/server-utils"; +import { extractSkillMentionIds } from "@paperclipai/shared"; const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; @@ -122,6 +128,90 @@ export async function resolveExecutionRunAdapterConfig(input: { return { resolvedConfig, secretKeys }; } +export function extractMentionedSkillIdsFromSources( + sources: Array, +): string[] { + const mentionedIds = new Set(); + for (const source of sources) { + if (typeof source !== "string" || source.length === 0) continue; + for (const skillId of extractSkillMentionIds(source)) { + mentionedIds.add(skillId); + } + } + return [...mentionedIds]; +} + +export function applyRunScopedMentionedSkillKeys( + config: Record, + skillKeys: string[], +): Record { + const normalizedSkillKeys = Array.from( + new Set( + skillKeys + .map((value) => value.trim()) + .filter(Boolean), + ), + ); + if (normalizedSkillKeys.length === 0) return config; + + const existingPreference = readPaperclipSkillSyncPreference(config); + return writePaperclipSkillSyncPreference(config, [ + ...existingPreference.desiredSkills, + ...normalizedSkillKeys, + ]); +} + +async function resolveRunScopedMentionedSkillKeys(input: { + db: Db; + companyId: string; + issueId: string | null; +}): Promise { + if (!input.issueId) return []; + + const issue = await input.db + .select({ + title: issues.title, + description: issues.description, + }) + .from(issues) + .where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))) + .then((rows) => rows[0] ?? null); + if (!issue) return []; + + const comments = await input.db + .select({ body: issueComments.body }) + .from(issueComments) + .where( + and( + eq(issueComments.issueId, input.issueId), + eq(issueComments.companyId, input.companyId), + ), + ); + const mentionedSkillIds = extractMentionedSkillIdsFromSources([ + issue.title, + issue.description ?? "", + ...comments.map((comment) => comment.body), + ]); + if (mentionedSkillIds.length === 0) return []; + + const skillRows = await input.db + .select({ + id: companySkillsTable.id, + key: companySkillsTable.key, + }) + .from(companySkillsTable) + .where( + and( + eq(companySkillsTable.companyId, input.companyId), + inArray(companySkillsTable.id, mentionedSkillIds), + ), + ); + const skillKeyById = new Map(skillRows.map((row) => [row.id, row.key])); + return mentionedSkillIds + .map((skillId) => skillKeyById.get(skillId) ?? null) + .filter((skillKey): skillKey is string => Boolean(skillKey)); +} + export function applyPersistedExecutionWorkspaceConfig(input: { config: Record; workspaceConfig: ExecutionWorkspaceConfig | null; @@ -2845,9 +2935,18 @@ export function heartbeatService(db: Db) { projectEnv: projectContext?.env ?? null, secretsSvc, }); + const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({ + db, + companyId: agent.companyId, + issueId, + }); + const effectiveResolvedConfig = applyRunScopedMentionedSkillKeys( + resolvedConfig, + runScopedMentionedSkillKeys, + ); const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); const runtimeConfig = { - ...resolvedConfig, + ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ @@ -3213,6 +3312,12 @@ export function heartbeatService(db: Db) { }, }); }; + if (runScopedMentionedSkillKeys.length > 0) { + await onLog( + "stdout", + `[paperclip] Enabled run-scoped skills from issue mentions: ${runScopedMentionedSkillKeys.join(", ")}\n`, + ); + } for (const warning of runtimeWorkspaceWarnings) { const logEntry = formatRuntimeWorkspaceWarningLog(warning); await onLog(logEntry.stream, logEntry.chunk); @@ -3233,7 +3338,7 @@ export function heartbeatService(db: Db) { issue: issueRef, workspace: executionWorkspace, executionWorkspaceId: persistedExecutionWorkspace?.id ?? issueRef?.executionWorkspaceId ?? null, - config: resolvedConfig, + config: effectiveResolvedConfig, adapterEnv, onLog, });