Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/coding-agent/docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1405,6 +1405,16 @@ Register a custom tool callable by the LLM. See [Custom Tools](#custom-tools) fo

Use `pi.setActiveTools()` to enable or disable tools (including dynamically added tools) at runtime.

Set `exposure` on a definition to control how it reaches the model:

| `exposure` | Behavior |
| --- | --- |
| `direct` (default) | Declared like any other tool, and active at session start. |
| `deferred` | Registered but not declared. The Step tool profile's `find_tools` loads it, and naming it in the tool settings, `--tools`, or `pi.setActiveTools()` activates it. |
| `hidden` | Registered but unreachable: left out of `pi.getAllTools()` and never activated automatically. |

The tools of MCP servers are `deferred` by default, so a session can keep a large catalogue out of the prompt; `find_tools` loads the ones a turn needs. An extension that publishes deferred tools should also make the deferred list discoverable, as the MCP extension does with its system-prompt section.

Use `promptSnippet` to opt a custom tool into a one-line entry in `Available tools`, and `promptGuidelines` to append tool-specific bullets to the default `Guidelines` section when the tool is active.

**Important:** `promptGuidelines` bullets are appended flat to the `Guidelines` section with no tool name prefix. Each guideline must name the tool it refers to — avoid "Use this tool when..." because the LLM cannot tell which tool "this" means. Write "Use my_tool when..." instead.
Expand Down
25 changes: 24 additions & 1 deletion packages/coding-agent/docs/mcp-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,30 @@ Step listens for `notifications/tools/list_changed` from the start of the connec

A closed connection removes its tools and changes its status to failed. Session shutdown removes its MCP registrations and closes the clients. Results that settle after cancellation or connection closure cannot publish a catalog. Calls already in progress retain their captured tool definition when a live catalog is updated or a tool is removed. This behavior does not add an automatic reconnect policy.

`find_tools` reads the currently active session tools on every invocation, so late MCP and extension registrations become discoverable. Inactive and removed tools disappear from search. Each match includes its callable name, description, and JSON parameter schema. The optional `ExtensionContext.getToolCatalog()` accessor supplies this metadata through the runner's existing session actions. Direct SDK callers whose context does not provide that accessor retain the builtin Step catalog fallback.
## Tool exposure

Every server tool is registered as `<server>__<sanitized-tool>`. A server's `exposure` in `config.toml` decides how the model reaches it:

| `exposure` | Behavior |
| --- | --- |
| `deferred` (default) | Registered but not declared. `find_tools` loads the matches, which become callable on the next turn. |
| `direct` | Declared like any other active tool, for small and frequently called servers. |
| `hidden` | Registered but unreachable; left out of the tool catalog. |

The optional `description` becomes the one-line summary in the `mcp_servers` system-prompt section, which lists the servers whose tools are deferred and names `find_tools` as the way to reach them. Without that section the model cannot know the servers exist, because neither `find_tools` nor the declared tool list names them.

```toml
[mcp_servers.github]
url = "https://api.githubcopilot.com/mcp/"
description = "GitHub issues and pull requests"
# exposure = "direct"
```

## find_tools

`find_tools` ranks the deferred tools plus the active ones by intent, tool name, description, and parameter names, and activates the deferred matches so the next request declares them. Activation is recorded on the tool result, so it survives resume and fork on that branch. Removed tools disappear from search. Each match includes its callable name, description, and JSON parameter schema.

The optional `ExtensionContext.getActiveTools()`, `getAllTools()`, and `setActiveTools()` accessors supply the catalog and activation through the runner's existing session actions. Direct SDK callers whose context provides only `getToolCatalog()` get the ranked answer without activation, and hosts with neither retain the builtin Step catalog fallback.

Extensions can replace part of their own catalog with the existing batch API:

Expand Down
15 changes: 12 additions & 3 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1909,12 +1909,13 @@ export class AgentSession {
getAllTools(): ToolInfo[] {
const unavailable = this._extensionRunner.getUnavailableToolNames();
return Array.from(this._toolDefinitions.values())
.filter(({ definition }) => !unavailable.has(definition.name))
.filter(({ definition }) => !unavailable.has(definition.name) && definition.exposure !== "hidden")
.map(({ definition, sourceInfo }) => ({
name: definition.name,
description: definition.description,
parameters: definition.parameters,
promptGuidelines: definition.promptGuidelines,
exposure: definition.exposure,
sourceInfo,
}));
}
Expand Down Expand Up @@ -4076,6 +4077,12 @@ export class AgentSession {
const excludedToolNames = this._excludedToolNames;
const isAllowedTool = (name: string): boolean =>
(!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);
// A tool that is deferred or hidden stays registered but is not declared until the session
// activates it, through the tool settings, an allowlist, or another tool's activation.
const isDefaultActive = (name: string): boolean => {
const exposure = this._toolDefinitions.get(name)?.definition.exposure;
return exposure !== "deferred" && exposure !== "hidden";
};

const registeredTools = this._extensionRunner.getAllRegisteredTools();
const allCustomTools = [
Expand Down Expand Up @@ -4142,20 +4149,22 @@ export class AgentSession {
).filter((name) => isAllowedTool(name));

if (allowedToolNames && options?.includeAllExtensionTools) {
// The allowlist is an explicit selection, so it activates a tool that opted out of the
// default set.
for (const toolName of this._toolRegistry.keys()) {
if (allowedToolNames.has(toolName)) {
nextActiveToolNames.push(toolName);
}
}
} else if (options?.includeAllExtensionTools) {
for (const tool of wrappedExtensionTools) {
nextActiveToolNames.push(tool.name);
if (isDefaultActive(tool.name)) nextActiveToolNames.push(tool.name);
}
} else {
// Preserve selections for known tools during refresh/reload; only genuinely
// new registrations opt in automatically after initial runtime creation.
for (const toolName of this._toolRegistry.keys()) {
if (!previousRegistryNames.has(toolName)) {
if (!previousRegistryNames.has(toolName) && isDefaultActive(toolName)) {
nextActiveToolNames.push(toolName);
}
}
Expand Down
12 changes: 12 additions & 0 deletions packages/coding-agent/src/core/extensions/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,18 @@ export class ExtensionRunner {
const active = new Set(runner.runtime.getActiveTools());
return runner.runtime.getAllTools().filter((tool) => active.has(tool.name));
},
getActiveTools: () => {
runner.assertActive();
return runner.runtime.getActiveTools();
},
getAllTools: () => {
runner.assertActive();
return runner.runtime.getAllTools();
},
setActiveTools: (toolNames) => {
runner.assertActive();
runner.runtime.setActiveTools(toolNames);
},
get autoRetryEnabled() {
runner.assertActive();
return runner.getAutoRetryEnabledFn?.();
Expand Down
22 changes: 21 additions & 1 deletion packages/coding-agent/src/core/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,12 @@ export interface ExtensionContext {
getSystemPrompt(): string;
/** Read the currently active tool definitions at call time, when the host provides a live catalog. */
getToolCatalog?(): readonly ToolInfo[];
/** Currently active tool names, when the host exposes the live tool set. */
getActiveTools?(): string[];
/** Every registered tool, including deferred and hidden ones, when the host exposes it. */
getAllTools?(): ToolInfo[];
/** Activate tools by name, when the host allows a tool to load others. */
setActiveTools?(toolNames: string[]): void;
/** Whether Pi's native provider retry loop is enabled, when exposed by the host. */
readonly autoRetryEnabled?: boolean;
/** Toggle Pi's native provider retry loop, when exposed by the host. */
Expand Down Expand Up @@ -558,6 +564,11 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
isError: boolean;
}

/**
* How a registered tool reaches the model. See `ToolDefinition.exposure`.
*/
export type ToolExposure = "direct" | "deferred" | "hidden";

/**
* Tool definition for registerTool().
*/
Expand All @@ -579,6 +590,15 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
/** Controls whether ToolExecutionComponent renders the standard colored shell or the tool renders its own framing. */
renderShell?: "default" | "self";

/**
* How this tool reaches the model.
* - `direct`: declared like any other tool, and active at session start. The default.
* - `deferred`: registered but not declared; `find_tools` loads it, and naming it in the
* tool settings or an allowlist activates it.
* - `hidden`: registered but unreachable until an extension activates it explicitly.
*/
exposure?: ToolExposure;

/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */
prepareArguments?: (args: unknown) => Static<TParams>;

Expand Down Expand Up @@ -1819,7 +1839,7 @@ export type GetSessionNameHandler = () => string | undefined;
export type GetActiveToolsHandler = () => string[];

/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines" | "exposure"> & {
sourceInfo: SourceInfo;
};

Expand Down
Loading
Loading