Bind Runner human interactions

This commit is contained in:
Dotta committed 2026-08-21 08:38:20 -05:00
1 parent 3485f2f775
commit 37c09c82de
2 files changed
+89 -2

No files matched your search

@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
import { agents, companies, createDb, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
@@ -53,9 +53,10 @@ describe("PaperclipRunnerToolAuthority", () => {
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
expect(authority.definitions()).toHaveLength(12);
expect(authority.definitions()).toHaveLength(13);
expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"list_documents", "read_document", "list_document_revisions",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]));
@@ -83,6 +84,31 @@ describe("PaperclipRunnerToolAuthority", () => {
})).rejects.toThrow("paperclip_runner_tool_idempotency_conflict");
});
it("creates checkbox interactions through the real interaction service", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
await expect(authority.execute({
tool: "request_human_input",
callId: "ask-checkbox",
arguments: {
idempotencyKey: "favorite-animals",
interactionKind: "checkbox",
title: "Favorite zoo animals",
prompt: "Which zoo animals are your favorites?",
continuationPolicy: "wake_assignee",
payload: {
options: [
{ id: "giraffes", label: "Giraffes" },
{ id: "lions", label: "Lions" },
],
},
},
})).resolves.toMatchObject({
interaction: { kind: "request_checkbox_confirmation", status: "pending" },
});
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId)))
.toHaveLength(1);
});
it("fails closed once the run is no longer active", async () => {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
@@ -6,9 +6,12 @@ import { agentService } from "../agents.js";
import { approvalService } from "../approvals.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { persistActivity, publishActivity } from "../activity-log.js";
const IMPLEMENTED_OPERATIONS = new Set([
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"list_documents", "read_document", "list_document_revisions",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]);
@@ -110,6 +113,7 @@ export class PaperclipRunnerToolAuthority {
return { approval, tasks: tasks.map((row) => row.issue) };
}
case "report_progress": return this.#reportProgress(call.callId, call.arguments);
case "request_human_input": return this.#requestHumanInput(input, context.issue);
default: throw new Error("paperclip_runner_tool_not_bound");
}
}
@@ -178,6 +182,63 @@ export class PaperclipRunnerToolAuthority {
return result;
});
}
async #requestHumanInput(input: Record<string, unknown>, issue: typeof issues.$inferSelect): Promise<unknown> {
const interactionKind = requiredString(input.interactionKind);
const kind = ({
confirmation: "request_confirmation",
checkbox: "request_checkbox_confirmation",
questions: "ask_user_questions",
suggest_tasks: "suggest_tasks",
item_verdicts: "request_item_verdicts",
} as const)[interactionKind as "confirmation"];
if (!kind) throw new Error("paperclip_runner_interaction_kind_invalid");
const suppliedPayload = record(input.payload);
if (input.targetRevisionId && suppliedPayload.target === undefined) {
throw new Error("paperclip_runner_interaction_target_incomplete");
}
const prompt = requiredString(input.prompt);
const interaction = await issueThreadInteractionService(this.db).create(issue, {
kind,
idempotencyKey: requiredString(input.idempotencyKey),
sourceRunId: this.binding.runId,
title: requiredString(input.title),
summary: prompt,
continuationPolicy: requiredString(input.continuationPolicy),
payload: {
...suppliedPayload,
version: 1,
prompt,
...(kind === "request_confirmation" ? {
detailsMarkdown: suppliedPayload.detailsMarkdown ?? "",
acceptLabel: suppliedPayload.acceptLabel ?? "Confirm",
rejectLabel: suppliedPayload.rejectLabel ?? "Request changes",
rejectRequiresReason: suppliedPayload.rejectRequiresReason ?? false,
supersedeOnUserComment: suppliedPayload.supersedeOnUserComment ?? true,
} : {}),
},
} as never, { agentId: this.binding.agentId, userId: null });
const activity = await persistActivity(this.db, {
companyId: this.binding.companyId,
actorType: "agent",
actorId: this.binding.agentId,
agentId: this.binding.agentId,
runId: this.binding.runId,
issueId: this.binding.issueId,
action: "issue.thread_interaction_created",
entityType: "issue",
entityId: this.binding.issueId,
details: {
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
continuationPolicy: interaction.continuationPolicy,
source: "paperclip_runner_protocol",
},
});
publishActivity(activity.publication);
return { interaction, disposition: "applied" };
}
}
function requiredString(value: unknown): string {