Files
PaperClipAI/server/src/services/native-runtime/runner-api-catalog.ts
T
DottaandClaude Opus 5.5 46ca7d4377 fix(api-search): stop function words from pushing case lookup off page one (#20382)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Chat and task agents find API operations with `search_api`, which
ranks the runner API catalog by word matches in each operation's method,
path and summary
> - The ranker scored function words such as "for", "the" and "of" like
resource words, so any summary that says "for" got 5 points for that
word alone
> - #20355 added two chat-bootstrap summaries that say "for up to ten
tasks" and "for one task". After that, the query "case for task" put
`GET /api/issues/{issueId}/cases` at rank 6, off the first page of 5
> - #20354 promised that agents find a task's case on the first search
page, and its test `case-history-api-discovery.test.ts` now fails on
master
> - This pull request stops the ranker from scoring function words, so
the resource words in the query decide the rank
> - The benefit is that master is green again and agents find the case
lookup (and other "X for an issue" reads) on the first page, whatever
new summaries say "for"

## Linked Issues or Issue Description

Refs #20354, #20355. Supersedes #19388 (the same ranker change, opened
earlier and not merged).

**What happened**
On a clean checkout of master at 0e3fdebea8 and later,
`server/src/__tests__/case-history-api-discovery.test.ts` fails:
"expected [ …(5) ] to include 'GET /api/issues/{issueId}/cases'".
`search_api` for "case for task" returns case handoffs, both
chat-bootstrap reads, task-drain, pending questions and privacy
constraints first. Most of these rank high only because their summaries
contain "for".

**Expected behavior**
An agent that searches "case for task" finds `GET
/api/issues/{issueId}/cases` on the first page. Words such as "for",
"an" and "the" do not change the rank.

**Steps to reproduce**
1. Check out master.
2. Run `cd server && npx vitest run
src/__tests__/case-history-api-discovery.test.ts`.
3. See the assertion failure for the "case for task" query.

## What Changed

- `server/src/services/native-runtime/runner-api-catalog.ts`: the search
tokenizer drops a small set of function words (`a`, `an`, `the`, `for`,
`of`, `to`, `in`, `on`, `at`, `by`, `with`, `and`, `or`, `from`) before
scoring. This applies to the query and to the catalog text.
- `server/src/services/native-runtime/runner-api.test.ts`: a regression
test. "list comments for an issue" returns the comments route first.
"queued comments", "activity", "runs" and "case for task" return their
routes on the first page. A query of only function words returns no
results.

## Verification

- Red on master before the change: `cd server && npx vitest run
src/__tests__/case-history-api-discovery.test.ts` fails (1 failed, 2
passed). Before the fix, the top 10 for "case for task" put the case
lookup at rank 6.
- Green after the change: `npx vitest run
src/__tests__/case-history-api-discovery.test.ts
src/services/native-runtime/runner-api.test.ts
src/__tests__/openapi-routes.test.ts
src/__tests__/agent-conversations.test.ts
src/__tests__/heartbeat-list.test.ts` passes.

## Risks

- Low risk. The change affects only `search_api` ranking. Exact
operationId lookup is unchanged. A query that has only function words
now returns no results instead of a list of arbitrary matches.
- Rank order can change for queries that used function words. The
existing ranking tests still pass.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with
tool use (shell, file edits) in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:28:42 -06:00

186 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { z } from "zod";
import { buildOpenApiDocument } from "../../routes/openapi.js";
import { badRequest, notFound } from "../../errors.js";
import { runnerApiReference } from "./runner-api-reference.js";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js";
type Json = Record<string, any>;
export interface RunnerApiOperation {
operationId: string;
method: string;
path: string;
summary: string;
description: string;
parameters: Json[];
requestBody?: Json;
responses: Json;
authorization: Json;
transport: "rest" | "protocol";
dedicatedTools: string[];
allowedModes: string[];
skillReference?: { section: string; description?: string; examples?: { body: unknown }[] };
dedicatedToolGuidance: string;
dedicatedToolCapabilities?: { name: string; description: string; supportedParameters: string[] }[];
runnerRestrictions?: string[];
callPolicy: "rest" | "protocol";
}
const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "options"]);
const SYNONYMS: Record<string, string> = {
task: "issue", tasks: "issues", employee: "agent", employees: "agents",
hire: "agent", hiring: "agents", expense: "cost", expenses: "costs",
cron: "routines", schedule: "routines", recurring: "routines",
blocker: "dependencies", blockers: "dependencies", files: "attachments",
};
// Function words carry no resource meaning; scoring them let any summary that
// happens to say "for" or "the" outrank the operation a query names.
const SEARCH_FUNCTION_WORDS = new Set(["a", "an", "the", "for", "of", "to", "in", "on", "at", "by", "with", "and", "or", "from"]);
function words(text: string): string[] {
return text.toLowerCase().split(/[^a-z0-9]+/).filter((word) => word.length > 0 && !SEARCH_FUNCTION_WORDS.has(word))
.flatMap((word) => [word, ...(SYNONYMS[word] ? [SYNONYMS[word]] : [])])
.map((word) => word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word);
}
function dedicatedTools(method: string, path: string): string[] {
if (method === "POST" && path === "/api/agents/me/chat/messages") return ["send_chat_message"];
if (/\/companies\/\{[^}]+\}\/workflows$/.test(path)) return method === "GET" ? ["list_workflows"] : method === "POST" ? ["create_workflow"] : [];
if (/\/workflows\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_workflow"] : method === "PATCH" ? ["update_workflow"] : [];
if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"];
if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"];
if (/\/companies\/\{[^}]+\}\/skills\/\{[^}]+\}\/files$/.test(path) && method === "PATCH") return ["update_skill"];
if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"];
if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : [];
if (/\/project-repositories$/.test(path) && method === "GET") return ["list_project_repositories"];
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
if (/\/case-handoffs$/.test(path) && method === "POST") return ["handoff_case", "create_case"];
if (/\/cases\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["read_case"] : method === "PATCH" ? ["update_case"] : [];
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];
if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["reassign_task", "set_dependencies", "finish_task", "block_task", "request_review"];
if (/\/agents$/.test(path) && method === "GET") return ["list_agents"];
if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"];
if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"];
if (/\/approvals\/\{[^}]+\}/.test(path) && method === "GET") return ["get_approval", "get_approval_context"];
return [];
}
// Descriptions and schemas are documentation, never authorization. Routes remain
// authoritative, including conditional role, company and resource checks.
export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()): RunnerApiOperation[] {
function dereference(value: any, seen = new Set<string>()): any {
if (Array.isArray(value)) return value.map((entry) => dereference(entry, seen));
if (!value || typeof value !== "object") return value;
if (typeof value.$ref === "string" && value.$ref.startsWith("#/")) {
if (seen.has(value.$ref)) return { description: `Recursive schema: ${value.$ref}` };
const target = value.$ref.slice(2).split("/").reduce((node: any, key: string) => node?.[key.replace(/~1/g, "/").replace(/~0/g, "~")], document);
if (!target) throw new Error(`Unresolved API schema: ${value.$ref}`);
return dereference(target, new Set([...seen, value.$ref]));
}
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, dereference(entry, seen)]));
}
const result: RunnerApiOperation[] = [];
for (const [path, item] of Object.entries<Json>(document.paths)) {
for (const [verb, operation] of Object.entries<Json>(item)) {
if (!METHODS.has(verb)) continue;
const method = verb.toUpperCase();
const conversationalConfirmation = method === "POST"
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
// Computes an install plan without saving it, so read-only modes may call it.
const readOnlyPreview = method === "POST" && path === "/api/companies/{companyId}/teams/catalog/{catalogId}/preview";
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
const protocol = operation["x-paperclip-transport"] === "protocol" || (operation["x-paperclip-transport"] !== "rest" && (!path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|| /event-stream|websocket/i.test(JSON.stringify(operation.responses))));
result.push({
operationId: `${method} ${path}`, method, path,
summary: operation.summary ?? `${method} ${path}`,
description: operation.description ?? "",
parameters: dereference([...(item.parameters ?? []), ...(operation.parameters ?? [])]),
...(operation.requestBody ? { requestBody: dereference(operation.requestBody) } : {}),
responses: dereference(operation.responses ?? {}),
authorization: operation["x-paperclip-authorization"] ?? { actor: "board_or_agent" },
transport: protocol ? "protocol" : "rest",
callPolicy: protocol ? "protocol" : "rest",
dedicatedTools: dedicatedTools(method, path),
dedicatedToolCapabilities: dedicatedTools(method, path).flatMap(name => {
const descriptor = CAPABILITY_SEMANTIC_TOOL_CATALOG.find(tool => tool.operationId === name);
return descriptor ? [{ name, description: descriptor.description, supportedParameters: Object.keys((descriptor.inputSchema as Json).properties ?? {}) }] : [];
}),
runnerRestrictions: ["Active run and assignment must remain authorized.",
conversationalConfirmation
? "This records an ordinary conversational confirmation under existing resolver permissions. It cannot decide governed approvals or impersonate the user."
: "The HTTP route enforces the same endpoint, payload, company, resource and approval permissions as agent REST access."],
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
? "Prefer hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime. Additional API fields use the route's existing agent permissions."
: "Prefer an available dedicated tool for its supported fields. call_api supports the route's other operations and fields under the same agent permissions.",
allowedModes: operation["x-paperclip-connection-management"] ? (method === "GET" ? ["standard", "ask", "planning"] : ["standard"]) : method !== "GET" && (path.endsWith("/case-handoffs") || /\/handoffs\//.test(path)) ? ["standard"]
: method === "GET" || method === "HEAD" || taskTitleUpdate || readOnlyPreview ? ["standard", "ask", "planning", "skill_test"]
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
...(skillReference ? { skillReference } : {}),
});
}
}
for (const [path, summary, actor] of [
["/api/companies/{companyId}/events/ws", "Live company events WebSocket; use the existing event client", "board_or_agent"],
["/api/runner/v1/connect/{runId}", "Authenticated runner PRP WebSocket; runner-owned transport", "runner"],
["/api/environment-custom-image-setup-sessions/{sessionId}/terminal/ws", "Image setup terminal WebSocket; use the existing terminal client", "board"],
]) {
if (result.some(operation => operation.path === path)) continue;
result.push({ operationId: `GET ${path}`, method: "GET", path, summary,
description: "WebSocket upgrade. Not callable through call_api.",
parameters: [...path.matchAll(/\{([^}]+)\}/g)].map(match => ({ in: "path", name: match[1], required: true, schema: { type: "string" } })),
responses: { "101": { description: "Switching Protocols" } }, authorization: { actor },
transport: "protocol", callPolicy: "protocol", dedicatedTools: [], dedicatedToolGuidance: "Use the existing protocol client.", allowedModes: [],
});
}
return result.sort((a, b) => a.operationId.localeCompare(b.operationId));
}
let cached: RunnerApiOperation[] | undefined;
let cachedDigest: string | undefined;
export function runnerApiCatalog(): RunnerApiOperation[] { return cached ??= buildRunnerApiCatalog(); }
export function runnerApiOperation(id: string): RunnerApiOperation {
const operation = runnerApiCatalog().find((entry) => entry.operationId === id);
if (!operation) throw notFound("Unknown API operation; use search_api to discover its exact operationId");
return operation;
}
export const runnerApiSearchSchema = z.object({ query: z.string().trim().min(1).max(500), limit: z.number().int().min(1).max(10).default(5), cursor: z.string().max(200).optional() }).strict();
export function searchRunnerApi(value: unknown) {
const parsed = runnerApiSearchSchema.safeParse(value);
if (!parsed.success) throw badRequest("Invalid API search query or limit");
const input = parsed.data;
const { query, limit } = input;
const catalog = runnerApiCatalog();
cachedDigest ??= createHash("sha256").update(JSON.stringify(catalog)).digest("hex");
const fingerprint = createHash("sha256").update(cachedDigest).update(query).digest("hex").slice(0, 16);
let offset = 0;
if (input.cursor) {
const match = /^([a-f0-9]{16}):(\d+)$/.exec(input.cursor);
if (!match || match[1] !== fingerprint) throw badRequest("Search cursor belongs to a different query or catalog");
offset = Number(match[2]);
if (!Number.isSafeInteger(offset)) throw badRequest("Invalid search cursor");
}
const exact = catalog.find((entry) => entry.operationId.toLowerCase() === query.trim().toLowerCase());
const terms = [...new Set(words(query))];
const matches = exact ? [exact] : catalog.map((entry) => {
const title = new Set(words(`${entry.method} ${entry.path} ${entry.summary}`));
const description = new Set(words(`${entry.description} ${entry.skillReference?.description ?? ""} ${entry.skillReference?.section ?? ""}`));
return { entry, score: terms.reduce((sum, word) => sum + (title.has(word) ? 5 : description.has(word) ? 1 : 0), 0) };
}).filter(({ score }) => score > 0).sort((a, b) => b.score - a.score || a.entry.operationId.localeCompare(b.entry.operationId)).map(({ entry }) => entry);
const results = matches.slice(offset, offset + limit);
return {
results, total: matches.length,
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
};
}