Auto-enable mentioned skills for heartbeat runs

This commit is contained in:
Dotta committed 2026-04-14 11:52:57 -05:00
1 parent ee45102587
commit 367065a3d7
2 files changed
+161 -3

No files matched your search

@@ -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"],
},
});
});
});
+107 -2
View File
@@ -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 | null | undefined>,
): string[] {
const mentionedIds = new Set<string>();
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<string, unknown>,
skillKeys: string[],
): Record<string, unknown> {
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<string[]> {
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<string, unknown>;
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,
});