mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Auto-enable mentioned skills for heartbeat runs
This commit is contained in:
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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user