Files
oh-my-pi/packages/coding-agent/src/session/session-tools.ts
T
roboomp e6cdfd6187 fix(session): cleared session-scoped tool state
Cleared queued tool-choice directives and ACP always decisions after successful logical session transitions.

Added new-session and cross-session regression coverage for staged resolves and both ACP always decisions.

Fixes #4093
2026-07-24 02:23:15 +02:00

939 lines
39 KiB
TypeScript

import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Model } from "@oh-my-pi/pi-ai";
import { logger, prompt, stringProperty } from "@oh-my-pi/pi-utils";
import { reset as resetCapabilities } from "../capability";
import type { ModelRegistry } from "../config/model-registry";
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 { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills";
import type { LocalProtocolOptions } from "../internal-urls";
import { resolveMemoryBackend } from "../memory-backend/resolve";
import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names";
import type { MemoryBackendStartOptions } from "../memory-backend/types";
import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" };
import { usesCodexTaskPrompt } from "../task/prompt-policy";
import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
import { wrapToolWithMetaNotice } from "../tools/output-meta";
import { ToolAbortError, ToolError } from "../tools/tool-errors";
import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
import { type EditMode, resolveEditMode } from "../utils/edit-mode";
import { formatLocalCalendarDate } from "../utils/local-date";
import {
extractPermissionLocations,
getPermissionIntent,
PERMISSION_OPTIONS,
PERMISSION_OPTIONS_BY_ID,
PERMISSION_REQUIRED_TOOLS,
} from "./acp-permission-gate";
import type { ClientBridge, ClientBridgePermissionOutcome } from "./client-bridge";
import type { CustomMessage } from "./messages";
import type { SessionManager } from "./session-manager";
/** Capabilities borrowed from the owning AgentSession. */
export interface SessionToolsHost {
agent: Agent;
sessionManager: SessionManager;
settings: Settings;
modelRegistry: ModelRegistry;
extensionRunner(): ExtensionRunner | undefined;
clientBridge(): ClientBridge | undefined;
agentKind(): "main" | "sub";
isDisposed(): boolean;
isStreaming(): boolean;
queuedMessageCount(): number;
planModeEnabled(): boolean;
model(): Model | undefined;
memoryBackendSession(): MemoryBackendStartOptions["session"];
clearInheritedProviderPromptCacheKey(): void;
clearMemoryPromotionSnapshot(): void;
captureMemoryPromotionSnapshot(prompt: string[]): void;
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
notifyCommandMetadataChanged(): void;
localProtocolOptions(): LocalProtocolOptions;
}
interface SessionToolsOptions {
autoApprove?: boolean;
toolRegistry?: Map<string, AgentTool>;
createVibeTools?: () => AgentTool[];
builtInToolNames?: Iterable<string>;
presentationPinnedToolNames?: ReadonlySet<string>;
ensureWriteRegistered?: () => Promise<boolean>;
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
getLocalCalendarDate?: () => string;
getMcpServerInstructions?: () => Map<string, string> | undefined;
xdevRegistry?: XdevRegistry;
initialMountedXdevToolNames?: string[];
setActiveToolNames?: (names: Iterable<string>) => void;
baseSystemPrompt: string[];
skills?: Skill[];
skillWarnings?: SkillWarning[];
skillsSettings?: SkillsSettings;
skillsReloadable?: boolean;
}
const XDEV_MOUNT_NOTICE_MESSAGE_TYPE = "xdev-mount-notice";
/** Owns tool registration, presentation, prompt rebuilding, skills, and permissions. */
export class SessionTools {
readonly #host: SessionToolsHost;
#autoApprove: boolean;
#toolRegistry: Map<string, AgentTool>;
#createVibeTools: (() => AgentTool[]) | undefined;
#installedVibeToolNames = new Set<string>();
#builtInToolNames: Set<string>;
#rpcHostToolNames = new Set<string>();
#xdevRegistry: XdevRegistry | undefined;
#mountedXdevToolNames: Set<string>;
#pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
#presentationPinnedToolNames: ReadonlySet<string> | undefined;
#runtimeSelectedToolNames: ReadonlySet<string> | undefined;
#baseSystemPrompt: string[];
#lastAppliedToolSignature: string | undefined;
#promptModelKey: string | undefined;
#rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"];
#getLocalCalendarDate: () => string;
#getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"];
#setActiveToolNames: SessionToolsOptions["setActiveToolNames"];
#ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"];
#skills: Skill[];
#skillWarnings: SkillWarning[];
#skillsSettings: SkillsSettings | undefined;
#skillsReloadable: boolean;
#acpPermissionDecisions = new Map<string, "allow_always" | "reject_always">();
constructor(host: SessionToolsHost, options: SessionToolsOptions) {
this.#host = host;
this.#autoApprove = options.autoApprove === true;
this.#toolRegistry = options.toolRegistry ?? new Map();
this.#createVibeTools = options.createVibeTools;
this.#builtInToolNames = new Set(options.builtInToolNames ?? []);
this.#presentationPinnedToolNames = options.presentationPinnedToolNames;
this.#ensureWriteRegistered = options.ensureWriteRegistered;
this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
this.#getMcpServerInstructions = options.getMcpServerInstructions;
this.#xdevRegistry = options.xdevRegistry;
this.#mountedXdevToolNames = new Set(options.initialMountedXdevToolNames ?? []);
this.#setActiveToolNames = options.setActiveToolNames;
this.#baseSystemPrompt = options.baseSystemPrompt;
this.#skills = options.skills ?? [];
this.#skillWarnings = options.skillWarnings ?? [];
this.#skillsSettings = options.skillsSettings;
this.#skillsReloadable = options.skillsReloadable ?? true;
this.#promptModelKey = this.#currentPromptModelKey();
}
/** Mutable registry shared with controller hosts that inspect available tools. */
get registry(): Map<string, AgentTool> {
return this.#toolRegistry;
}
/** Current stable base system prompt. */
get baseSystemPrompt(): string[] {
return this.#baseSystemPrompt;
}
/** Replaces the controller-owned base prompt without applying it to the agent. */
setBaseSystemPrompt(prompt: string[]): void {
this.#baseSystemPrompt = prompt;
}
/** Skills currently rendered into the system prompt. */
get skills(): Skill[] {
return this.#skills;
}
/** Diagnostics produced while loading the current skills. */
get skillWarnings(): SkillWarning[] {
return this.#skillWarnings;
}
/** Settings snapshot used for the current skill discovery. */
get skillsSettings(): SkillsSettings | undefined {
return this.#skillsSettings;
}
/** Drops cached per-session ACP `allow_always`/`reject_always` decisions. */
clearAcpPermissionDecisions(): void {
this.#acpPermissionDecisions.clear();
}
/** Re-wraps active and mounted tools after the ACP client changes. */
refreshAcpPermissionGates(): void {
this.#acpPermissionDecisions.clear();
const activeTools = this.getActiveToolNames()
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool !== undefined)
.map(tool => this.#wrapToolForAcpPermission(tool));
this.#host.agent.setTools(activeTools);
const mountedTools = [...this.#mountedXdevToolNames]
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool !== undefined)
.map(tool => this.#wrapToolForAcpPermission(tool));
this.#xdevRegistry?.reconcile(mountedTools);
}
#getActiveNonMCPToolNames(): string[] {
return this.getEnabledToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name));
}
/** Names of tools currently exposed at the top level. */
getActiveToolNames(): string[] {
return this.#host.agent.state.tools.map(t => t.name);
}
/** Enabled top-level and discoverable tool names. */
getEnabledToolNames(): string[] {
if (this.#mountedXdevToolNames.size === 0) return this.getActiveToolNames();
return [...this.getActiveToolNames(), ...this.#mountedXdevToolNames];
}
/** Names of dynamic tools mounted under `xd://`. */
getMountedXdevToolNames(): string[] {
return [...this.#mountedXdevToolNames];
}
/** Whether the edit tool is registered. */
get hasEditTool(): boolean {
return this.#toolRegistry.has("edit");
}
/** Looks up a registered tool by name. */
getToolByName(name: string): AgentTool | undefined {
return this.#toolRegistry.get(name);
}
/** Whether a registry entry came from a built-in factory. */
hasBuiltInTool(name: string): boolean {
return this.#builtInToolNames.has(name);
}
/** Names of every registered tool. */
getAllToolNames(): string[] {
return Array.from(this.#toolRegistry.keys());
}
#wrapRuntimeTool(tool: AgentTool): AgentTool {
const wrapped = wrapToolWithMetaNotice(tool);
const extensionRunner = this.#host.extensionRunner();
return extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped;
}
/** Installs and activates the ephemeral vibe tool set. */
async activateVibeTools(baseToolNames: string[]): Promise<void> {
const createVibeTools = this.#createVibeTools;
if (!createVibeTools) {
throw new Error("Vibe tools are unavailable in this session.");
}
const tools = createVibeTools();
const vibeToolNames = tools.map(tool => tool.name);
if (new Set(vibeToolNames).size !== vibeToolNames.length) {
throw new Error("Vibe tool names must be unique.");
}
for (const tool of tools) {
if (this.#toolRegistry.has(tool.name)) continue;
this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool));
this.#builtInToolNames.add(tool.name);
this.#installedVibeToolNames.add(tool.name);
}
await this.applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]);
}
/** Uninstalls vibe tools and activates the replacement set. */
async deactivateVibeTools(nextToolNames: string[]): Promise<void> {
this.#uninstallVibeTools();
await this.applyActiveToolsByName(nextToolNames);
}
/** Removes vibe tools without restoring a source-session snapshot. */
async removeVibeToolsPreservingActive(): Promise<void> {
const removed = new Set(this.#installedVibeToolNames);
this.#uninstallVibeTools();
const nextActive = this.getActiveToolNames().filter(name => !removed.has(name));
await this.applyActiveToolsByName(nextActive);
}
#uninstallVibeTools(): void {
for (const name of this.#installedVibeToolNames) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
}
this.#installedVibeToolNames.clear();
}
#getEditModeSession() {
return {
settings: this.#host.settings,
getActiveModelString: () => {
const model = this.#host.model();
return model ? formatModelString(model) : undefined;
},
} as const;
}
/** Resolves the edit mode for the active model and settings. */
resolveActiveEditMode(): EditMode {
return resolveEditMode(this.#getEditModeSession());
}
#currentPromptModelKey(): string | undefined {
const activeModel = this.#host.model();
const model = activeModel ? formatModelString(activeModel) : undefined;
if (!model || this.#host.settings.get("includeModelInPrompt")) return model;
return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default";
}
/** Rebuilds model-dependent tool prompts after a model change. */
async syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
const currentEditMode = this.resolveActiveEditMode();
const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
// The system prompt selects model-specific policy even when it does not display the model id.
const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
if (editModeChanged || modelChanged) {
await this.refreshBaseSystemPrompt();
}
}
/** Enabled MCP tools in their current presentation partition. */
getSelectedMCPToolNames(): string[] {
// Every connected MCP tool is enabled; presentation (top-level vs xd://) is
// decided by loadMode. Return the enabled MCP tools in the current set.
return this.getEnabledToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name));
}
/**
* Wrap a tool with a permission-gate proxy when an ACP client is connected.
* Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
* the bridge exposes `requestPermission`. No-ops for all other cases.
*
* When the user has explicitly opted into `yolo` / auto-approve behavior (via
* the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`),
* skips the gate unless the per-tool policy explicitly requires a prompt or
* deny. The schema default is also `yolo`, so an explicit configuration or
* explicit session flag is required: default-config ACP sessions keep the
* client-side permission gate.
*/
#wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
const bridge = this.#host.clientBridge();
// Match the capability+method gating pattern used by read/write/bash.
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
if (PERMISSION_REQUIRED_TOOLS[tool.name] !== true) return tool;
// Skip the gate only on explicit yolo opt-in; honour per-tool policies
// that require a prompt or deny (matching the normal approval wrapper).
if (this.#isExplicitAutoApproveMode()) {
const userPolicies = (this.#host.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
const toolPolicy = userPolicies[tool.name];
if (!toolPolicy || toolPolicy === "allow") return tool;
}
return new Proxy(tool, {
get: (target, prop) => {
if (prop !== "execute") return target[prop as keyof T];
return async (
toolCallId: string,
args: unknown,
signal: AbortSignal | undefined,
onUpdate: never,
ctx: never,
) => {
const permissionIntent = getPermissionIntent(target.name, args);
if (!permissionIntent) {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
const command =
target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
? stringProperty(args, "command")
: undefined;
const commandContent = command
? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
: undefined;
// Short-circuit on persisted decisions.
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
if (persisted === "allow_always") {
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
}
if (persisted === "reject_always") {
throw new ToolError(`Tool call rejected by user (preference)`);
}
if (signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
type PermissionRaceResult =
| { kind: "permission"; outcome: ClientBridgePermissionOutcome }
| { kind: "aborted" };
const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
const onAbort = () => resolveAbort({ kind: "aborted" });
signal?.addEventListener("abort", onAbort, { once: true });
let raced: PermissionRaceResult;
try {
const permissionPromise = bridge.requestPermission!(
{
toolCallId,
toolName: target.name,
title: permissionIntent.title,
...(target.name === "bash" ? { kind: "execute" } : {}),
status: "pending",
rawInput: args,
...(commandContent ? { content: commandContent } : {}),
locations: extractPermissionLocations(
args,
this.#host.sessionManager.getCwd(),
permissionIntent.paths,
),
},
PERMISSION_OPTIONS,
signal,
).then(outcome => ({ kind: "permission" as const, outcome }));
raced = await Promise.race([permissionPromise, abortPromise]);
} finally {
signal?.removeEventListener("abort", onAbort);
}
if (raced.kind === "aborted" || signal?.aborted) {
throw new ToolAbortError("Permission request cancelled");
}
const outcome = raced.outcome;
if (outcome.outcome === "cancelled") {
throw new ToolAbortError("Permission request cancelled");
}
const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
if (!selectedOption) {
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
}
if (selectedOption.kind === "allow_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
} else if (selectedOption.kind === "reject_always") {
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
}
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
throw new ToolError(`Tool call rejected by user (${target.name})`);
}
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
};
},
}) as T;
}
#isExplicitAutoApproveMode(): boolean {
return (
this.#autoApprove ||
(this.#host.settings.isConfigured("tools.approvalMode") &&
this.#host.settings.get("tools.approvalMode") === "yolo")
);
}
/** Applies an enabled tool set and reconciles its `xd://` partition. */
async applyActiveToolsByName(toolNames: string[]): Promise<void> {
toolNames = normalizeToolNames(toolNames);
const selectedTools = toolNames.flatMap(name => {
const tool = this.#toolRegistry.get(name);
return tool ? [{ name, tool }] : [];
});
const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read");
const isPresentationPinned = (name: string): boolean =>
this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
const mountCandidates = selectedTools.filter(
({ name, tool }) =>
this.#xdevRegistry !== undefined &&
xdevReadAvailable &&
!isPresentationPinned(name) &&
isMountableUnderXdev(tool),
);
let builtInWriteAvailable = this.#builtInToolNames.has("write");
if (mountCandidates.length > 0 && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
const mountedTools: AgentTool[] = [];
for (const { name, tool } of selectedTools) {
if (mountNames.has(name)) {
mountedTools.push(this.#wrapToolForAcpPermission(tool));
} else {
tools.push(this.#wrapToolForAcpPermission(tool));
validToolNames.push(name);
}
}
const pinnedWrite = isPresentationPinned("write");
const activeDeferrableTool = tools.some(tool => tool.deferrable === true);
const transportNeeded = mountedTools.length > 0 || activeDeferrableTool || this.#host.planModeEnabled();
if (transportNeeded && !builtInWriteAvailable) {
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
}
if (transportNeeded && builtInWriteAvailable) {
const write = this.#toolRegistry.get("write");
if (write && !validToolNames.includes("write")) {
tools.push(this.#wrapToolForAcpPermission(write));
validToolNames.push("write");
}
} else if (
!pinnedWrite &&
(this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined)
) {
const writeNameIndex = validToolNames.indexOf("write");
if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1);
const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write"));
if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
}
const previousMounted = this.#mountedXdevToolNames;
const previousMountedTools = [...previousMounted].flatMap(name => {
const tool = this.#xdevRegistry?.get(name);
return tool ? [tool] : [];
});
const previousActiveToolNames = this.getActiveToolNames();
this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
this.#xdevRegistry?.reconcile(mountedTools);
this.#setActiveToolNames?.(validToolNames);
let rebuiltSystemPrompt: string[] | undefined;
let rebuiltSignature: string | undefined;
try {
if (this.#rebuildSystemPrompt) {
const signature = this.#computeAppliedToolSignature(validToolNames, tools);
if (signature !== this.#lastAppliedToolSignature) {
const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
rebuiltSystemPrompt = built.systemPrompt;
rebuiltSignature = signature;
}
}
} catch (error) {
this.#mountedXdevToolNames = previousMounted;
this.#xdevRegistry?.reconcile(previousMountedTools);
this.#setActiveToolNames?.(previousActiveToolNames);
throw error;
}
this.#notifyXdevMountDelta(previousMounted);
this.#host.agent.setTools(tools);
if (rebuiltSystemPrompt && rebuiltSignature) {
if (this.#lastAppliedToolSignature !== undefined) this.#host.clearInheritedProviderPromptCacheKey();
this.#baseSystemPrompt = rebuiltSystemPrompt;
this.#host.clearMemoryPromotionSnapshot();
this.#host.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#lastAppliedToolSignature = rebuiltSignature;
this.#promptModelKey = this.#currentPromptModelKey();
}
}
/**
* Record a mid-session `xd://` mount delta for the model without rewriting
* the system prompt: the prompt (and its provider cache prefix) stays
* byte-stable across MCP connects and disconnects. The delta is NOT steered
* immediately — a steered notice landing at a run's stop boundary (or while
* the session is idle) forces an unsolicited extra assistant turn — it is
* coalesced into {@link #pendingXdevMountDelta} and rides along with the
* next prompt (docs + schema stay one `read xd://<tool>` away). The full
* docs join the system prompt opportunistically on the next unrelated
* rebuild.
*/
#notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
const registry = this.#xdevRegistry;
if (!registry) return;
const current = this.#mountedXdevToolNames;
const addedNames = [...current].filter(name => !previousMounted.has(name));
const removedNames = [...previousMounted].filter(name => !current.has(name));
if (addedNames.length === 0 && removedNames.length === 0) return;
// Coalesce against the unannounced delta: an unmount cancels a pending
// mount the model never learned about, and a remount cancels a pending
// unmount.
const pending = this.#pendingXdevMountDelta ?? { added: new Set<string>(), removed: new Set<string>() };
for (const name of addedNames) {
if (!pending.removed.delete(name)) pending.added.add(name);
}
for (const name of removedNames) {
if (!pending.added.delete(name)) pending.removed.add(name);
}
this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined;
if (this.#host.settings.get("startup.quiet")) return;
const parts: string[] = [];
if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`);
if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`);
this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev");
}
/** Consumes the hidden notice for unannounced `xd://` mount changes. */
takePendingXdevMountNotice(): CustomMessage | undefined {
const pending = this.#pendingXdevMountDelta;
if (!pending) return undefined;
this.#pendingXdevMountDelta = undefined;
const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
const removed = [...pending.removed].map(name => ({ name }));
return {
role: "custom",
customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
content: prompt.render(xdevMountNoticePrompt, { added, removed }),
attribution: "agent",
display: false,
timestamp: Date.now(),
};
}
/** Rediscovers reloadable skills and refreshes prompt metadata. */
async refreshSkills(): Promise<void> {
if (!this.#skillsReloadable) {
return;
}
resetCapabilities();
const skillsSettings = this.#host.settings.getGroup("skills");
const discovered = await loadSkills({
...skillsSettings,
cwd: this.#host.sessionManager.getCwd(),
disabledExtensions: this.#host.settings.get("disabledExtensions") ?? [],
});
this.#skills = discovered.skills;
this.#skillWarnings = discovered.warnings;
this.#skillsSettings = skillsSettings;
if (this.#host.agentKind() === "main") {
setActiveSkills(this.#skills);
}
await this.refreshBaseSystemPrompt();
this.#host.notifyCommandMetadataChanged();
}
/** Selects enabled tools, ignoring names absent from the registry. */
async setActiveToolsByName(toolNames: string[]): Promise<void> {
const normalized = normalizeToolNames(toolNames);
// Transport-write eligibility keys off the *current* active set: an ordinary
// selection change should not demote `write` unless it is already active.
await this.#applyToolPresentation(
normalized,
this.#mountedXdevToolNames,
this.getActiveToolNames().includes("write"),
);
}
/**
* Restore an enabled tool set with its exact top-level versus `xd://` partition.
*
* Both inputs are required because {@link setActiveToolsByName} only receives the
* enabled name list and classifies mounts from the current `#mountedXdevToolNames`.
* Rollback/restore callers must pass the snapshotted mounted subset so names that
* were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
* `xd://` remain mount-eligible, even when the live mount set has drifted.
*
* Names outside `mountedToolNames` are pinned top-level for this application;
* names in the mounted subset remain eligible for xdev mounting. Delegates the
* actual apply through {@link applyActiveToolsByName} and restores the prior runtime
* selection if that apply throws.
*/
async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
const normalized = normalizeToolNames(toolNames);
// Restoration targets a snapshot, so write eligibility comes from the
// *target* set rather than whatever happens to be active mid-rollback.
await this.#applyToolPresentation(
normalized,
new Set(normalizeToolNames(mountedToolNames)),
normalized.includes("write"),
);
}
/**
* Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
* pins non-mounted names as the runtime selection (holding `write` back when it is
* transport-only) and applies the set, rolling the selection back if apply throws.
*/
async #applyToolPresentation(
normalized: string[],
mounted: ReadonlySet<string>,
writeSelected: boolean,
): Promise<void> {
const transportWriteActive =
writeSelected &&
this.#builtInToolNames.has("write") &&
this.#presentationPinnedToolNames?.has("write") !== true &&
this.#runtimeSelectedToolNames?.has("write") !== true &&
(mounted.size > 0 || this.#host.planModeEnabled());
const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames;
this.#runtimeSelectedToolNames = new Set(
normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)),
);
try {
await this.applyActiveToolsByName(normalized);
} catch (error) {
this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames;
throw error;
}
}
/** Replaces memory-backend tools while preserving unrelated selections. */
async replaceMemoryTools(tools: AgentTool[]): Promise<void> {
const removed = new Set<string>(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name)));
const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name));
for (const name of removed) {
this.#toolRegistry.delete(name);
this.#builtInToolNames.delete(name);
}
for (const tool of tools) {
if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) {
continue;
}
const wrapped = this.#wrapRuntimeTool(tool);
this.#toolRegistry.set(wrapped.name, wrapped);
this.#builtInToolNames.add(wrapped.name);
nextActive.push(wrapped.name);
}
await this.applyActiveToolsByName([...new Set(nextActive)]);
}
/** Rebuilds the stable base prompt for the current tools and model. */
async refreshBaseSystemPrompt(): Promise<void> {
if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return;
const activeToolNames = this.getActiveToolNames();
this.#setActiveToolNames?.(activeToolNames);
const previousBaseSystemPrompt = this.#baseSystemPrompt;
const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
if (this.#host.isDisposed()) return;
this.#baseSystemPrompt = built.systemPrompt;
this.#host.clearMemoryPromotionSnapshot();
if (
previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length ||
previousBaseSystemPrompt.some((part, index) => part !== this.#baseSystemPrompt[index])
) {
this.#host.clearInheritedProviderPromptCacheKey();
}
this.#host.agent.setSystemPrompt(this.#baseSystemPrompt);
this.#promptModelKey = this.#currentPromptModelKey();
// Refresh the cached signature so a subsequent `applyActiveToolsByName` with
// the same tool set does not re-rebuild on top of the explicit refresh we
// just performed (and conversely, a different set forces a fresh rebuild).
const activeTools = activeToolNames
.map(name => this.#toolRegistry.get(name))
.filter((tool): tool is AgentTool => tool != null);
this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
}
/** Applies one-turn memory prompt injection before an agent run. */
async buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
const backend = await resolveMemoryBackend(this.#host.settings);
if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt;
try {
const injected = await backend.beforeAgentStartPrompt(this.#host.memoryBackendSession(), promptText);
if (!injected) return this.#baseSystemPrompt;
const previousBaseSystemPrompt = this.#baseSystemPrompt;
try {
await this.refreshBaseSystemPrompt();
} catch (refreshErr) {
logger.debug("Memory backend prompt refresh after beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(refreshErr),
});
}
if (
this.#baseSystemPrompt.length !== previousBaseSystemPrompt.length ||
this.#baseSystemPrompt.some((part, index) => part !== previousBaseSystemPrompt[index])
) {
return this.#baseSystemPrompt;
}
this.#host.captureMemoryPromotionSnapshot(previousBaseSystemPrompt);
const stablePrompt = [...previousBaseSystemPrompt, injected];
this.#baseSystemPrompt = stablePrompt;
this.#host.agent.setSystemPrompt(stablePrompt);
return stablePrompt;
} catch (err) {
logger.debug("Memory backend beforeAgentStartPrompt failed", {
backend: backend.id,
error: String(err),
});
return this.#baseSystemPrompt;
}
}
/**
* Compose a stable signature for the inputs that `rebuildSystemPrompt` reads.
* Two calls producing identical signatures are guaranteed to produce identical
* system prompt bytes, so the rebuild can be skipped.
*
* The signature covers:
* 1. Active tool names in order (the prompt renders them in this order).
* 2. Active tool labels, descriptions, and wire-visible names — all are
* rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\``
* and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from
* `tool.customWireName` and overrides the internal name on the model wire
* (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode);
* a stale wire name would desync prompt guidance from actual tool routing.
* 3. When MCP discovery is on, every registry tool's name+label+description+
* customWireName, since `rebuildSystemPrompt` summarizes discoverable MCP
* tools that are not in the active set.
* 4. MCP server instructions text (per server), since `rebuildSystemPrompt`
* embeds these in the appended prompt under "## MCP Server Instructions".
* A server upgrade can change instructions while keeping tools identical.
*
* Settings-driven tool metadata is covered automatically: built-in tools that
* depend on settings expose `description`/`label` via getters (see `TaskTool`,
* `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every
* call - so a settings flip that mutates the rendered string differs the signature
* the next time {@link applyActiveToolsByName} runs. Do not refactor `describeTool`
* to cache per-tool strings without preserving this property.
*
* Inputs NOT covered: tool input schemas; memory instructions read from disk;
* and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`,
* `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
* closure-captured ones cannot change at runtime regardless of skip behavior.
* For everything else, callers must explicitly call {@link refreshBaseSystemPrompt}
* after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}.
*
* The current calendar date IS covered (appended as a segment) because
* `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
* Without this, a session spanning midnight with only tool-stable MCP
* reconnects would keep yesterday's date indefinitely.
*/
#computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
// Order-preserving join: any reorder must produce a different signature so
// the rebuild fires and the new tool list reaches the API.
const nameSegment = toolNames.join("\u0001");
const describeTool = (tool: AgentTool): string =>
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
const descriptionSegment = tools.map(describeTool).join("\u0002");
let instructionsSegment = "";
const serverInstructions = this.#getMcpServerInstructions?.();
if (serverInstructions && serverInstructions.size > 0) {
// Sort by server name so transport flap order does not perturb the signature.
const entries: string[] = [];
for (const [server, instructions] of serverInstructions) {
entries.push(`${server}=${instructions}`);
}
entries.sort();
instructionsSegment = entries.join("\u0006");
}
// The xd:// device inventory is deliberately NOT part of the signature:
// a mount/unmount announces itself via `#notifyXdevMountDelta` instead of
// rewriting the system prompt, so MCP connects/disconnects keep the
// prompt (and its provider cache prefix) byte-stable. Rebuilds triggered
// by other inputs pick up the current device docs opportunistically.
const date = this.#getLocalCalendarDate();
return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}|${date}`;
}
/**
* Replace MCP tools in the registry and enable them immediately. Every
* connected MCP tool becomes available (mounted under `xd://` when that
* transport is active, else top-level). Lets `/mcp add/remove/reauth` take
* effect without restarting the session.
*/
async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
const existingNames = Array.from(this.#toolRegistry.keys());
const previousMcpTools = new Map(
existingNames.flatMap(name => {
const tool = this.#toolRegistry.get(name);
return isMCPToolName(name) && tool ? [[name, tool] as const] : [];
}),
);
for (const name of existingNames) {
if (isMCPToolName(name)) {
this.#toolRegistry.delete(name);
}
}
const getCustomToolContext = (): CustomToolContext => ({
sessionManager: this.#host.sessionManager,
modelRegistry: this.#host.modelRegistry,
model: this.#host.model(),
isIdle: () => !this.#host.isStreaming(),
hasQueuedMessages: () => this.#host.queuedMessageCount() > 0,
abort: () => {
this.#host.agent.abort();
},
settings: this.#host.settings,
localProtocolOptions: this.#host.localProtocolOptions(),
});
const extensionRunner = this.#host.extensionRunner();
for (const customTool of mcpTools) {
const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
const finalTool = (
extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
}
// Every connected MCP tool is selected; centralized repartitioning owns
// presentation pins and write-transport activation/removal.
const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...mcpTools.map(tool => tool.name)])];
try {
await this.applyActiveToolsByName(nextActive);
} catch (error) {
for (const name of this.#toolRegistry.keys()) {
if (isMCPToolName(name)) this.#toolRegistry.delete(name);
}
for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool);
throw error;
}
}
/** Replaces RPC host-owned tools and refreshes the active set before the next model call. */
async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
const nextToolNames = rpcTools.map(tool => tool.name);
const uniqueToolNames = new Set(nextToolNames);
if (uniqueToolNames.size !== nextToolNames.length) {
throw new Error("RPC host tool names must be unique");
}
for (const name of uniqueToolNames) {
if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
throw new Error(`RPC host tool "${name}" conflicts with an existing tool`);
}
}
const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
const previousActiveToolNames = this.getEnabledToolNames();
const previousRpcHostTools = new Map(
[...previousRpcHostToolNames].flatMap(name => {
const tool = this.#toolRegistry.get(name);
return tool ? [[name, tool] as const] : [];
}),
);
for (const name of previousRpcHostToolNames) {
this.#toolRegistry.delete(name);
}
this.#rpcHostToolNames.clear();
const extensionRunner = this.#host.extensionRunner();
for (const tool of rpcTools) {
const metaWrapped = wrapToolWithMetaNotice(tool);
const finalTool = (
extensionRunner ? new ExtensionToolWrapper(metaWrapped, extensionRunner) : metaWrapped
) as AgentTool;
this.#toolRegistry.set(finalTool.name, finalTool);
this.#rpcHostToolNames.add(finalTool.name);
}
const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name));
const preservedRpcToolNames = previousActiveToolNames.filter(
name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name),
);
const autoActivatedRpcToolNames = rpcTools
.filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
.map(tool => tool.name);
try {
await this.applyActiveToolsByName(
Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
);
} catch (error) {
for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name);
this.#rpcHostToolNames = previousRpcHostToolNames;
for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool);
throw error;
}
}
}