mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Some checks failed
Docker / build-and-push (push) Failing after 3m41s
Refresh Lockfile / refresh (push) Failing after 5m12s
Release / verify_canary (push) Failing after 10m53s
Release / verify_stable (push) Has been skipped
Release / publish_canary (push) Has been skipped
Release / preview_stable (push) Has been skipped
Release / publish_stable (push) Has been skipped
Fixes #2391 Fixes #3394 Fixes #4094 Fixes #5501 Fixes #5916 Fixes #6215 Fixes #6514 ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Plugins extend the platform by registering agent-callable tools backed by long-running worker processes > - `PluginToolDispatcher` is the boundary between the HTTP `/api/plugins/tools/execute` route and `PluginWorkerManager`, which owns those worker processes > - `PluginWorkerManager` keys live workers by the plugin's **database UUID**, but `plugin-loader` was registering tools using only `pluginKey` — so every tool call did `workerManager.isRunning(pluginKey)` and always got `false` > - As a result, every `POST /api/plugins/tools/execute` against a tool-exposing plugin returned 502 `worker for plugin X is not running`, even though the worker process was alive (hit in production by `vexion.council-chat`; `mem0-sync` would be next) > - This pull request threads the DB UUID through the dispatcher → registry hop and hardens the contract so omitting the UUID is a compile-time error, not a silent fallback > - The benefit is plugin tool execution actually works for any plugin declaring `manifest.tools[]`, and the type system prevents the same bug from recurring ## What Changed - `server/src/services/plugin-loader.ts` — pass in-scope `pluginId` (DB UUID) as the third argument to `toolDispatcher.registerPluginTools`. Single-line root fix. - `server/src/services/plugin-tool-dispatcher.ts` — `registerPluginTools` now takes `pluginDbId: string` (required, was optional). JSDoc updated to document the worker-routing contract and why the optional signature masked the bug. - `server/src/services/plugin-tool-registry.ts` — `registerPlugin` throws on missing/empty `pluginDbId` so any new call site that forgets the UUID fails immediately rather than silently falling back to `pluginKey`. - `server/src/__tests__/plugin-tool-dispatcher-pluginDbId.test.ts` — new focused regression suite covering the activation path, disable→enable lifecycle, worker re-spawn, and the empty-UUID guard. ## Verification - `pnpm vitest run server/src/__tests__/plugin-tool-dispatcher-pluginDbId.test.ts` — 6/6 passing. - `pnpm vitest run server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/plugin-lifecycle-restart.test.ts` — 48/48 passing on the merge commit. - `pnpm --filter @paperclipai/server typecheck` — no new errors introduced by these files. - Manual repro path: 1. Install a plugin that declares `manifest.tools[]` and uses `runWorker`. 2. Confirm status `ready` and a live worker (`paperclipai plugin diagnostics <key>`). 3. `POST /api/plugins/tools/execute` with `{ tool: "<pluginKey>:<toolName>", parameters, runContext }`. 4. Pre-fix: HTTP 502, `worker for plugin <key> is not running`. Post-fix: tool dispatches normally. ## Risks - Low risk. The signature tightening (`pluginDbId?` → `pluginDbId`) is a back-compatible behavioral fix at the only production call site (`plugin-loader`), which already had the UUID in scope. - Test/recovery paths that previously omitted the UUID must now supply it; the new error message identifies the missing arg explicitly. - No database migration, no API/schema change, no plugin-author-facing change. - The merge commit pulls master into the PR branch additively (no rebase); reviewers can read the fix commits independently of the merge. ## Model Used - Provider/model: Anthropic Claude (Opus 4.7, `claude-opus-4-7`) for the additive merge-conflict resolution, PR description rewrite, and Greptile follow-up; original fix authored by [@Ramon-nassa](https://github.com/Ramon-nassa). - Capabilities used: tool use (file edit, shell, GitHub CLI), extended thinking off, no code execution by the model. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (N/A — server-only change) - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- ## Original Summary (preserved from contributor) `plugin-loader` activates plugins and calls ```ts toolDispatcher.registerPluginTools(pluginKey, manifest) ``` with only two args. `PluginToolDispatcher.registerPluginTools` forwards them to `registry.registerPlugin(pluginKey, manifest)`. The registry falls back `pluginDbId ?? pluginKey`, but `PluginWorkerManager` keys live workers by the DB UUID — so the downstream ```ts workerManager.isRunning(pluginKey) // always false ``` causes every `POST /api/plugins/tools/execute` to fail with `worker for plugin X is not running`, even when the worker process is alive and healthy. **This hits every plugin that exposes tools** (we hit it in `vexion.council-chat`; `mem0-sync` would too). Reported-by: Vexion / Ramon Nassar (vexion.council-chat plugin, MO-068). --------- Co-authored-by: ramon nassar <ramon@tabs.co> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Devin Foley <devin@devinfoley.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
/**
|
|
* PluginToolRegistry — host-side registry for plugin-contributed agent tools.
|
|
*
|
|
* Responsibilities:
|
|
* - Store tool declarations (from plugin manifests) alongside routing metadata
|
|
* so the host can resolve namespaced tool names to the owning plugin worker.
|
|
* - Namespace tools automatically: a tool `"search-issues"` from plugin
|
|
* `"acme.linear"` is exposed to agents as `"acme.linear:search-issues"`.
|
|
* - Route `executeTool` calls to the correct plugin worker via the
|
|
* `PluginWorkerManager`.
|
|
* - Provide tool discovery queries so agents can list available tools.
|
|
* - Clean up tool registrations when a plugin is unloaded or its worker stops.
|
|
*
|
|
* The registry is an in-memory structure — tool declarations are derived from
|
|
* the plugin manifest at load time and do not need persistence. When a plugin
|
|
* worker restarts, the host re-registers its manifest tools.
|
|
*
|
|
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
|
* @see PLUGIN_SPEC.md §13.10 — `executeTool`
|
|
*/
|
|
|
|
import type {
|
|
PaperclipPluginManifestV1,
|
|
PluginToolDeclaration,
|
|
} from "@paperclipai/shared";
|
|
import type { ToolRunContext, ToolResult, ExecuteToolParams } from "@paperclipai/plugin-sdk";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Separator between plugin ID and tool name in the namespaced tool identifier.
|
|
*
|
|
* Example: `"acme.linear:search-issues"`
|
|
*/
|
|
export const TOOL_NAMESPACE_SEPARATOR = ":";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A registered tool entry stored in the registry.
|
|
*
|
|
* Combines the manifest-level declaration with routing metadata so the host
|
|
* can resolve a namespaced tool name → plugin worker in O(1).
|
|
*/
|
|
export interface RegisteredTool {
|
|
/** The plugin key used for namespacing (e.g. `"acme.linear"`). */
|
|
pluginId: string;
|
|
/**
|
|
* The plugin's database UUID, used for worker routing and availability
|
|
* checks. Falls back to `pluginId` when not provided (e.g. in tests
|
|
* where `id === pluginKey`).
|
|
*/
|
|
pluginDbId: string;
|
|
/** The tool's bare name (without namespace prefix). */
|
|
name: string;
|
|
/** Fully namespaced identifier: `"<pluginId>:<toolName>"`. */
|
|
namespacedName: string;
|
|
/** Human-readable display name. */
|
|
displayName: string;
|
|
/** Description provided to the agent so it knows when to use this tool. */
|
|
description: string;
|
|
/** JSON Schema describing the tool's input parameters. */
|
|
parametersSchema: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Filter criteria for listing available tools.
|
|
*/
|
|
export interface ToolListFilter {
|
|
/** Only return tools owned by this plugin. */
|
|
pluginId?: string;
|
|
}
|
|
|
|
/**
|
|
* Result of executing a tool, extending `ToolResult` with routing metadata.
|
|
*/
|
|
export interface ToolExecutionResult {
|
|
/** The plugin that handled the tool call. */
|
|
pluginId: string;
|
|
/** The bare tool name that was executed. */
|
|
toolName: string;
|
|
/** The result returned by the plugin's tool handler. */
|
|
result: ToolResult;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PluginToolRegistry interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The host-side tool registry — held by the host process.
|
|
*
|
|
* Created once at server startup and shared across the application. Plugins
|
|
* register their tools when their worker starts, and unregister when the
|
|
* worker stops or the plugin is uninstalled.
|
|
*/
|
|
export interface PluginToolRegistry {
|
|
/**
|
|
* Register all tools declared in a plugin's manifest.
|
|
*
|
|
* Called when a plugin worker starts and its manifest is loaded. Any
|
|
* previously registered tools for the same plugin are replaced (idempotent).
|
|
*
|
|
* @param pluginId - The plugin's unique identifier (e.g. `"acme.linear"`).
|
|
* @param manifest - The plugin manifest containing the `tools` array.
|
|
* @param pluginDbId - The plugin's database UUID, used for worker routing
|
|
* and availability checks. Required — `workerManager` keys live workers
|
|
* by the DB UUID, so omitting this guarantees that every subsequent
|
|
* `workerManager.isRunning(pluginDbId)` call returns false and every tool
|
|
* dispatch fails with `worker for plugin X is not running`.
|
|
*/
|
|
registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId: string): void;
|
|
|
|
/**
|
|
* Remove all tool registrations for a plugin.
|
|
*
|
|
* Called when a plugin worker stops, crashes, or is uninstalled.
|
|
*
|
|
* @param pluginId - The plugin to clear
|
|
*/
|
|
unregisterPlugin(pluginId: string): void;
|
|
|
|
/**
|
|
* Look up a registered tool by its namespaced name.
|
|
*
|
|
* @param namespacedName - Fully qualified name, e.g. `"acme.linear:search-issues"`
|
|
* @returns The registered tool entry, or `null` if not found
|
|
*/
|
|
getTool(namespacedName: string): RegisteredTool | null;
|
|
|
|
/**
|
|
* Look up a registered tool by plugin ID and bare tool name.
|
|
*
|
|
* @param pluginId - The owning plugin
|
|
* @param toolName - The bare tool name (without namespace prefix)
|
|
* @returns The registered tool entry, or `null` if not found
|
|
*/
|
|
getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null;
|
|
|
|
/**
|
|
* List all registered tools, optionally filtered.
|
|
*
|
|
* @param filter - Optional filter criteria
|
|
* @returns Array of registered tool entries
|
|
*/
|
|
listTools(filter?: ToolListFilter): RegisteredTool[];
|
|
|
|
/**
|
|
* Parse a namespaced tool name into plugin ID and bare tool name.
|
|
*
|
|
* @param namespacedName - e.g. `"acme.linear:search-issues"`
|
|
* @returns `{ pluginId, toolName }` or `null` if the format is invalid
|
|
*/
|
|
parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null;
|
|
|
|
/**
|
|
* Build a namespaced tool name from a plugin ID and bare tool name.
|
|
*
|
|
* @param pluginId - e.g. `"acme.linear"`
|
|
* @param toolName - e.g. `"search-issues"`
|
|
* @returns The namespaced name, e.g. `"acme.linear:search-issues"`
|
|
*/
|
|
buildNamespacedName(pluginId: string, toolName: string): string;
|
|
|
|
/**
|
|
* Execute a tool by its namespaced name, routing to the correct plugin worker.
|
|
*
|
|
* Resolves the namespaced name to the owning plugin, validates the tool
|
|
* exists, and dispatches the `executeTool` RPC call to the worker.
|
|
*
|
|
* @param namespacedName - Fully qualified tool name (e.g. `"acme.linear:search-issues"`)
|
|
* @param parameters - The parsed parameters matching the tool's schema
|
|
* @param runContext - Agent run context
|
|
* @returns The execution result with routing metadata
|
|
* @throws {Error} if the tool is not found or the worker is not running
|
|
*/
|
|
executeTool(
|
|
namespacedName: string,
|
|
parameters: unknown,
|
|
runContext: ToolRunContext,
|
|
): Promise<ToolExecutionResult>;
|
|
|
|
/**
|
|
* Get the number of registered tools, optionally scoped to a plugin.
|
|
*
|
|
* @param pluginId - If provided, count only this plugin's tools
|
|
*/
|
|
toolCount(pluginId?: string): number;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Factory: createPluginToolRegistry
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a new `PluginToolRegistry`.
|
|
*
|
|
* The registry is backed by two in-memory maps:
|
|
* - `byNamespace`: namespaced name → `RegisteredTool` for O(1) lookups.
|
|
* - `byPlugin`: pluginId → Set of namespaced names for efficient per-plugin ops.
|
|
*
|
|
* @param workerManager - The worker manager used to dispatch `executeTool` RPC
|
|
* calls to plugin workers. If not provided, `executeTool` will throw.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const toolRegistry = createPluginToolRegistry(workerManager);
|
|
*
|
|
* // Register tools from a plugin manifest
|
|
* toolRegistry.registerPlugin("acme.linear", linearManifest);
|
|
*
|
|
* // List all available tools for agents
|
|
* const tools = toolRegistry.listTools();
|
|
* // → [{ namespacedName: "acme.linear:search-issues", ... }]
|
|
*
|
|
* // Execute a tool
|
|
* const result = await toolRegistry.executeTool(
|
|
* "acme.linear:search-issues",
|
|
* { query: "auth bug" },
|
|
* { agentId: "agent-1", runId: "run-1", companyId: "co-1", projectId: "proj-1" },
|
|
* );
|
|
* ```
|
|
*/
|
|
export function createPluginToolRegistry(
|
|
workerManager?: PluginWorkerManager,
|
|
): PluginToolRegistry {
|
|
const log = logger.child({ service: "plugin-tool-registry" });
|
|
|
|
// Primary index: namespaced name → tool entry
|
|
const byNamespace = new Map<string, RegisteredTool>();
|
|
|
|
// Secondary index: pluginId → set of namespaced names (for bulk operations)
|
|
const byPlugin = new Map<string, Set<string>>();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Internal helpers
|
|
// -----------------------------------------------------------------------
|
|
|
|
function buildName(pluginId: string, toolName: string): string {
|
|
return `${pluginId}${TOOL_NAMESPACE_SEPARATOR}${toolName}`;
|
|
}
|
|
|
|
function parseName(namespacedName: string): { pluginId: string; toolName: string } | null {
|
|
const sepIndex = namespacedName.lastIndexOf(TOOL_NAMESPACE_SEPARATOR);
|
|
if (sepIndex <= 0 || sepIndex >= namespacedName.length - 1) {
|
|
return null;
|
|
}
|
|
return {
|
|
pluginId: namespacedName.slice(0, sepIndex),
|
|
toolName: namespacedName.slice(sepIndex + 1),
|
|
};
|
|
}
|
|
|
|
function addTool(pluginId: string, decl: PluginToolDeclaration, pluginDbId: string): void {
|
|
const namespacedName = buildName(pluginId, decl.name);
|
|
|
|
const entry: RegisteredTool = {
|
|
pluginId,
|
|
pluginDbId,
|
|
name: decl.name,
|
|
namespacedName,
|
|
displayName: decl.displayName,
|
|
description: decl.description,
|
|
parametersSchema: decl.parametersSchema,
|
|
};
|
|
|
|
byNamespace.set(namespacedName, entry);
|
|
|
|
let pluginTools = byPlugin.get(pluginId);
|
|
if (!pluginTools) {
|
|
pluginTools = new Set();
|
|
byPlugin.set(pluginId, pluginTools);
|
|
}
|
|
pluginTools.add(namespacedName);
|
|
}
|
|
|
|
function removePluginTools(pluginId: string): number {
|
|
const pluginTools = byPlugin.get(pluginId);
|
|
if (!pluginTools) return 0;
|
|
|
|
const count = pluginTools.size;
|
|
for (const name of pluginTools) {
|
|
byNamespace.delete(name);
|
|
}
|
|
byPlugin.delete(pluginId);
|
|
|
|
return count;
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Public API
|
|
// -----------------------------------------------------------------------
|
|
|
|
return {
|
|
registerPlugin(pluginId: string, manifest: PaperclipPluginManifestV1, pluginDbId: string): void {
|
|
// Guard at the registry boundary so a missing UUID surfaces as an
|
|
// explicit contract error instead of a downstream
|
|
// `worker for plugin X is not running`.
|
|
if (!pluginDbId) {
|
|
throw new Error(
|
|
`plugin-tool-registry.registerPlugin: pluginDbId is required (pluginId="${pluginId}"). ` +
|
|
`Workers are keyed by DB UUID; omitting this guarantees worker-lookup failure.`,
|
|
);
|
|
}
|
|
const dbId = pluginDbId;
|
|
|
|
// Remove any previously registered tools for this plugin (idempotent)
|
|
const previousCount = removePluginTools(pluginId);
|
|
if (previousCount > 0) {
|
|
log.debug(
|
|
{ pluginId, previousCount },
|
|
"cleared previous tool registrations before re-registering",
|
|
);
|
|
}
|
|
|
|
const tools = manifest.tools ?? [];
|
|
if (tools.length === 0) {
|
|
log.debug({ pluginId }, "plugin declares no tools");
|
|
return;
|
|
}
|
|
|
|
for (const decl of tools) {
|
|
addTool(pluginId, decl, dbId);
|
|
}
|
|
|
|
log.info(
|
|
{
|
|
pluginId,
|
|
toolCount: tools.length,
|
|
tools: tools.map((t) => buildName(pluginId, t.name)),
|
|
},
|
|
`registered ${tools.length} tool(s) for plugin`,
|
|
);
|
|
},
|
|
|
|
unregisterPlugin(pluginId: string): void {
|
|
const removed = removePluginTools(pluginId);
|
|
if (removed > 0) {
|
|
log.info(
|
|
{ pluginId, removedCount: removed },
|
|
`unregistered ${removed} tool(s) for plugin`,
|
|
);
|
|
}
|
|
},
|
|
|
|
getTool(namespacedName: string): RegisteredTool | null {
|
|
return byNamespace.get(namespacedName) ?? null;
|
|
},
|
|
|
|
getToolByPlugin(pluginId: string, toolName: string): RegisteredTool | null {
|
|
const namespacedName = buildName(pluginId, toolName);
|
|
return byNamespace.get(namespacedName) ?? null;
|
|
},
|
|
|
|
listTools(filter?: ToolListFilter): RegisteredTool[] {
|
|
if (filter?.pluginId) {
|
|
const pluginTools = byPlugin.get(filter.pluginId);
|
|
if (!pluginTools) return [];
|
|
const result: RegisteredTool[] = [];
|
|
for (const name of pluginTools) {
|
|
const tool = byNamespace.get(name);
|
|
if (tool) result.push(tool);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return Array.from(byNamespace.values());
|
|
},
|
|
|
|
parseNamespacedName(namespacedName: string): { pluginId: string; toolName: string } | null {
|
|
return parseName(namespacedName);
|
|
},
|
|
|
|
buildNamespacedName(pluginId: string, toolName: string): string {
|
|
return buildName(pluginId, toolName);
|
|
},
|
|
|
|
async executeTool(
|
|
namespacedName: string,
|
|
parameters: unknown,
|
|
runContext: ToolRunContext,
|
|
): Promise<ToolExecutionResult> {
|
|
// 1. Resolve the namespaced name
|
|
const parsed = parseName(namespacedName);
|
|
if (!parsed) {
|
|
throw new Error(
|
|
`Invalid tool name "${namespacedName}". Expected format: "<pluginId>${TOOL_NAMESPACE_SEPARATOR}<toolName>"`,
|
|
);
|
|
}
|
|
|
|
const { pluginId, toolName } = parsed;
|
|
|
|
// 2. Verify the tool is registered
|
|
const tool = byNamespace.get(namespacedName);
|
|
if (!tool) {
|
|
throw new Error(
|
|
`Tool "${namespacedName}" is not registered. ` +
|
|
`The plugin may not be installed or its worker may not be running.`,
|
|
);
|
|
}
|
|
|
|
// 3. Verify the worker manager is available
|
|
if (!workerManager) {
|
|
throw new Error(
|
|
`Cannot execute tool "${namespacedName}" — no worker manager configured. ` +
|
|
`Tool execution requires a PluginWorkerManager.`,
|
|
);
|
|
}
|
|
|
|
// 4. Verify the plugin worker is running (use DB UUID for worker lookup)
|
|
const dbId = tool.pluginDbId;
|
|
if (!workerManager.isRunning(dbId)) {
|
|
throw new Error(
|
|
`Cannot execute tool "${namespacedName}" — ` +
|
|
`worker for plugin "${pluginId}" is not running.`,
|
|
);
|
|
}
|
|
|
|
// 5. Dispatch the executeTool RPC call to the worker
|
|
log.debug(
|
|
{ pluginId, pluginDbId: dbId, toolName, namespacedName, agentId: runContext.agentId, runId: runContext.runId },
|
|
"executing tool via plugin worker",
|
|
);
|
|
|
|
const rpcParams: ExecuteToolParams = {
|
|
toolName,
|
|
parameters,
|
|
runContext,
|
|
};
|
|
|
|
const result = await workerManager.call(dbId, "executeTool", rpcParams);
|
|
|
|
log.debug(
|
|
{
|
|
pluginId,
|
|
toolName,
|
|
namespacedName,
|
|
hasContent: !!result.content,
|
|
hasData: result.data !== undefined,
|
|
hasError: !!result.error,
|
|
},
|
|
"tool execution completed",
|
|
);
|
|
|
|
return { pluginId, toolName, result };
|
|
},
|
|
|
|
toolCount(pluginId?: string): number {
|
|
if (pluginId !== undefined) {
|
|
return byPlugin.get(pluginId)?.size ?? 0;
|
|
}
|
|
return byNamespace.size;
|
|
},
|
|
};
|
|
}
|