mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
Add triage assistant backend
This commit is contained in:
1 parent
929a275717
commit
ac27ec06ca
5 files changed
+1950
-2
No files matched your search
@@ -67,6 +67,10 @@ CREATE TABLE plugin_triage_f3b4aa721e.triage_queue_chats (
|
||||
CREATE INDEX triage_queue_chats_company_queue_idx
|
||||
ON plugin_triage_f3b4aa721e.triage_queue_chats (company_id, queue_id, updated_at DESC);
|
||||
|
||||
CREATE UNIQUE INDEX triage_queue_chats_active_queue_idx
|
||||
ON plugin_triage_f3b4aa721e.triage_queue_chats (queue_id)
|
||||
WHERE status = 'active';
|
||||
|
||||
CREATE TABLE plugin_triage_f3b4aa721e.triage_items (
|
||||
id uuid PRIMARY KEY,
|
||||
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -69,6 +69,16 @@ const manifest: PaperclipPluginManifestV1 = {
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"companies.read",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"agents.read",
|
||||
"agents.resume",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"skills.managed",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,11 +4,13 @@ import {
|
||||
type PluginContext,
|
||||
type PluginApiRequestInput,
|
||||
type PaperclipPlugin,
|
||||
type AgentSession,
|
||||
type PluginManagedAgentResolution,
|
||||
type PluginManagedProjectResolution,
|
||||
type PluginManagedSkillResolution,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
PLUGIN_ID,
|
||||
TRIAGE_ASSISTANT_AGENT_KEY,
|
||||
TRIAGE_MANAGED_SKILL_KEYS,
|
||||
TRIAGE_PROJECT_KEY,
|
||||
@@ -140,6 +142,66 @@ async function managedResourceHealth(
|
||||
};
|
||||
}
|
||||
|
||||
function actionActor(ctx: PluginContext, params: Record<string, unknown>) {
|
||||
return {
|
||||
actorType: "plugin-action",
|
||||
actorId: ctx.manifest.id,
|
||||
actorRunId: typeof params.actorRunId === "string" ? params.actorRunId : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureQueueChatIssue(
|
||||
ctx: PluginContext,
|
||||
service: ReturnType<typeof createTriageService>,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
const companyId = requireCompanyId(params);
|
||||
const ensured = await service.ensureQueueChat(params);
|
||||
let hiddenIssueId = ensured.chat.hiddenIssueId;
|
||||
const existingIssue = hiddenIssueId ? await ctx.issues.get(hiddenIssueId, companyId) : null;
|
||||
if (!existingIssue) {
|
||||
const project = await ctx.projects.managed.reconcile(TRIAGE_PROJECT_KEY, companyId);
|
||||
const issue = await ctx.issues.create({
|
||||
companyId,
|
||||
projectId: project.projectId ?? undefined,
|
||||
title: ensured.chat.title ?? `Triage chat: ${ensured.queue.title}`,
|
||||
description: [
|
||||
`Hidden queue chat for Paperclip Triage queue \`${ensured.queue.queueKey}\`.`,
|
||||
"",
|
||||
"This issue stores assistant conversation audit context for the queue and is created by the triage plugin.",
|
||||
].join("\n"),
|
||||
status: "in_review",
|
||||
priority: "low",
|
||||
surfaceVisibility: "plugin_operation",
|
||||
originKind: `plugin:${PLUGIN_ID}:operation:queue-chat`,
|
||||
originId: `queue-chat:${ensured.queue.id}`,
|
||||
});
|
||||
hiddenIssueId = issue.id;
|
||||
ensured.chat = await service.updateQueueChat({
|
||||
companyId,
|
||||
chatId: ensured.chat.id,
|
||||
hiddenIssueId,
|
||||
metadata: {
|
||||
...ensured.chat.metadata,
|
||||
hiddenIssueIdentifier: issue.identifier,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { ...ensured, hiddenIssueId };
|
||||
}
|
||||
|
||||
async function activeSessionForChat(
|
||||
ctx: PluginContext,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
chatMetadata: Record<string, unknown>,
|
||||
): Promise<AgentSession | null> {
|
||||
const activeSessionId = typeof chatMetadata.activeSessionId === "string" ? chatMetadata.activeSessionId : null;
|
||||
if (!activeSessionId) return null;
|
||||
const sessions = await ctx.agents.sessions.list(agentId, companyId);
|
||||
return sessions.find((session) => session.sessionId === activeSessionId) ?? null;
|
||||
}
|
||||
|
||||
export function createTriagePlugin(options: {
|
||||
createStore?: (ctx: PluginContext) => TriageStore;
|
||||
} = {}): PaperclipPlugin {
|
||||
@@ -235,6 +297,26 @@ export function createTriagePlugin(options: {
|
||||
return getService().getItem(params);
|
||||
});
|
||||
|
||||
ctx.data.register("assistant-context", async (params: Record<string, unknown>) => {
|
||||
return getService().getAssistantContext(params);
|
||||
});
|
||||
|
||||
ctx.data.register("queue-guidance", async (params: Record<string, unknown>) => {
|
||||
return getService().listGuidanceDocs(params);
|
||||
});
|
||||
|
||||
ctx.data.register("guidance-proposals", async (params: Record<string, unknown>) => {
|
||||
return getService().listGuidanceProposals(params);
|
||||
});
|
||||
|
||||
ctx.data.register("item-events", async (params: Record<string, unknown>) => {
|
||||
return getService().listItemEvents(params);
|
||||
});
|
||||
|
||||
ctx.data.register("queue-transition-actions", async (params: Record<string, unknown>) => {
|
||||
return getService().listTransitionActions(params);
|
||||
});
|
||||
|
||||
ctx.actions.register("reconcile-managed-resources", async (params: Record<string, unknown>) => {
|
||||
const companyId = requireCompanyId(params);
|
||||
const result = await managedResourceHealth(ctx, companyId, "reconcile");
|
||||
@@ -277,10 +359,117 @@ export function createTriagePlugin(options: {
|
||||
return getService().updateItem(params);
|
||||
});
|
||||
|
||||
ctx.actions.register("update-item-content", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().updateItemContent(params, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("archive-item", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().archiveItem(params);
|
||||
});
|
||||
|
||||
ctx.actions.register("upsert-transition-action", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().upsertTransitionAction(params);
|
||||
});
|
||||
|
||||
ctx.actions.register("transition-item", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().transitionItem(params, ctx, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("ensure-queue-chat", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return ensureQueueChatIssue(ctx, getService(), params);
|
||||
});
|
||||
|
||||
ctx.actions.register("send-assistant-message", async (params: Record<string, unknown>) => {
|
||||
const companyId = requireCompanyId(params);
|
||||
await requireKnownCompany(ctx, companyId);
|
||||
const message = stringField(params.message);
|
||||
if (!message) {
|
||||
throw new Error("message is required");
|
||||
}
|
||||
|
||||
const context = await getService().getAssistantContext(params);
|
||||
const chat = await ensureQueueChatIssue(ctx, getService(), params);
|
||||
const agentResolution = await ctx.agents.managed.reconcile(TRIAGE_ASSISTANT_AGENT_KEY, companyId);
|
||||
if (!agentResolution.agentId) {
|
||||
throw new Error("Triage Assistant managed agent is missing");
|
||||
}
|
||||
if (agentResolution.agent?.status === "paused") {
|
||||
await ctx.agents.resume(agentResolution.agentId, companyId);
|
||||
}
|
||||
const existingSession = await activeSessionForChat(
|
||||
ctx,
|
||||
companyId,
|
||||
agentResolution.agentId,
|
||||
chat.chat.metadata,
|
||||
);
|
||||
const session = existingSession ??
|
||||
await ctx.agents.sessions.create(agentResolution.agentId, companyId, {
|
||||
taskKey: `plugin:${PLUGIN_ID}:session:triage-queue-chat:${chat.queue.id}`,
|
||||
reason: "triage_queue_chat",
|
||||
});
|
||||
const run = await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||
prompt: context.prompt,
|
||||
reason: `triage:${chat.queue.queueKey}`,
|
||||
});
|
||||
const updatedChat = await getService().updateQueueChat({
|
||||
companyId,
|
||||
chatId: chat.chat.id,
|
||||
metadata: {
|
||||
...chat.chat.metadata,
|
||||
activeSessionId: session.sessionId,
|
||||
lastRunId: run.runId,
|
||||
lastItemId: context.item.id,
|
||||
},
|
||||
});
|
||||
if (chat.hiddenIssueId) {
|
||||
await ctx.issues.createComment(
|
||||
chat.hiddenIssueId,
|
||||
[`User message for item \`${context.item.title}\`:`, "", message].join("\n"),
|
||||
companyId,
|
||||
{ authorAgentId: agentResolution.agentId },
|
||||
);
|
||||
}
|
||||
return {
|
||||
queue: context.queue,
|
||||
item: context.item,
|
||||
guidanceDocs: context.guidanceDocs,
|
||||
chat: updatedChat,
|
||||
hiddenIssueId: chat.hiddenIssueId,
|
||||
session,
|
||||
runId: run.runId,
|
||||
prompt: context.prompt,
|
||||
};
|
||||
});
|
||||
|
||||
ctx.actions.register("generate-guidance-proposal", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().createGuidanceProposal(params, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("revise-guidance-proposal", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().reviseGuidanceProposal(params, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("reject-guidance-proposal", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().rejectGuidanceProposal(params, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("accept-guidance-proposal", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().acceptGuidanceProposal(params, actionActor(ctx, params));
|
||||
});
|
||||
|
||||
ctx.actions.register("manual-edit-guidance", async (params: Record<string, unknown>) => {
|
||||
await requireKnownCompany(ctx, requireCompanyId(params));
|
||||
return getService().manualEditGuidance(params, actionActor(ctx, params));
|
||||
});
|
||||
},
|
||||
|
||||
async onApiRequest(input: PluginApiRequestInput) {
|
||||
|
||||
@@ -65,6 +65,11 @@ describe("Paperclip Triage scaffold", () => {
|
||||
"database.namespace.read",
|
||||
"database.namespace.write",
|
||||
"companies.read",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"agents.managed",
|
||||
"projects.managed",
|
||||
"skills.managed",
|
||||
@@ -155,7 +160,7 @@ describe("Paperclip Triage queue and item ingest", () => {
|
||||
const testPlugin = createTriagePlugin({ createStore: () => store });
|
||||
const harness = createTestHarness({ manifest });
|
||||
harness.seed({ companies: [company(COMPANY_ID), company(OTHER_COMPANY_ID)] });
|
||||
return { harness, plugin: testPlugin };
|
||||
return { harness, plugin: testPlugin, store };
|
||||
}
|
||||
|
||||
it("creates and updates queues through worker actions", async () => {
|
||||
@@ -304,4 +309,201 @@ describe("Paperclip Triage queue and item ingest", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses one hidden queue chat and assistant session per queue while isolating other queues", async () => {
|
||||
const { harness, plugin: testPlugin } = createHarness();
|
||||
await testPlugin.definition.setup(harness.ctx);
|
||||
|
||||
const first = await harness.performAction<{
|
||||
item: { id: string };
|
||||
}>("ingest-item", {
|
||||
companyId: COMPANY_ID,
|
||||
queueKey: "reviews",
|
||||
title: "Launch review",
|
||||
content: "Please review the launch copy.",
|
||||
properties: { sourceKind: "manual" },
|
||||
});
|
||||
const other = await harness.performAction<{
|
||||
item: { id: string };
|
||||
}>("ingest-item", {
|
||||
companyId: COMPANY_ID,
|
||||
queueKey: "support",
|
||||
title: "Support review",
|
||||
content: "Please review the support reply.",
|
||||
});
|
||||
|
||||
const firstSend = await harness.performAction<{
|
||||
chat: { id: string; hiddenIssueId: string; metadata: Record<string, unknown> };
|
||||
hiddenIssueId: string;
|
||||
session: { sessionId: string };
|
||||
runId: string;
|
||||
prompt: string;
|
||||
}>("send-assistant-message", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: first.item.id,
|
||||
message: "What should I change?",
|
||||
});
|
||||
|
||||
expect(firstSend.hiddenIssueId).toBeTruthy();
|
||||
expect(firstSend.chat.hiddenIssueId).toBe(firstSend.hiddenIssueId);
|
||||
expect(firstSend.runId).toBeTruthy();
|
||||
expect(firstSend.prompt).toContain("Queue: Reviews (reviews)");
|
||||
expect(firstSend.prompt).toContain("Please review the launch copy.");
|
||||
expect(firstSend.prompt).toContain("guidance.md");
|
||||
expect(firstSend.prompt).toContain("What should I change?");
|
||||
|
||||
const secondSend = await harness.performAction<{
|
||||
chat: { id: string; hiddenIssueId: string };
|
||||
hiddenIssueId: string;
|
||||
session: { sessionId: string };
|
||||
}>("send-assistant-message", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: first.item.id,
|
||||
message: "Use the same thread.",
|
||||
});
|
||||
expect(secondSend.chat.id).toBe(firstSend.chat.id);
|
||||
expect(secondSend.hiddenIssueId).toBe(firstSend.hiddenIssueId);
|
||||
expect(secondSend.session.sessionId).toBe(firstSend.session.sessionId);
|
||||
|
||||
const otherSend = await harness.performAction<{
|
||||
chat: { id: string };
|
||||
hiddenIssueId: string;
|
||||
}>("send-assistant-message", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: other.item.id,
|
||||
message: "Different queue.",
|
||||
});
|
||||
expect(otherSend.chat.id).not.toBe(firstSend.chat.id);
|
||||
expect(otherSend.hiddenIssueId).not.toBe(firstSend.hiddenIssueId);
|
||||
});
|
||||
|
||||
it("updates item content only when the expected revision matches", async () => {
|
||||
const { harness, plugin: testPlugin } = createHarness();
|
||||
await testPlugin.definition.setup(harness.ctx);
|
||||
|
||||
const ingested = await harness.performAction<{
|
||||
item: { id: string; revision: number };
|
||||
}>("ingest-item", {
|
||||
companyId: COMPANY_ID,
|
||||
queueKey: "reviews",
|
||||
title: "Original",
|
||||
content: "Original content",
|
||||
});
|
||||
|
||||
const updated = await harness.performAction<{
|
||||
item: { title: string; content: string; revision: number };
|
||||
event: { eventType: string; metadata: Record<string, unknown> };
|
||||
}>("update-item-content", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: ingested.item.id,
|
||||
expectedRevision: ingested.item.revision,
|
||||
title: "Edited",
|
||||
content: "Edited content",
|
||||
});
|
||||
|
||||
expect(updated.item).toMatchObject({ title: "Edited", content: "Edited content", revision: 2 });
|
||||
expect(updated.event).toMatchObject({
|
||||
eventType: "item.content.updated",
|
||||
metadata: { previousRevision: 1, nextRevision: 2 },
|
||||
});
|
||||
|
||||
await expect(harness.performAction("update-item-content", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: ingested.item.id,
|
||||
expectedRevision: 1,
|
||||
content: "Stale edit",
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "item_revision_conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports guidance proposal generation, revision, acceptance, rejection, and manual edits", async () => {
|
||||
const { harness, plugin: testPlugin } = createHarness();
|
||||
await testPlugin.definition.setup(harness.ctx);
|
||||
|
||||
const ingested = await harness.performAction<{
|
||||
item: { id: string };
|
||||
}>("ingest-item", {
|
||||
companyId: COMPANY_ID,
|
||||
queueKey: "reviews",
|
||||
title: "Review example",
|
||||
content: "Prefer concise copy.",
|
||||
});
|
||||
|
||||
const manual = await harness.performAction<{
|
||||
doc: { content: string; currentRevisionId: string };
|
||||
revision: { id: string; summary: string };
|
||||
}>("manual-edit-guidance", {
|
||||
companyId: COMPANY_ID,
|
||||
queueKey: "reviews",
|
||||
content: "# Reviews Guidance\n\nStart from the user's target audience.",
|
||||
summary: "Seed guidance",
|
||||
});
|
||||
expect(manual.doc.content).toContain("target audience");
|
||||
expect(manual.doc.currentRevisionId).toBe(manual.revision.id);
|
||||
|
||||
const generated = await harness.performAction<{
|
||||
proposal: { id: string; status: string; proposedContent: string };
|
||||
}>("generate-guidance-proposal", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: ingested.item.id,
|
||||
suggestedChange: "For launch copy, flag vague benefits before approving.",
|
||||
rationale: "The item exposed a repeatable approval rule.",
|
||||
});
|
||||
expect(generated.proposal.status).toBe("proposed");
|
||||
expect(generated.proposal.proposedContent).toContain("target audience");
|
||||
expect(generated.proposal.proposedContent).toContain("flag vague benefits");
|
||||
|
||||
const revised = await harness.performAction<{ status: string; proposedContent: string }>(
|
||||
"revise-guidance-proposal",
|
||||
{
|
||||
companyId: COMPANY_ID,
|
||||
proposalId: generated.proposal.id,
|
||||
proposedContent: "# Reviews Guidance\n\nFlag vague benefits and ask for concrete proof.",
|
||||
rationale: "Sharper wording.",
|
||||
},
|
||||
);
|
||||
expect(revised).toMatchObject({ status: "revised" });
|
||||
|
||||
const accepted = await harness.performAction<{
|
||||
proposal: { status: string; metadata: Record<string, unknown> };
|
||||
doc: { content: string; currentRevisionId: string };
|
||||
revision: { id: string };
|
||||
}>("accept-guidance-proposal", {
|
||||
companyId: COMPANY_ID,
|
||||
proposalId: generated.proposal.id,
|
||||
});
|
||||
expect(accepted.proposal.status).toBe("accepted");
|
||||
expect(accepted.proposal.metadata.acceptedRevisionId).toBe(accepted.revision.id);
|
||||
expect(accepted.doc.content).toContain("concrete proof");
|
||||
expect(accepted.doc.currentRevisionId).toBe(accepted.revision.id);
|
||||
|
||||
const rejectedCandidate = await harness.performAction<{
|
||||
proposal: { id: string };
|
||||
}>("generate-guidance-proposal", {
|
||||
companyId: COMPANY_ID,
|
||||
itemId: ingested.item.id,
|
||||
proposedContent: "# Reviews Guidance\n\nToo broad.",
|
||||
});
|
||||
const rejected = await harness.performAction<{ status: string; metadata: Record<string, unknown> }>(
|
||||
"reject-guidance-proposal",
|
||||
{
|
||||
companyId: COMPANY_ID,
|
||||
proposalId: rejectedCandidate.proposal.id,
|
||||
reason: "Too broad for this queue.",
|
||||
},
|
||||
);
|
||||
expect(rejected).toMatchObject({
|
||||
status: "rejected",
|
||||
metadata: expect.objectContaining({ rejectionReason: "Too broad for this queue." }),
|
||||
});
|
||||
await expect(harness.performAction("accept-guidance-proposal", {
|
||||
companyId: COMPANY_ID,
|
||||
proposalId: rejectedCandidate.proposal.id,
|
||||
})).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "guidance_proposal_rejected",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user