fix(coding-agent): returned ToolInfo[] from getAllTools extension API

The ExtensionAPI getAllTools() wired to session.getAllToolNames(),
returning bare tool-name strings. Upstream @earendil-works/pi-coding-agent
promises ToolInfo[] with sourceInfo, so extensions loaded through the
legacy-pi shim (e.g. gentle-pi) crashed on t.sourceInfo.source at every
session start.

Added SourceInfo/ToolInfo types plus SessionTools.getAllToolInfos(), which
returns { name, description, parameters, sourceInfo } and classifies each
tool as builtin/mcp/sdk/extension. Rewired every getAllTools action site
(interactive, acp, print/rpc, subagent executor) and the example extension.

Fixes #7732
This commit is contained in:
roboomp
2026-08-05 15:42:06 +00:00
parent 1e492d6ff9
commit 83496b8211
11 changed files with 168 additions and 13 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `pi.getAllTools()` returning bare tool-name strings instead of `ToolInfo[]`, which crashed extensions authored against the upstream `@earendil-works/pi-coding-agent` contract (e.g. gentle-pi's startup banner: `undefined is not an object (evaluating 't.sourceInfo.source')`). The ExtensionAPI now returns `{ name, description, parameters, promptGuidelines, sourceInfo }` objects with `sourceInfo.source` classifying each tool as `builtin`/`sdk`/`mcp`/`extension` ([#7732](https://github.com/can1357/oh-my-pi/issues/7732)).
## [17.2.9] - 2026-08-05
### Breaking Changes
@@ -36,7 +36,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
// Find the last tools-config entry in the current branch
async function restoreFromBranch(ctx: ExtensionContext) {
allTools = pi.getAllTools();
allTools = pi.getAllTools().map(t => t.name);
// Get entries in current branch only
const branchEntries = ctx.sessionManager.getBranch();
@@ -66,7 +66,7 @@ export default function toolsExtension(pi: ExtensionAPI) {
description: "Enable/disable tools",
handler: async (_args, ctx) => {
// Refresh tool list
allTools = pi.getAllTools();
allTools = pi.getAllTools().map(t => t.name);
await ctx.ui.custom((tui, theme, _keybindings, done) => {
// Build settings items for each tool
@@ -46,6 +46,7 @@ import type {
ProviderConfig,
RegisteredCommand,
ToolDefinition,
ToolInfo,
} from "./types";
installLegacyPiSpecifierShim();
@@ -92,7 +93,7 @@ export class ExtensionRuntime implements IExtensionRuntime {
throw new ExtensionRuntimeNotInitializedError();
}
getAllTools(): string[] {
getAllTools(): ToolInfo[] {
throw new ExtensionRuntimeNotInitializedError();
}
@@ -245,7 +246,7 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime {
return this.runtime.getActiveTools();
}
getAllTools(): string[] {
getAllTools(): ToolInfo[] {
return this.runtime.getAllTools();
}
@@ -596,6 +596,36 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
) => Component;
}
/** Whether a tool's source is scoped to the user, the project, or a transient runtime session. */
export type SourceScope = "user" | "project" | "temporary";
/** Whether a tool's source came from an installed package or a top-level (loose) file. */
export type SourceOrigin = "package" | "top-level";
/**
* Provenance metadata describing where a registered tool came from. Mirrors the
* `@earendil-works/pi-coding-agent` `SourceInfo` contract so extensions authored
* against upstream pi (e.g. gentle-pi) can read `sourceInfo.source` unchanged.
*/
export interface SourceInfo {
/** Synthetic or on-disk identifier for the tool's origin (e.g. `<builtin:read>`). */
path: string;
/** Origin class: `"builtin"`, `"sdk"`, `"mcp"`, or `"extension"`. */
source: string;
scope: SourceScope;
origin: SourceOrigin;
baseDir?: string;
}
/** Tool metadata returned by {@link ExtensionAPI.getAllTools}: identity, schema, and source provenance. */
export interface ToolInfo {
name: string;
description: string;
parameters: TSchema;
promptGuidelines?: string[];
sourceInfo: SourceInfo;
}
// ============================================================================
// Resource Events
// ============================================================================
@@ -1267,8 +1297,8 @@ export interface ExtensionAPI {
/** Get the list of currently active tool names. */
getActiveTools(): string[];
/** Get all configured tools (built-in + extension tools). */
getAllTools(): string[];
/** Get all configured tools (built-in + extension tools) with schema and source metadata. */
getAllTools(): ToolInfo[];
/** Set the active tools by name. */
setActiveTools(toolNames: string[]): Promise<void>;
@@ -1464,7 +1494,7 @@ export type AppendEntryHandler = <T = unknown>(customType: string, data?: T) =>
export type GetActiveToolsHandler = () => string[];
export type GetAllToolsHandler = () => string[];
export type GetAllToolsHandler = () => ToolInfo[];
export type GetCommandsHandler = () => SlashCommandInfo[];
@@ -2348,7 +2348,7 @@ export class AcpAgent implements Agent {
record.session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => record.session.getEnabledToolNames(),
getAllTools: () => record.session.getAllToolNames(),
getAllTools: () => record.session.getAllToolInfos(),
setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
getCommands: () => getSessionSlashCommands(record.session),
setModel: async model => {
@@ -166,7 +166,7 @@ export class ExtensionUiController {
this.ctx.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => this.ctx.session.getEnabledToolNames(),
getAllTools: () => this.ctx.session.getAllToolNames(),
getAllTools: () => this.ctx.session.getAllToolInfos(),
setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
setModel: async model => {
const key = await this.ctx.session.modelRegistry.getApiKey(model);
@@ -399,7 +399,7 @@ export class ExtensionUiController {
this.ctx.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => this.ctx.session.getEnabledToolNames(),
getAllTools: () => this.ctx.session.getAllToolNames(),
getAllTools: () => this.ctx.session.getAllToolInfos(),
setActiveTools: toolNames => this.ctx.session.setActiveToolsByName(toolNames),
setModel: async model => {
const key = await this.ctx.session.modelRegistry.getApiKey(model);
@@ -84,7 +84,7 @@ export async function initializeExtensions(session: AgentSession, options: Initi
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getEnabledToolNames(),
getAllTools: () => session.getAllToolNames(),
getAllTools: () => session.getAllToolInfos(),
setActiveTools: (toolNames: string[]) => session.setActiveToolsByName(toolNames),
getCommands: () => getSessionSlashCommands(session),
setModel: model => runExtensionSetModel(session, model),
@@ -127,6 +127,7 @@ import type {
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
ToolInfo,
TreePreparation,
TurnEndEvent,
TurnStartEvent,
@@ -4181,6 +4182,11 @@ export class AgentSession {
return this.#tools.getAllToolNames();
}
/** Full metadata for every registered tool, including source provenance (backs `getAllTools()`). */
getAllToolInfos(): ToolInfo[] {
return this.#tools.getAllToolInfos();
}
/** Installs and activates the ephemeral vibe tool set. */
activateVibeTools(baseToolNames: string[]): Promise<void> {
return this.#tools.activateVibeTools(baseToolNames);
@@ -7,7 +7,7 @@ import { formatModelString } from "../config/model-resolver";
import type { Settings, SkillsSettings } from "../config/settings";
import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper";
import type { ExtensionRunner } from "../extensibility/extensions";
import type { ExtensionRunner, SourceInfo, ToolInfo } from "../extensibility/extensions";
import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
import { type LocalProtocolOptions, XD_URL_PREFIX } from "../internal-urls";
@@ -333,6 +333,33 @@ export class SessionTools {
return Array.from(this.#toolRegistry.keys());
}
/**
* Full metadata for every registered tool, including source provenance.
*
* Backs the `getAllTools()` ExtensionAPI method. Returns {@link ToolInfo}
* objects (not bare names) so extensions authored against upstream
* `@earendil-works/pi-coding-agent` — which promises `ToolInfo[]` — can read
* `sourceInfo.source` unchanged.
*/
getAllToolInfos(): ToolInfo[] {
return Array.from(this.#toolRegistry, ([name, tool]) => {
const source = this.#builtInToolNames.has(name)
? "builtin"
: isMCPToolName(name)
? "mcp"
: this.#rpcHostToolNames.has(name)
? "sdk"
: "extension";
const sourceInfo: SourceInfo = {
path: `<${source}:${name}>`,
source,
scope: "temporary",
origin: "top-level",
};
return { name, description: tool.description, parameters: tool.parameters, sourceInfo };
});
}
#wrapRuntimeTool(tool: AgentTool): AgentTool {
const wrapped = wrapToolWithMetaNotice(tool);
const extensionRunner = this.#host.extensionRunner();
+1 -1
View File
@@ -3179,7 +3179,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
session.sessionManager.appendLabelChange(targetId, label);
},
getActiveTools: () => session.getEnabledToolNames(),
getAllTools: () => session.getAllToolNames(),
getAllTools: () => session.getAllToolInfos(),
setActiveTools: (toolNames: string[]) =>
session.setActiveToolsByName(toolNames.filter(name => !isParentOwnedTool(name))),
getCommands: () => getSessionSlashCommands(session),
@@ -0,0 +1,87 @@
import { describe, expect, it } from "bun:test";
import * as path from "node:path";
import { type } from "@oh-my-pi/omptype";
import { Agent, type AgentTool } from "@oh-my-pi/pi-agent-core";
import { createMockModel } from "@oh-my-pi/pi-ai/providers/mock";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage";
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { TempDir } from "@oh-my-pi/pi-utils";
function createTool(name: string): AgentTool {
return {
name,
label: name,
description: `${name} tool`,
parameters: type({}),
async execute() {
return { content: [{ type: "text", text: name }] };
},
};
}
describe("AgentSession.getAllToolInfos", () => {
it("returns ToolInfo objects with sourceInfo so upstream-pi extensions read sourceInfo.source", async () => {
const tempDir = TempDir.createSync("@getalltools-toolinfo-");
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const settings = Settings.isolated({ "compaction.enabled": false });
const model = buildModel({
id: "mock",
name: "mock",
api: "openai-responses",
provider: "openai",
baseUrl: "https://example.invalid",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 2048,
});
const read = createTool("read");
const custom = createTool("my_ext_tool");
const agent = new Agent({
getApiKey: () => "test-key",
initialState: { model, systemPrompt: ["initial"], tools: [read] },
streamFn: createMockModel({ responses: [{ content: ["ok"] }] }).stream,
});
// `read` is a built-in; `my_ext_tool` is registered without being marked
// built-in, so it must classify as an extension-sourced tool.
const toolRegistry = new Map<string, AgentTool>([
[read.name, read],
[custom.name, custom],
]);
const session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(tempDir.path()),
settings,
modelRegistry: new ModelRegistry(authStorage, path.join(tempDir.path(), "models.yml")),
toolRegistry,
builtInToolNames: [read.name],
rebuildSystemPrompt: async toolNames => ({ systemPrompt: [toolNames.join(",")] }),
});
try {
const allTools = session.getAllToolInfos();
const byName = new Map(allTools.map(t => [t.name, t]));
expect(byName.get("read")?.sourceInfo.source).toBe("builtin");
expect(byName.get("my_ext_tool")?.sourceInfo.source).toBe("extension");
// ToolInfo carries schema + description, not just a name.
expect(byName.get("read")?.description).toBe("read tool");
expect(byName.get("read")?.parameters).toBeDefined();
// gentle-pi's startup-banner.ts filter must not throw and must treat
// only non-builtin/non-sdk tools as "custom".
const customTools = allTools.filter(t => !["builtin", "sdk"].includes(t.sourceInfo.source));
expect(customTools.map(t => t.name)).toEqual(["my_ext_tool"]);
} finally {
await session.dispose();
authStorage.close();
tempDir.removeSync();
}
});
});