fix(runner): discover assigned tools when direct catalogs exceed limits (#14218)

Keep assigned app tools accessible when the combined Runner catalog exceeds
its operation or byte limits. Reserve task and completion tools, then expose
bounded discovery and call tools for large catalogs. Fetch oversized schemas
in reauthorized chunks without blocking later search results.

Retain task ownership, work-mode restrictions, pinned assignments, current
gateway authorization, approvals, and audit. Small catalogs stay direct.

Validation: 46 focused server tests, two Runner capacity tests, local
typecheck/build, 52 passing CI checks, and Greptile 5/5 with no open findings.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-27 00:22:16 -07:00
1 parent 640dee1802
commit 5ee9e751fb
9 files changed
+319 -12

No files matched your search

@@ -20,8 +20,10 @@ import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
import type { ToolGatewayService } from "../tool-gateway.js";
import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
describe("PaperclipRunnerToolAuthority", () => {
let temporary: Awaited<
@@ -610,6 +612,38 @@ describe("PaperclipRunnerToolAuthority", () => {
).resolves.toMatchObject({ approval: { id: approvalId }, tasks: [] });
});
it("fits large assigned catalogs alongside workspace and completion tools without dropping task tools", async () => {
const listToolsForNamedGateway = vi.fn().mockResolvedValue(Array.from({ length: 224 }, (_, i) => ({
name: `app.action_${i}`, displayName: `Action ${i}`, description: "Read a fixture",
parametersSchema: { type: "object", properties: {} }, risk: "read",
})));
const assignedMcpTools = await createAssignedMcpTools({
gateway: { listToolsForNamedGateway } as unknown as ToolGatewayService,
gatewayPublicId: "fixture", bearerToken: "fixture-token",
});
const binding = { companyId, agentId, issueId, runId, workspaceRoot: "/tmp/fixture-workspace" };
const baseline = new PaperclipRunnerToolAuthority(db, binding).definitions();
expect(runnerCodexDynamicToolsFit([...baseline, ...assignedMcpTools.definitions()])).toBe(false);
const authority = new PaperclipRunnerToolAuthority(db, { ...binding, assignedMcpTools });
const tools = authority.definitions();
expect(runnerCodexDynamicToolsFit(tools)).toBe(true);
expect(tools).toEqual(expect.arrayContaining(baseline));
expect(tools.filter(tool => String(tool.name).startsWith("app_"))).toEqual([]);
expect(tools.map(tool => tool.name)).toEqual(expect.arrayContaining([
"paperclip_search_assigned_tools", "paperclip_call_assigned_tool", "register_deliverable",
]));
const call = { tool: "paperclip_search_assigned_tools", callId: "discover", arguments: { query: "Action 223" } };
await expect(authority.execute(call)).resolves.toMatchObject({ tools: [expect.objectContaining({ description: "Action 223: Read a fixture" })] });
listToolsForNamedGateway.mockClear();
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, runId));
try {
await expect(authority.execute(call)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
expect(listToolsForNamedGateway).not.toHaveBeenCalled();
} finally {
await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, runId));
}
});
it("relays assigned MCP calls only while the native run still owns its task", async () => {
const tool = { name: "app_mem0_recall", description: "Recall memory", inputSchema: { type: "object" } };
const execute = vi.fn().mockResolvedValue({ content: "synthetic memory" });