Files
oh-my-pi/packages/coding-agent/src/extensibility/custom-tools/loader.ts
T
roboomp dde53a8f18 fix(task): forward custom-tool paths to subagents, rebind tools per session
Reviewer flagged that forwarding `LoadedCustomTool[]` from a parent session
to a subagent reused tool instances whose factories had closed over the
parent's `CustomToolAPI` — `cwd`, `exec`, `pushPendingAction`, and `ui` all
pointed at the parent. In isolated tasks the tool would `exec` against the
parent worktree and queue pending actions on the parent session.

Forward only the path list; let each session rebuild tools through
`loadCustomTools` so factories see the right `CustomToolAPI`.

- `extensibility/custom-tools/loader.ts`: extract `discoverCustomToolPaths`
  (FS scan only) from `discoverAndLoadCustomTools`; export
  `ToolPathWithSource`. The combined helper is now `discoverCustomToolPaths`
  + `loadCustomTools`.
- `sdk.ts`: replace `preloadedCustomTools` (`LoadedCustomTool[]`) with
  `preloadedCustomToolPaths` (`ToolPathWithSource[]`). The custom-tools
  block runs `loadCustomTools` unconditionally; only the path scan is
  skipped when the caller pre-discovered it.
- `tools/index.ts`: `ToolSession.loadedCustomTools` →
  `ToolSession.customToolPaths` for the same reason.
- `task/executor.ts` and `task/index.ts`: forward `customToolPaths`.
  Drop the forward for isolated subagents — the worktree shifts `cwd`, so
  the subagent re-discovers tools against its own working tree.
- New `test/sdk-custom-tools-per-session-binding.test.ts` pins the contract:
  two `loadCustomTools` calls on the same path with different `cwd` and
  different `pushPendingAction` callbacks yield distinct tool instances
  whose factories see the per-call bindings.
- Updated `executor-pass-through` and `sdk-preloaded-extensions-isolation`
  tests for the new option name and added a `ToolPathWithSource` fixture.

Refs PR review on #2193
2026-06-09 14:09:37 +00:00

270 lines
9.0 KiB
TypeScript

/**
* Custom tool loader - loads TypeScript tool modules using native Bun import.
*
* Dependencies (the zod-backed typebox shim and pi-coding-agent) are injected via the
* CustomToolAPI to avoid import resolution issues with custom tools loaded from user directories.
*/
import * as path from "node:path";
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { logger } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import { toolCapability } from "../../capability/tool";
import { type CustomTool, loadCapability } from "../../discovery";
import type { ExecOptions } from "../../exec/exec";
import { execCommand } from "../../exec/exec";
import type { HookUIContext } from "../../extensibility/hooks/types";
import { getAllPluginToolPaths } from "../../extensibility/plugins/loader";
import * as typebox from "../typebox";
import { createNoOpUIContext, resolvePath } from "../utils";
import type { CustomToolAPI, CustomToolFactory, LoadedCustomTool, ToolLoadError } from "./types";
/**
* Load a single tool module using native Bun import.
*/
async function loadTool(
toolPath: string,
cwd: string,
sharedApi: CustomToolAPI,
source?: { provider: string; providerName: string; level: "user" | "project" },
): Promise<{ tools: LoadedCustomTool[] | null; error: ToolLoadError | null }> {
const resolvedPath = resolvePath(toolPath, cwd);
// Skip declarative tool files (.md, .json) - these are metadata only, not executable modules
if (resolvedPath.endsWith(".md") || resolvedPath.endsWith(".json")) {
return {
tools: null,
error: {
path: toolPath,
error: "Declarative tool files (.md, .json) cannot be loaded as executable modules",
source,
},
};
}
try {
const module = await import(resolvedPath);
const factory = (module.default ?? module) as CustomToolFactory;
if (typeof factory !== "function") {
return { tools: null, error: { path: toolPath, error: "Tool must export a default function", source } };
}
const toolResult = await factory(sharedApi);
const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult];
const loadedTools: LoadedCustomTool[] = toolsArray.map(tool => ({
path: toolPath,
resolvedPath,
tool,
source,
}));
return { tools: loadedTools, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { tools: null, error: { path: toolPath, error: `Failed to load tool: ${message}`, source } };
}
}
/** Tool path with optional source metadata, suitable for forwarding from a
* parent session to a subagent so the subagent can re-bind tools to its own
* `CustomToolAPI` without redoing the filesystem scan. */
export interface ToolPathWithSource {
path: string;
source?: { provider: string; providerName: string; level: "user" | "project" };
}
/**
* Loads custom tools from paths with conflict detection and error handling.
*
* Manages a shared API instance passed to all tool factories, providing access to
* execution context, UI, logger, and injected dependencies. The UI context can be
* updated after loading via setUIContext().
*/
export class CustomToolLoader {
tools: LoadedCustomTool[] = [];
errors: ToolLoadError[] = [];
#sharedApi: CustomToolAPI;
#seenNames: Set<string>;
constructor(
pi: typeof import("@oh-my-pi/pi-coding-agent"),
cwd: string,
builtInToolNames: string[],
pushPendingAction?: (action: {
label: string;
sourceToolName: string;
apply(reason: string): Promise<AgentToolResult<unknown>>;
reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
}) => void,
) {
this.#sharedApi = {
cwd,
exec: (command: string, args: string[], options?: ExecOptions) =>
execCommand(command, args, options?.cwd ?? cwd, options),
ui: createNoOpUIContext(),
hasUI: false,
logger,
typebox,
zod: z,
pi,
pushPendingAction: action => {
if (!pushPendingAction) {
throw new Error("Pending action store unavailable for custom tools in this runtime.");
}
pushPendingAction({
label: action.label,
sourceToolName: action.sourceToolName ?? "custom_tool",
apply: action.apply,
reject: action.reject,
});
},
};
this.#seenNames = new Set<string>(builtInToolNames);
}
async load(pathsWithSources: ToolPathWithSource[]): Promise<void> {
for (const { path: toolPath, source } of pathsWithSources) {
const { tools: loadedTools, error } = await loadTool(toolPath, this.#sharedApi.cwd, this.#sharedApi, source);
if (error) {
this.errors.push(error);
continue;
}
if (loadedTools) {
for (const loadedTool of loadedTools) {
// Check for name conflicts
if (this.#seenNames.has(loadedTool.tool.name)) {
this.errors.push({
path: toolPath,
error: `Tool name "${loadedTool.tool.name}" conflicts with existing tool`,
source,
});
continue;
}
this.#seenNames.add(loadedTool.tool.name);
this.tools.push(loadedTool);
}
}
}
}
setUIContext(uiContext: HookUIContext, hasUI: boolean): void {
this.#sharedApi.ui = uiContext;
this.#sharedApi.hasUI = hasUI;
}
}
/**
* Load all tools from configuration.
* @param pathsWithSources - Array of tool paths with optional source metadata
* @param cwd - Current working directory for resolving relative paths
* @param builtInToolNames - Names of built-in tools to check for conflicts
*/
export async function loadCustomTools(
pathsWithSources: ToolPathWithSource[],
cwd: string,
builtInToolNames: string[],
pushPendingAction?: (action: {
label: string;
sourceToolName: string;
apply(reason: string): Promise<AgentToolResult<unknown>>;
reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
}) => void,
) {
const loader = new CustomToolLoader(
await import("@oh-my-pi/pi-coding-agent"),
cwd,
builtInToolNames,
pushPendingAction,
);
await loader.load(pathsWithSources);
return {
tools: loader.tools,
errors: loader.errors,
setUIContext: (uiContext: HookUIContext, hasUI: boolean) => {
loader.setUIContext(uiContext, hasUI);
},
};
}
/**
* Collect the absolute tool-source paths to load, without importing or
* binding factories. Hot path on session startup — the scan walks
* `.omp/tools/`, `.claude/tools/`, the plugin tree, and any configured paths.
*
* Subagents reuse the parent's collected paths via the SDK's
* `preloadedCustomToolPaths` option, then call `loadCustomTools` themselves
* so each session re-binds factories with its own session-scoped
* `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
*
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
* @param cwd - Current working directory
*/
export async function discoverCustomToolPaths(configuredPaths: string[], cwd: string): Promise<ToolPathWithSource[]> {
const allPathsWithSources: ToolPathWithSource[] = [];
const seen = new Set<string>();
// Helper to add paths without duplicates
const addPath = (p: string, source?: { provider: string; providerName: string; level: "user" | "project" }) => {
const resolved = path.resolve(p);
if (!seen.has(resolved)) {
seen.add(resolved);
allPathsWithSources.push({ path: p, source });
}
};
// 1. Discover tools via capability system (user + project from all providers)
const discoveredTools = await loadCapability<CustomTool>(toolCapability.id, { cwd });
for (const tool of discoveredTools.items) {
addPath(tool.path, {
provider: tool._source.provider,
providerName: tool._source.providerName,
level: tool.level,
});
}
// 2. Plugin tools: ~/.omp/plugins/node_modules/*/
for (const pluginPath of await getAllPluginToolPaths(cwd)) {
addPath(pluginPath, { provider: "plugin", providerName: "Plugin", level: "user" });
}
// 3. Explicitly configured paths (can override/add)
for (const configPath of configuredPaths) {
addPath(resolvePath(configPath, cwd), { provider: "config", providerName: "Config", level: "project" });
}
return allPathsWithSources;
}
/**
* Discover and load tools from standard locations via capability system:
* 1. User and project tools discovered by capability providers
* 2. Installed plugins (~/.omp/plugins/node_modules/*)
* 3. Explicitly configured paths from settings or CLI
*
* Composed of {@link discoverCustomToolPaths} (FS scan) + {@link loadCustomTools}
* (per-session binding). Subagents skip the first step and just call
* `loadCustomTools` against the parent's collected paths.
*
* @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
* @param cwd - Current working directory
* @param builtInToolNames - Names of built-in tools to check for conflicts
*/
export async function discoverAndLoadCustomTools(
configuredPaths: string[],
cwd: string,
builtInToolNames: string[],
pushPendingAction?: (action: {
label: string;
sourceToolName: string;
apply(reason: string): Promise<AgentToolResult<unknown>>;
reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
}) => void,
) {
const pathsWithSources = await discoverCustomToolPaths(configuredPaths, cwd);
return loadCustomTools(pathsWithSources, cwd, builtInToolNames, pushPendingAction);
}