Merge PR #7735: fix(coding-agent): return ToolInfo[] from getAllTools extension API (@roboomp)
This commit is contained in:
@@ -27,6 +27,9 @@
|
||||
- Fixed Codex web search sending GPT-5.6 models a Responses-Lite request shape that the hosted `web_search` tool ignores. ([#7666](https://github.com/can1357/oh-my-pi/issues/7666))
|
||||
- Fixed resumed or rebuilt sessions auto-applying a new checkpoint with a stale rewind report from an earlier completed checkpoint cycle ([#7739](https://github.com/can1357/oh-my-pi/issues/7739)).
|
||||
- Fixed `read` treating semicolon-delimited internal URLs, such as batched `skill://` resources, as one invalid resource.
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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
|
||||
// ============================================================================
|
||||
@@ -1268,8 +1298,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>;
|
||||
@@ -1465,7 +1495,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();
|
||||
|
||||
@@ -3194,7 +3194,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user