diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index fbaeb478473..526b75d93f0 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -53,7 +53,12 @@ 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().map((tool) => tool.name)).toEqual(["get_task_context", "report_progress"]); + expect(authority.definitions()).toHaveLength(12); + expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([ + "get_task_context", "get_task_history", "search_tasks", "report_progress", + "list_documents", "read_document", "list_document_revisions", + "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", + ])); await expect(authority.execute({ tool: "get_task_context", callId: "context", arguments: {} })) .resolves.toMatchObject({ activeTask: { id: issueId, identifier: "RNT-1" }, actor: { id: agentId } }); await expect(authority.execute({ tool: "finish_task", callId: "hidden", arguments: {} })) diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index a6501dff6cd..e7b9e0b8e81 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,10 +1,17 @@ import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agents, heartbeatRuns, issues } from "@paperclipai/db"; +import { agents, heartbeatRuns, issueApprovals, issueComments, issues } from "@paperclipai/db"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js"; +import { agentService } from "../agents.js"; +import { approvalService } from "../approvals.js"; +import { documentService } from "../documents.js"; import { issueService } from "../issues.js"; -const IMPLEMENTED_OPERATIONS = new Set(["get_task_context", "report_progress"]); +const IMPLEMENTED_OPERATIONS = new Set([ + "get_task_context", "get_task_history", "search_tasks", "report_progress", + "list_documents", "read_document", "list_document_revisions", + "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", +]); type Binding = { companyId: string; @@ -50,15 +57,67 @@ export class PaperclipRunnerToolAuthority { async execute(call: { tool: string; callId: string; arguments: unknown }): Promise { if (!IMPLEMENTED_OPERATIONS.has(call.tool)) throw new Error("paperclip_runner_tool_not_advertised"); const context = await this.#boundContext(); - if (call.tool === "get_task_context") { - return { + const input = record(call.arguments); + switch (call.tool) { + case "get_task_context": return { company: { id: this.binding.companyId }, actor: context.actor, activeTask: context.issue, run: { id: this.binding.runId }, }; + case "get_task_history": { + const limit = boundedLimit(input.limit); + const comments = await this.db.select().from(issueComments) + .where(eq(issueComments.issueId, this.binding.issueId)); + return { comments: comments.slice(-limit) }; + } + case "search_tasks": { + const tasks = await issueService(this.db).list(this.binding.companyId); + const query = typeof input.query === "string" ? input.query.toLowerCase() : ""; + const statuses = Array.isArray(input.statuses) ? new Set(input.statuses.filter((value): value is string => typeof value === "string")) : null; + return { tasks: tasks.filter((task) => + (!query || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) + && (!statuses || statuses.size === 0 || statuses.has(task.status)) + ).slice(0, boundedLimit(input.limit)) }; + } + case "list_documents": + return { documents: await documentService(this.db).listIssueDocuments(this.binding.issueId) }; + case "read_document": { + const document = await documentService(this.db).getIssueDocumentByKey(this.binding.issueId, requiredString(input.key)); + if (!document) throw new Error("paperclip_runner_document_not_found"); + return { document }; + } + case "list_document_revisions": + return { revisions: await documentService(this.db).listIssueDocumentRevisions(this.binding.issueId, requiredString(input.key)) }; + case "list_agents": + return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) }; + case "get_agent": { + const actor = await agentService(this.db).getById(requiredString(input.actorId)); + if (!actor || actor.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found"); + return { actor: redactedActor(actor) }; + } + case "list_approvals": + return { approvals: await approvalService(this.db).list(this.binding.companyId) }; + case "get_approval": { + const approval = await this.#approval(requiredString(input.approvalId)); + return { approval }; + } + case "get_approval_context": { + const approval = await this.#approval(requiredString(input.approvalId)); + const tasks = await this.db.select({ issue: issues }).from(issueApprovals) + .innerJoin(issues, eq(issues.id, issueApprovals.issueId)) + .where(eq(issueApprovals.approvalId, approval.id)); + return { approval, tasks: tasks.map((row) => row.issue) }; + } + case "report_progress": return this.#reportProgress(call.callId, call.arguments); + default: throw new Error("paperclip_runner_tool_not_bound"); } - return this.#reportProgress(call.callId, call.arguments); + } + + async #approval(id: string) { + const approval = await approvalService(this.db).getById(id); + if (!approval || approval.companyId !== this.binding.companyId) throw new Error("paperclip_runner_approval_not_found"); + return approval; } async #boundContext() { @@ -120,3 +179,34 @@ export class PaperclipRunnerToolAuthority { }); } } + +function requiredString(value: unknown): string { + if (typeof value !== "string" || value.trim() === "") throw new Error("paperclip_runner_tool_input_invalid"); + return value.trim(); +} + +function boundedLimit(value: unknown): number { + return typeof value === "number" && Number.isInteger(value) + ? Math.max(1, Math.min(value, 100)) + : 50; +} + +function redactedActor(actor: { + id: string; + companyId: string; + name: string; + role: string; + title?: string | null; + status: string; + reportsTo?: string | null; +}) { + return { + id: actor.id, + companyId: actor.companyId, + name: actor.name, + role: actor.role, + title: actor.title ?? null, + status: actor.status, + reportsTo: actor.reportsTo ?? null, + }; +}