/** * lazy-tools 功能模块(合并自 lazy-tools) * * Pi 默认把全部工具(内置 + 所有扩展)全量塞进请求体 tools[](实测 ~69KB / 17K tokens, * 其中 subagent 一个就占 37%)。本模块把这些大工具变成按需激活: * * - 常驻集(默认基础小工具)始终激活,请求体很小 * - 其余工具通过 `lazy` 代理工具按需激活,激活后整个会话保持 * - externallyManaged 工具由 owning extension 决定 active,本模块只保留当前状态 * - 常驻集可在 cache-stack.json 的 lazyTools.alwaysActive 追加(合并语义,不能删核心工具) * * 常驻集和 externallyManaged 之外的已注册工具自动视为懒加载(对未来新增扩展同样生效)。 * lazy 工具名与命令名保持合并前不变(模型已在用)。 * prompt cache、session affinity 和 deferred tool serialization 由官方 Pi/provider 负责; * ChatGPT OAuth/OpenAI 适配器可自行使用 additional_tools/tool-search,非 native provider * 只获得当前 active tools 的请求体缩减,不获得完整 cache-hit 保证。 * 本模块只提供工具搜索、active-tool policy 和兼容性诊断。 */ import { Type } from "typebox"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, SessionEntry, ToolInfo, } from "@earendil-works/pi-coding-agent"; import { DEFAULT_ALWAYS_ACTIVE, type EffectiveLazyToolsConfig } from "./config.ts"; export const PROXY_TOOL_NAME = "lazy"; export const ACTIVATION_ENTRY_TYPE = "pi-cache-stack.activation-state.v1"; type ActiveToolsTransition = { mode: "initial" | "native-candidate" | "fallback" | "no-op"; reason: string; }; export interface ActivationStateV1 { version: 1; activatedTools: string[]; } interface LazyToolsState { pi: ExtensionAPI; alwaysActive: Set; disabled: Set; /** Tools whose active/inactive eligibility is owned by another extension. */ externallyManaged: Set; /** Tools the LLM has activated this session (beyond alwaysActive). */ activated: Set; /** All tool names currently registered (snapshot, refreshed lazily). */ knownTools: Map; /** Last branch snapshot seen or written by this extension instance. */ lastPersistedActivation?: ActivationStateV1; /** Last policy transition, exposed through lazy status diagnostics. */ lastTransition?: ActiveToolsTransition; } export function getAlwaysActiveTools(cfg: EffectiveLazyToolsConfig): Set { // Merge configured always-active tools with the defaults rather than replacing // them: a config like {"alwaysActive": ["bash"]} must not silently drop // read/write/edit. Explicit disabled entries are removed afterward. const configured = cfg.alwaysActive && cfg.alwaysActive.length > 0 ? cfg.alwaysActive : []; const disabled = new Set(cfg.disabled ?? []); return new Set( [...DEFAULT_ALWAYS_ACTIVE, ...configured].filter((name) => !disabled.has(name)), ); } function createState(pi: ExtensionAPI, cfg: EffectiveLazyToolsConfig): LazyToolsState { return { pi, alwaysActive: getAlwaysActiveTools(cfg), disabled: new Set(cfg.disabled ?? []), externallyManaged: new Set(cfg.externallyManaged ?? []), activated: new Set(), knownTools: new Map(), }; } function normalizeActivationNames(names: readonly string[]): string[] { return [...new Set(names.map((name) => name.trim()).filter((name) => name.length > 0))].sort(); } function activationStatesEqual(left: ActivationStateV1 | undefined, right: ActivationStateV1): boolean { return Boolean( left && left.version === right.version && left.activatedTools.length === right.activatedTools.length && left.activatedTools.every((name, index) => name === right.activatedTools[index]), ); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Decode and normalize the versioned branch-local activation contract. * Unknown fields are ignored so snapshots written by the former catalog-based * design (including `catalogHash`) continue to restore safely. */ export function decodeActivationState(value: unknown): ActivationStateV1 | undefined { if (!isRecord(value) || value.version !== 1) return undefined; if (!Array.isArray(value.activatedTools) || !value.activatedTools.every((name) => typeof name === "string")) { return undefined; } return { version: 1, activatedTools: normalizeActivationNames(value.activatedTools), }; } /** Find the most recent valid activation snapshot on the selected session branch. */ export function getLatestActivationState(entries: readonly SessionEntry[]): ActivationStateV1 | undefined { for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]; if (entry?.type !== "custom" || entry.customType !== ACTIVATION_ENTRY_TYPE) continue; const decoded = decodeActivationState(entry.data); if (decoded) return decoded; } return undefined; } function refreshKnownTools(state: LazyToolsState): void { state.knownTools.clear(); for (const tool of state.pi.getAllTools()) { state.knownTools.set(tool.name, tool); } } function getBranchEntries(ctx: ExtensionContext | undefined): SessionEntry[] { try { const sessionManager = (ctx as (ExtensionContext & { sessionManager?: ExtensionContext["sessionManager"] }) | undefined)?.sessionManager; return sessionManager?.getBranch() ?? []; } catch { return []; } } /** Estimate the request-body bytes a tool contributes (description + parameter schema). */ function estimateToolBytes(tool: ToolInfo): number { const description = tool.description ?? ""; let schemaBytes = 0; try { schemaBytes = JSON.stringify(tool.parameters).length; } catch { schemaBytes = 0; } return description.length + schemaBytes; } function formatBytes(bytes: number): string { if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`; return `${bytes}B`; } function hasVisiblePromptMetadata(tool: ToolInfo): boolean { return Boolean(tool.promptGuidelines?.some((guideline) => guideline.trim().length > 0)); } function promptMetadataWarning(tool: ToolInfo): string { return hasVisiblePromptMetadata(tool) ? " [prompt guidelines: activation may rebuild the official Pi system prompt/cache prefix]" : ""; } function getPromptMetadataToolNames(state: LazyToolsState): string[] { return [...state.knownTools.values()] .filter((tool) => tool.name !== PROXY_TOOL_NAME && hasVisiblePromptMetadata(tool)) .map((tool) => tool.name) .sort(); } function describeTool(tool: ToolInfo, alwaysActive: Set): string { const lazyMark = alwaysActive.has(tool.name) ? "always" : "lazy"; return `- ${tool.name} [${lazyMark}] (~${formatBytes(estimateToolBytes(tool))}): ${tool.description}${promptMetadataWarning(tool)}`; } function computeSavings(state: LazyToolsState): { activeBytes: number; totalBytes: number } { const activeNames = new Set(state.pi.getActiveTools()); let activeBytes = 0; let totalBytes = 0; for (const tool of state.knownTools.values()) { const bytes = estimateToolBytes(tool); totalBytes += bytes; if (activeNames.has(tool.name)) activeBytes += bytes; } return { activeBytes, totalBytes }; } function buildStatusText(state: LazyToolsState): string { const { activeBytes, totalBytes } = computeSavings(state); const activeTools = state.pi.getActiveTools(); const activeToolSet = new Set(activeTools); const lazyTools = [...state.activated].filter((name) => !state.alwaysActive.has(name)); const externalNames = [...state.externallyManaged].sort(); const disabledExternalConflicts = externalNames.filter((name) => state.disabled.has(name)); const alwaysExternalConflicts = externalNames.filter((name) => state.alwaysActive.has(name)); const unconflictedExternalNames = externalNames.filter( (name) => !state.disabled.has(name) && !state.alwaysActive.has(name), ); const activeExternalTools = unconflictedExternalNames.filter( (name) => state.knownTools.has(name) && activeToolSet.has(name), ); const inactiveExternalTools = unconflictedExternalNames.filter( (name) => state.knownTools.has(name) && !activeToolSet.has(name), ); const unregisteredExternalTools = unconflictedExternalNames.filter( (name) => !state.knownTools.has(name), ); const inactiveTools = [...state.knownTools.keys()] .filter((name) => ( name !== PROXY_TOOL_NAME && !activeToolSet.has(name) && !state.disabled.has(name) && !state.externallyManaged.has(name) )) .sort(); const promptMetadataTools = getPromptMetadataToolNames(state); const lines: string[] = []; lines.push(`Active tools (${activeTools.length}): ${activeTools.join(", ") || "(none)"}`); if (state.disabled.size > 0) { lines.push(`Disabled tools: ${[...state.disabled].join(", ")}`); } if (activeExternalTools.length > 0) { lines.push(`Externally managed and active (preserved, never added by cache-stack): ${activeExternalTools.join(", ")}.`); } if (inactiveExternalTools.length > 0) { lines.push(`Externally managed and inactive (not lazy-activatable): ${inactiveExternalTools.join(", ")}.`); } if (unregisteredExternalTools.length > 0) { lines.push(`Externally managed but not registered: ${unregisteredExternalTools.join(", ")}.`); } if (disabledExternalConflicts.length > 0) { lines.push(`Ownership conflict (externallyManaged + disabled): ${disabledExternalConflicts.join(", ")}. Use the owning extension's configuration for eligibility; cache-stack disabled wins during its reconciliation.`); } if (alwaysExternalConflicts.length > 0) { lines.push(`Ownership conflict (externallyManaged + alwaysActive): ${alwaysExternalConflicts.join(", ")}. alwaysActive actively adds these names, so remove one ownership declaration.`); } if (lazyTools.length > 0) { lines.push(`Lazily activated this session: ${lazyTools.join(", ")}`); } if (state.lastTransition) { const mode = state.lastTransition.mode === "fallback" ? "official fallback" : state.lastTransition.mode; lines.push(`Active-tool update: ${mode} (${state.lastTransition.reason}).`); if (state.lastTransition.mode === "fallback") { lines.push("This transition may rebuild the official Pi system prompt and invalidate a provider prefix-cache entry."); } } lines.push(`Inactive discoverable tools (${inactiveTools.length}): ${inactiveTools.join(", ") || "(none)"}.`); lines.push("Prompt ownership: official Pi builds Available tools/Guidelines from the active tool set; inactive tools are not injected into the system prompt."); lines.push("Provider boundary: native deferred loading is official-Pi/provider behavior; non-native providers only get smaller active-tool payloads, not a complete cache-hit guarantee."); lines.push("Prompt metadata note: official getAllTools() exposes promptGuidelines but not promptSnippet; activating a tool may rebuild the official Pi system prompt/cache prefix."); if (promptMetadataTools.length > 0) { lines.push(`Prompt-metadata tools with visible metadata: ${promptMetadataTools.join(", ")}.`); } lines.push(`Request-body cost: ${formatBytes(activeBytes)} active / ${formatBytes(totalBytes)} total (${totalBytes > 0 ? Math.round((activeBytes / totalBytes) * 100) : 0}%)`); lines.push(`Use lazy({ search: "..." }) to find tools, lazy({ activate: ["name"] }) to enable one.`); return lines.join("\n"); } const SEARCH_STOP_WORDS = new Set(["a", "an", "and", "for", "in", "of", "on", "or", "the", "to", "tool", "tools", "with"]); function searchTerms(query: string): string[] { return [...new Set( (query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []) .filter((term) => term.length > 1 && !SEARCH_STOP_WORDS.has(term)), )]; } function findMatchingTools(state: LazyToolsState, query: string): ToolInfo[] { const normalizedQuery = query.trim().toLowerCase(); const terms = searchTerms(normalizedQuery); const activeTools = new Set(state.pi.getActiveTools()); return [...state.knownTools.values()] .filter((tool) => ( tool.name !== PROXY_TOOL_NAME && !activeTools.has(tool.name) && !state.disabled.has(tool.name) && !state.externallyManaged.has(tool.name) )) .map((tool) => { const name = tool.name.toLowerCase().replace(/[_-]+/g, " "); const description = (tool.description ?? "").toLowerCase(); let score = normalizedQuery && `${name} ${description}`.includes(normalizedQuery) ? terms.length * 4 + 4 : 0; for (const term of terms) { if (name.includes(term)) score += 4; if (description.includes(term)) score += 1; } return { tool, score }; }) .filter(({ score }) => !normalizedQuery || score > 0) .sort((a, b) => b.score - a.score || estimateToolBytes(b.tool) - estimateToolBytes(a.tool)) .map(({ tool }) => tool); } function getPolicyActiveTools(state: LazyToolsState, current: readonly string[]): string[] { const externallyManagedActive = current.filter( (name) => ( state.externallyManaged.has(name) && state.knownTools.has(name) && !state.disabled.has(name) ), ); const selected = [ ...new Set([ PROXY_TOOL_NAME, ...state.alwaysActive, ...state.activated, ...externallyManagedActive, ]), ]; const known = new Set(state.knownTools.keys()); return selected.filter( (name) => !state.disabled.has(name) && (known.has(name) || name === PROXY_TOOL_NAME), ); } function recordTransition(state: LazyToolsState, mode: ActiveToolsTransition["mode"], reason: string): void { state.lastTransition = { mode, reason }; } function activeToolSetsEqual(left: readonly string[], right: readonly string[]): boolean { const leftSet = new Set(left); const rightSet = new Set(right); return leftSet.size === rightSet.size && [...leftSet].every((name) => rightSet.has(name)); } /** * Lazy activation must be purely additive. Passing the current active set back * to official Pi lets it record only the new names as `addedToolNames` and let its * ChatGPT OAuth/OpenAI adapter choose `additional_tools`/tool-search or another * native deferred mode. Other providers receive official fallback serialization. */ function addActiveTools( state: LazyToolsState, additions: string[], reason: string, mode: Extract = "fallback", ): void { const current = state.pi.getActiveTools(); const next = [...new Set([...current, ...additions])]; if (activeToolSetsEqual(current, next)) { recordTransition(state, "no-op", `${reason}; active set unchanged`); return; } state.pi.setActiveTools(next); recordTransition(state, mode, reason); } /** * Removal is intentionally a fallback path. Reset/model/disabled reconciliation * may replace the active set; official Pi/provider then owns prompt rebuilding, * cache invalidation, and non-native tool serialization. */ function setActiveToolsFallback( state: LazyToolsState, reason: string, target?: readonly string[], mode: Extract = "fallback", ): void { const current = state.pi.getActiveTools(); const resolvedTarget = target ? [...target] : getPolicyActiveTools(state, current); if (activeToolSetsEqual(current, resolvedTarget)) { recordTransition(state, "no-op", `${reason}; active set unchanged`); return; } state.pi.setActiveTools(resolvedTarget); recordTransition(state, mode, reason); } function reconcileActiveTools( state: LazyToolsState, reason: string, mode: Extract = "fallback", ): void { const current = state.pi.getActiveTools(); const target = getPolicyActiveTools(state, current); const targetSet = new Set(target); const removed = current.filter((name) => !targetSet.has(name)); const additions = target.filter((name) => !current.includes(name)); if (removed.length > 0) { setActiveToolsFallback(state, `${reason}; removed ${removed.join(", ")}`, target, mode); return; } if (additions.length > 0) { if (mode === "initial") { setActiveToolsFallback(state, `${reason}; added ${additions.join(", ")}`, target, mode); } else { addActiveTools(state, additions, `${reason}; added ${additions.join(", ")}`, "fallback"); } return; } recordTransition(state, "no-op", `${reason}; active set unchanged`); } function reconcileDisabledMode(state: LazyToolsState, reason: string): void { // Disabled lazy mode means policy is not controlling the registry: keep every // currently registered tool active, including after tree/compact restoration. const target = [...state.knownTools.keys()]; const current = state.pi.getActiveTools(); const removed = current.filter((name) => !target.includes(name)); if (removed.length > 0) { setActiveToolsFallback(state, `${reason}; lazy mode disabled`, target); return; } addActiveTools( state, target.filter((name) => !current.includes(name)), `${reason}; lazy mode disabled`, "fallback", ); } function currentActivationState(state: LazyToolsState): ActivationStateV1 { return { version: 1, activatedTools: normalizeActivationNames([...state.activated]), }; } function persistActivationState(state: LazyToolsState, allowInitialSnapshot: boolean): boolean { const snapshot = currentActivationState(state); if (activationStatesEqual(state.lastPersistedActivation, snapshot)) return false; if (!state.lastPersistedActivation && !allowInitialSnapshot) return false; try { if (typeof state.pi.appendEntry !== "function") return false; state.pi.appendEntry(ACTIVATION_ENTRY_TYPE, snapshot); state.lastPersistedActivation = snapshot; return true; } catch (error) { console.error("[cache-stack] failed to persist activation state:", error); return false; } } function filterRestoredActivations(state: LazyToolsState, snapshot: ActivationStateV1 | undefined): string[] { if (!snapshot) return []; const known = state.knownTools; return snapshot.activatedTools.filter( (name) => ( known.has(name) && !state.disabled.has(name) && !state.alwaysActive.has(name) && !state.externallyManaged.has(name) ), ); } function activateTools( state: LazyToolsState, names: string[], transitionMode: Extract, ): { activated: string[]; missing: string[]; disabled: string[]; externallyManaged: string[] } { const activated: string[] = []; const missing: string[] = []; const disabled: string[] = []; const externallyManaged: string[] = []; const activeNames = new Set(state.pi.getActiveTools()); for (const name of names) { const normalized = name.trim(); if (!normalized) continue; if (state.disabled.has(normalized)) { disabled.push(normalized); continue; } if (!state.knownTools.has(normalized)) { missing.push(normalized); continue; } if (state.externallyManaged.has(normalized)) { externallyManaged.push(normalized); continue; } if (state.alwaysActive.has(normalized) || activeNames.has(normalized)) continue; state.activated.add(normalized); activated.push(normalized); activeNames.add(normalized); } addActiveTools( state, activated, `${transitionMode === "native-candidate" ? "lazy tool execute" : "slash command activate"}; added ${activated.join(", ")}`, transitionMode, ); if (activated.length > 0) persistActivationState(state, true); return { activated, missing, disabled, externallyManaged }; } /** * Return tool-owned guidance after activation. The extension does not replace * official Pi's system prompt; prompt metadata remains owned by the tool/Pi. */ function describeActivatedTool(state: LazyToolsState, name: string): string { const tool = state.knownTools.get(name); if (!tool) return `- ${name}: (unknown tool)`; const lines = [`- ${name}: ${tool.description ?? ""}`]; if (tool.promptGuidelines && tool.promptGuidelines.length > 0) { for (const g of tool.promptGuidelines) { lines.push(` - ${g}`); } } if (hasVisiblePromptMetadata(tool)) { lines.push(" - Warning: this tool exposes prompt guidelines; activation may rebuild the official Pi system prompt/cache prefix."); } return lines.join("\n"); } function buildToolResult(state: LazyToolsState, text: string): AgentToolResult { return { content: [{ type: "text", text }], details: { state: { alwaysActive: [...state.alwaysActive], activated: [...state.activated], externallyManaged: [...state.externallyManaged], }, activeToolsTransition: state.lastTransition, promptMetadataTools: getPromptMetadataToolNames(state), }, } as AgentToolResult; } export interface LazyToolsHooks { onSessionStart(ctx?: ExtensionContext, reason?: "startup" | "reload" | "new" | "resume" | "fork"): void; onBeforeAgentStart(): void; onSessionTree(ctx?: ExtensionContext): void; onSessionCompact(ctx?: ExtensionContext): void; onModelChange(): void; onToolExecutionEnd(): void; onReset(): void; } /** * 注册 lazy 工具 + lazy 命令,返回生命周期钩子(index.ts 按管线顺序调用)。 * lazyTools.enabled=false 时:不接管活跃工具集(保持 Pi 默认全量),lazy 调用给出提示。 */ export function setupLazyTools(pi: ExtensionAPI, getCfg: () => EffectiveLazyToolsConfig): LazyToolsHooks { let state: LazyToolsState | null = null; const ensureState = (): LazyToolsState => { if (!state) { state = createState(pi, getCfg()); refreshKnownTools(state); } return state; }; const DISABLED_TEXT = `lazy-tools is disabled (cache-stack.json "lazyTools.enabled": false). ` + `All tools stay active, like the pi default.`; pi.registerTool({ name: PROXY_TOOL_NAME, label: "Lazy Tool Gateway", description: "Lazy-load gateway for tools. Use lazy({}) for status, lazy({ search: \"query\" }) to discover tools, lazy({ activate: [\"name\"] }) to enable tools for the rest of the session, or lazy({ reset: true }) to clear activations. Activating takes effect on the next agent turn.", promptSnippet: "Discover and activate lazy-loaded tools", parameters: { ...Type.Object({ search: Type.Optional(Type.String({ description: "Search deactivated tools by name or description; omit to list all" })), activate: Type.Optional(Type.Array(Type.String({ description: "Tool names to activate for this session" }))), reset: Type.Optional(Type.Boolean({ description: "Clear cache-owned lazy activations while preserving current externally managed owner state" })), }), }, async execute( _toolCallId: string, params: { search?: string; activate?: string[]; reset?: boolean }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext, ): Promise> { if (!getCfg().enabled) { return { content: [{ type: "text", text: DISABLED_TEXT }] } as AgentToolResult; } const state = ensureState(); refreshKnownTools(state); if (params.reset) { const hadActivations = state.activated.size > 0; state.activated.clear(); setActiveToolsFallback(state, "explicit reset; removed session activations"); if (hadActivations) persistActivationState(state, true); return buildToolResult(state, buildStatusText(state)); } if (params.activate && params.activate.length > 0) { const { activated, missing, disabled, externallyManaged, } = activateTools(state, params.activate, "native-candidate"); const parts: string[] = []; if (activated.length > 0) { parts.push(`Activated: ${activated.join(", ")} (available next turn)`); parts.push(""); parts.push("Tool guidance:"); for (const name of activated) { parts.push(describeActivatedTool(state, name)); } parts.push(""); parts.push("Prompt metadata note: official getAllTools() does not expose promptSnippet; any activated tool may trigger a system-prompt rebuild/cache invalidation."); } else { parts.push("Nothing new activated."); } if (disabled.length > 0) { parts.push(`Disabled tool names (not activatable): ${disabled.join(", ")}`); } if (externallyManaged.length > 0) { parts.push(`Externally managed tool names (activate through their owning extension/model policy): ${externallyManaged.join(", ")}`); } if (missing.length > 0) { parts.push(`Unknown tool names (not registered): ${missing.join(", ")}`); const known = [...state.knownTools.values()] .filter((tool) => !state.disabled.has(tool.name) && !state.externallyManaged.has(tool.name)) .map((t) => t.name) .join(", "); parts.push(`Known lazy-activatable tools: ${known}`); } parts.push(""); parts.push(buildStatusText(state)); return buildToolResult(state, parts.join("\n")); } if (params.search !== undefined) { const matches = findMatchingTools(state, params.search ?? ""); if (matches.length === 0) { return buildToolResult(state, `No deactivated tools match "${params.search ?? ""}". ${buildStatusText(state)}`); } const lines = [`Tools matching "${params.search ?? ""}" (${matches.length}):`, ""]; for (const tool of matches) { lines.push(describeTool(tool, state.alwaysActive)); } lines.push(""); lines.push("Prompt metadata note: official getAllTools() does not expose promptSnippet; activating a matching tool may trigger a system-prompt rebuild/cache invalidation."); lines.push("Activate one with lazy({ activate: [\"name\"] })."); return buildToolResult(state, lines.join("\n")); } return buildToolResult(state, buildStatusText(state)); }, }); pi.registerCommand("lazy", { description: "Show lazy-tools status: active set, request-body cost, and how to activate tools", handler: async (args: string | undefined, ctx: ExtensionCommandContext) => { if (!getCfg().enabled) { if (ctx.hasUI) { ctx.ui.notify(DISABLED_TEXT, "info"); } else { console.log(DISABLED_TEXT); } return; } const state = ensureState(); refreshKnownTools(state); const parts = args?.trim()?.split(/\s+/) ?? []; const sub = parts[0] ?? ""; const rest = parts.slice(1).join(" "); let msg = buildStatusText(state); if (sub === "search" && rest) { const matches = findMatchingTools(state, rest); msg = matches.length > 0 ? `Tools matching "${rest}":\n${matches.map((t) => describeTool(t, state.alwaysActive)).join("\n")}` : `No deactivated tools match "${rest}".`; } else if (sub === "activate" && rest) { const names = rest.split(",").map((s) => s.trim()).filter(Boolean); const { activated, missing, disabled, externallyManaged, } = activateTools(state, names, "fallback"); const messages: string[] = []; if (activated.length > 0) messages.push(`Activated: ${activated.join(", ")} (next turn)`); if (disabled.length > 0) messages.push(`Disabled tools: ${disabled.join(", ")}`); if (externallyManaged.length > 0) { messages.push(`Externally managed tools (use the owning extension/model policy): ${externallyManaged.join(", ")}`); } if (missing.length > 0) messages.push(`Unknown tools: ${missing.join(", ")}`); msg = messages.join("\n") || "Nothing new activated."; } else if (sub === "reset") { const hadActivations = state.activated.size > 0; state.activated.clear(); setActiveToolsFallback(state, "explicit reset; removed session activations"); if (hadActivations) persistActivationState(state, true); msg = "Cleared cache-owned lazy activations and preserved externally managed owner state (official fallback; the provider may rebuild the prompt/cache prefix)."; } if (ctx.hasUI) { ctx.ui.notify(msg, "info"); } else { console.log(msg); } }, }); const reconcile = ( resetActivated: boolean, reason: string, mode: Extract = "fallback", ): void => { const cfg = getCfg(); if (!state) state = createState(pi, cfg); const currentState = state; refreshKnownTools(currentState); currentState.disabled = new Set(cfg.disabled ?? []); currentState.externallyManaged = new Set(cfg.externallyManaged ?? []); currentState.alwaysActive = getAlwaysActiveTools(cfg); if (resetActivated) { currentState.activated.clear(); currentState.lastPersistedActivation = undefined; } if (!cfg.enabled) { // disabled is an activation policy, not a permission boundary; restore // every registered tool while leaving provider cache/deferred loading to Pi. reconcileDisabledMode(currentState, reason); persistActivationState(currentState, false); return; } const beforeActivated = [...currentState.activated]; const known = new Set(currentState.knownTools.keys()); for (const name of [...currentState.activated]) { if ( !known.has(name) || currentState.disabled.has(name) || currentState.alwaysActive.has(name) || currentState.externallyManaged.has(name) ) { currentState.activated.delete(name); } } reconcileActiveTools(currentState, reason, mode); if (beforeActivated.length !== currentState.activated.size || beforeActivated.some((name) => !currentState.activated.has(name))) { persistActivationState(currentState, true); } else { persistActivationState(currentState, false); } }; const restoreFromBranch = ( ctx: ExtensionContext | undefined, reason: "session_start" | "session_tree" | "session_compact", ): void => { const cfg = getCfg(); if (!state) state = createState(pi, cfg); refreshKnownTools(state); state.disabled = new Set(cfg.disabled ?? []); state.externallyManaged = new Set(cfg.externallyManaged ?? []); state.alwaysActive = getAlwaysActiveTools(cfg); const previousActivated = [...state.activated]; const persisted = getLatestActivationState(getBranchEntries(ctx)); const restored = normalizeActivationNames(filterRestoredActivations(state, persisted)); state.lastPersistedActivation = persisted; state.activated = new Set(restored); if (cfg.enabled) { reconcileActiveTools(state, `${reason} branch activation restore`, "fallback"); } else { reconcileDisabledMode(state, `${reason} branch activation restore`); } const activationChanged = !activeToolSetsEqual(previousActivated, restored); persistActivationState(state, activationChanged || Boolean(persisted)); }; return { onSessionStart(ctx, reason = "new"): void { if (reason === "new") { reconcile(true, "session_start initial policy", "initial"); return; } restoreFromBranch(ctx, "session_start"); }, onBeforeAgentStart(): void { reconcile(false, "before_agent_start policy reconcile", "fallback"); }, onSessionTree(ctx): void { restoreFromBranch(ctx, "session_tree"); }, onSessionCompact(ctx): void { restoreFromBranch(ctx, "session_compact"); }, onModelChange(): void { reconcile(false, "model policy reconcile", "fallback"); }, onToolExecutionEnd(): void { reconcile(false, "tool registry reconcile", "fallback"); }, onReset(): void { state = null; }, }; }