From 1c868b86be519c46632a093f9d124a0186ab2f07 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:02:35 -0600 Subject: [PATCH] fix(server): tell agents who owns a plugin-managed routine and where it is changed (#20409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A plugin can create and own routines ("plugin-managed routines"). The routine row shows the plugin in `managedByPlugin`. > - An agent reads routines through `GET` and `PATCH /api/routines/{id}`. These API catalog entries had no description. > - So an agent that read `managedByPlugin` did not know what it meant or where a person changes the routine. > - In chat evals, the agent searched for managed-routine routes and probed plugin files for over four minutes. It did not reply. > - This pull request adds one shared description to both catalog entries. It says the plugin owns the definition, links the board's plugin settings page, and tells the agent not to rewrite, detach or duplicate the routine. > - The benefit is that the agent can answer at once and send the person to the correct page. ## Linked Issues or Issue Description No public issue exists. This is a bug report. **What happened** A person asked the CEO agent in chat to rewrite a routine that a plugin manages. The agent read the routine and saw `managedByPlugin`. No tool result or catalog entry told it what that field means. The agent searched the API catalog ("plugin managed routine", "managedByPlugin", "detach plugin managed routine"), tried to read plugin UI files, and listed agents. After more than four minutes it had not replied, and the chat timed out. This happened in two separate runs. **Expected behavior** The agent replies promptly. It says that the plugin owns the routine, does not rewrite or copy it, and links the plugin settings page where a board user changes what the plugin exposes. **Steps to reproduce** 1. Install a plugin that declares a managed routine (`routines.managed` capability, top-level `routines[]`). 2. Open a chat with the CEO and select the routine. 3. Ask: "Rewrite this plugin's daily maintenance routine." 4. Look at the agent's `search_api` results for `GET /api/routines/{id}` and `PATCH /api/routines/{id}`. They have no description. ## What Changed - `server/src/routes/openapi.ts`: add a shared `PLUGIN_MANAGED_ROUTINE_GUIDANCE` description to `GET /api/routines/{id}` and `PATCH /api/routines/{id}`. It names `managedByPlugin`, links `[plugin settings](//company/settings/instance/plugins/)`, and tells agents to reply with that link instead of rewriting, detaching or duplicating the routine. The `PATCH` entry also states the existing agent rule (own routines only, no reassignment). - `server/src/__tests__/plugin-managed-routine-guidance.test.ts`: new regression test. The queries the agent actually used now find these entries with the guidance, and the linked settings route is mounted in `ui/src/App.tsx`. ## Verification - Red then green: `cd server && npx vitest run src/__tests__/plugin-managed-routine-guidance.test.ts` failed 6/6 before the change and passes 6/6 after. - `cd server && npx vitest run src/services/native-runtime/runner-api.test.ts src/__tests__/openapi-routes.test.ts src/__tests__/person-only-page-guidance.test.ts src/__tests__/case-history-api-discovery.test.ts src/__tests__/skill-import-discovery.test.ts` passes. - `cd server && npx tsc --noEmit -p .` passes. - Live chat eval `routines.plugin-managed-routine` on a branch with this commit: see the comment on this PR for the run. ## Risks - Low risk. The change adds documentation text to two API catalog entries. Routes and permissions do not change. - The catalog search scores description words, so these two entries can rank a little higher for queries about plugins or managed resources. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution 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 - [ ] All Paperclip CI gates are green - [ ] 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 --- .../plugin-managed-routine-guidance.test.ts | 51 +++++++++++++++++++ server/src/routes/openapi.ts | 37 +++++++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/plugin-managed-routine-guidance.test.ts diff --git a/server/src/__tests__/plugin-managed-routine-guidance.test.ts b/server/src/__tests__/plugin-managed-routine-guidance.test.ts new file mode 100644 index 00000000000..1dc3ca3474e --- /dev/null +++ b/server/src/__tests__/plugin-managed-routine-guidance.test.ts @@ -0,0 +1,51 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; +import { buildOpenApiDocument, applyPluginSettingsVisibility } from "../routes/openapi.js"; +import { runnerApiCatalog, searchRunnerApi } from "../services/native-runtime/runner-api-catalog.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const settingsLink = "[plugin settings](//company/settings/instance/plugins/)"; + +// Queries an agent searched after reading a routine whose `managedByPlugin` was set. +it.each([ + "plugin managed routine", + "plugin routine managed definition", + "managedByPlugin", + "detach plugin managed routine", + "update routine", +])("search %j explains who owns a plugin-managed routine and links its settings", (query) => { + const found = searchRunnerApi({ query, limit: 10 }).results + .filter(row => row.path === "/api/routines/{id}" && (row.method === "GET" || row.method === "PATCH")); + expect(found.length, query).toBeGreaterThan(0); + for (const operation of found) { + expect(operation.description).toContain("managedByPlugin"); + expect(operation.description).toContain(settingsLink); + expect(operation.description).toContain("Do not rewrite, detach or duplicate"); + } +}); + +it("links a plugin settings page the board UI mounts", () => { + const appRoutes = readFileSync(path.join(repoRoot, "ui/src/App.tsx"), "utf8"); + expect(appRoutes).toContain('path="company/settings/instance/plugins/:pluginId"'); +}); + +it.each(["GET /api/routines/{id}", "PATCH /api/routines/{id}"])("%s carries the full plugin-managed routine guidance", (operationId) => { + const description = runnerApiCatalog().find(row => row.operationId === operationId)?.description ?? ""; + expect(description).toContain("managedByPlugin"); + expect(description).toContain(settingsLink); + expect(description).toContain("Do not rewrite, detach or duplicate"); + expect(description).toContain("reply now with that link"); +}); + +it("names the hosting operator instead of linking plugin settings when the operator hides them", () => { + const document = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set(["instance.plugins"])); + for (const operation of [document.paths["/api/routines/{id}"].get, document.paths["/api/routines/{id}"].patch]) { + expect(operation.description).not.toContain("/company/settings/instance/plugins"); + expect(operation.description).toContain("the hosting operator controls this plugin's settings"); + expect(operation.description).toContain("Do not rewrite, detach or duplicate"); + } + const visible = applyPluginSettingsVisibility(buildOpenApiDocument(), new Set()); + expect(visible.paths["/api/routines/{id}"].get.description).toContain(settingsLink); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 2f438032a26..8b66811fe9d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -11,6 +11,7 @@ import { import { Router } from "express"; import { subscriptionPriceSchema, mergeSubscriptionsSchema } from "@paperclipai/shared"; import { z } from "zod"; +import { getHiddenSettings } from "../services/settings-visibility.js"; import { gitHubRepositoryPageQuerySchema, toggleAllGitHubRepositoriesSchema, @@ -5341,11 +5342,42 @@ registry.registerPath({ responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized }, }); +// A plugin-managed routine is still an ordinary routine row, so agents read it +// through the routine routes; say there who owns it and where it is changed. +const PLUGIN_MANAGED_ROUTINE_GUIDANCE = + "A routine with a non-null `managedByPlugin` is a plugin-managed routine: that plugin created it and owns its definition " + + "(title, instructions, schedule, assignee), and the plugin can reset it to its manifest defaults. " + + "It is changed through the settings that plugin exposes, which a board user opens at " + + "[plugin settings](//company/settings/instance/plugins/), with `managedByPlugin.pluginId` as " + + "and this conversation's company prefix as . " + + "Do not rewrite, detach or duplicate a plugin-managed routine to imitate a change, and do not search for another route: " + + "reply now with that link and the routine's page link."; +// When the hosting operator hides plugin management, that settings page redirects +// away, so the guidance names the operator instead of linking it. +const PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE = + "A routine with a non-null `managedByPlugin` is a plugin-managed routine: that plugin created it and owns its definition " + + "(title, instructions, schedule, assignee), and the plugin can reset it to its manifest defaults. " + + "On this instance the hosting operator manages plugins, so no Paperclip page offers that change. " + + "Do not rewrite, detach or duplicate a plugin-managed routine to imitate a change, and do not search for another route: " + + "reply now that the hosting operator controls this plugin's settings, with the routine's page link."; + +/** Picks the plugin-managed routine guidance that matches plugin settings visibility. */ +export function applyPluginSettingsVisibility(document: any, hidden: ReadonlySet = getHiddenSettings()): any { + if (!hidden.has("instance.plugins")) return document; + for (const operation of [document.paths?.["/api/routines/{id}"]?.get, document.paths?.["/api/routines/{id}"]?.patch]) { + if (typeof operation?.description === "string") { + operation.description = operation.description.replace(PLUGIN_MANAGED_ROUTINE_GUIDANCE, PLUGIN_MANAGED_ROUTINE_OPERATOR_GUIDANCE); + } + } + return document; +} + registry.registerPath({ method: "get", path: "/api/routines/{id}", tags: ["routines"], summary: "Get a routine", + description: `Routine details including triggers. ${PLUGIN_MANAGED_ROUTINE_GUIDANCE}`, request: { params: z.object({ id: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); @@ -5355,6 +5387,7 @@ registry.registerPath({ path: "/api/routines/{id}", tags: ["routines"], summary: "Update a routine", + description: `Agents can update only routines assigned to them and cannot reassign them. ${PLUGIN_MANAGED_ROUTINE_GUIDANCE}`, request: { params: z.object({ id: z.string() }), body: jsonBody(updateRoutineSchema), @@ -13357,7 +13390,7 @@ for (const [method, path, body] of experimentalApiPaths) { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function buildOpenApiDocument(): any { - return applyDocumentFixups({ + return applyPluginSettingsVisibility(applyDocumentFixups({ openapi: "3.0.0", info: { title: "Paperclip API", @@ -13367,7 +13400,7 @@ export function buildOpenApiDocument(): any { servers: [{ url: "/" }], components: registry.buildComponents(), paths: registry.buildPaths(), - }); + })); } export const buildOpenApiSpec = buildOpenApiDocument;