diff --git a/CHANGELOG.md b/CHANGELOG.md index f883b72..916ca98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ Versioning rules are in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). +## Unreleased + +### Added + +- Defer Tools: a new extension, off by default. In each request to Meridian + it marks the tools named in `deferTools.names` (`pi-extras.json`) + `defer_loading: true`, so their declarations leave the prompt and the model + loads them through tool search. The README gives a recommended list of 13 + tools. Turn it on only with Meridian 1.89.0 or newer: older builds send + deferred tools in full and add a hidden model turn to each turn with a + tool call. Each session keeps the list of its start in a session entry, + also after a resume, fork or `/reload`, so a config edit never rewrites + the prompt cache of a session. A session that had turns before it had a list never + starts to defer. `PI_EXTRAS_DEFER_TOOLS=0` turns it off. + Requests to other providers do not change. + ## 0.44.2 - 2026-11-13 ### Changed diff --git a/README.md b/README.md index be52e43..8a62584 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ pi install git:github.com/jaedync/pi-extras ``` Restart Pi after installation. Use `pi config` to select extensions. Installing -adds all twenty-two extensions (quota hibernation, computer use and auto-update stay off until you opt in); it makes `quiet` available but does not select it. It also adds the skills `be-ambitious`, `code-slop-rules`, `formal-verification`, `implicit-state-machines`, `ui-redline`, `untangle-codebase` and `walkthrough-video` and the prompt templates `/higher` and `/wider`. +adds all twenty-three extensions (quota hibernation, computer use and auto-update stay off until you opt in); it makes `quiet` available but does not select it. It also adds the skills `be-ambitious`, `code-slop-rules`, `formal-verification`, `implicit-state-machines`, `ui-redline`, `untangle-codebase` and `walkthrough-video` and the prompt templates `/higher` and `/wider`. Choose the theme using `/settings`. Use only one custom footer at a time. Phase Spinner wraps an existing editor where possible; other editor extensions can still conflict. @@ -37,6 +37,7 @@ can still conflict. | Bash Default Timeout | Adds a 120-second timeout only when a bash or powershell call omitted one | | Idle GC | Once the agent has been idle for 30 seconds, one full garbage collection gives the memory of the last run back to the system | | Loop Stall | Logs each time Pi stops answering for 500 ms or more, with what held it: main-thread CPU, GC, slow file writes and disk pressure | +| Defer Tools | Opt-in: requests to a local [Meridian](https://github.com/rynfar/meridian) mark the tools you name `defer_loading`, so their declarations leave the prompt until the model searches for them | | Herdr Tab Name | In a [Herdr](https://herdr.dev) tab, keeps the session name and the tab label the same: `/name` renames the tab, and a session with no name takes the tab's label | | Kagi Search | Adds `kagi_search` without replacing existing search/fetch tools | | Gov Search | `gov_search` tool: free search of the Firecrawl Government & Legal Index (US statutes, regulations, city codes, court opinions, agency guidance), with filters for jurisdiction, court and citation; no key needed | @@ -324,6 +325,8 @@ child's bash without the sandbox that hides them). The model guide is I/O pressure (Linux). CPU time close to the stall means JavaScript or GC work; CPU time close to zero means a blocking system call, such as an fsync on a busy disk. At 1 MB the log moves to `loop-stall.log.1`. +- `PI_EXTRAS_DEFER_TOOLS=0`: disable Defer Tools for the run (see + [Defer Tools](#defer-tools)). - `PI_HERDR_TAB_NAME=off`, or `herdrTabName.enabled: false` in `pi-extras.json`: disable Herdr Tab Name. In a Herdr tab with one pane, it keeps Pi's session name and the tab's label the same. A session that has a @@ -1835,6 +1838,69 @@ a `codemode` script. The line goes into that user message only, so the system prompt and the tool list stay the same for the whole session, and the prompt cache stays valid. What you type yourself is never changed. +## Defer Tools + +Every request declares every active tool, and a declaration costs input tokens +on every turn. Anthropic's tool search lets a request mark a tool +`defer_loading: true`. The prompt then does not hold its declaration, and the +model loads the tool through a search when it needs it. Meridian gives the +flag to Claude Code's tool search. + +Defer Tools adds `defer_loading: true` to the tools that you name, in each +request to Meridian. A request goes to Meridian when its model uses the +`anthropic-messages` API and has a Meridian base URL or sends the +`x-meridian-agent` header. Requests to all other providers do not change. + +It is off by default. Older Meridian builds send deferred tools in full and +add a hidden model turn to each turn with a tool call, so deferral costs more +than it saves there. Turn it on only with Meridian 1.89.0 or newer, which +keeps tool search and has no such extra turn. This is the recommended list +(tools used in under 5% of sessions): + +```json +{ + "deferTools": { + "names": [ + "agent_send", "list_peers", "agent_request", "intercom", + "artifact_feedback", "artifact_check", "gov_search", "stop_subagent", + "get_goal", "create_goal", "propose_goal_draft", "complete_goal", + "update_goal_progress" + ] + } +} +``` + +- `names`: the tools to defer. With no `names`, or `"names": []`, it + defers nothing. +- `meridianBaseUrls`: the base URLs that are Meridian. The default is + `["http://127.0.0.1:3456", "http://localhost:3456"]`. +- `PI_EXTRAS_DEFER_TOOLS=0` turns it off for the run. + +A change to the tool list rewrites the whole prompt cache. So each session +keeps the list that it had at its start, for its whole life: + +- A new session reads `pi-extras.json` and writes its list into the session + as a `defer-tools/frozen` entry. +- A resumed, forked or reloaded session uses the list in that entry and does + not read `pi-extras.json`. +- A session that had model turns before it had an entry defers nothing, so + deferral never starts in the middle of a session. + +A config edit thus applies only to new sessions. The same request always +gives the same bytes, so a cache warm repeats the real request. + +Some tools are never deferred: `read`, `bash`, `edit`, `write`, `codemode` and +`powershell`. A tool that already has a `defer_loading` value keeps it. A +listed tool that has `cache_control` is deferred too, and its +`cache_control` is removed, because Anthropic does not accept both on one +tool. Meridian does not send a tool's `cache_control` upstream, so this +changes nothing there, and the deferred set does not depend on Pi's cache +retention setting. When every tool of a request would be deferred, the +request does not change, because one tool must stay loaded. + +A tool that Tool Loadout hides (`toolLoadout.codemodeOnly`) is not in the +request, so Defer Tools does nothing for it. Subagents do not load Defer Tools. + ## Copy Blocks Code blocks and quotes in the agent's replies are drawn as cards on a diff --git a/docs/security.md b/docs/security.md index 2675029..cc165f4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1028,6 +1028,16 @@ the time, numbers and the labels of slow synchronous operations, such as `subagents index save`; never a path, a task or any content. `PI_LOOP_STALL=off` turns it off. +## Defer Tools + +Defer Tools is off until `deferTools.names` lists tools. It sends nothing by +itself and writes no files of its own. It reads the `deferTools` section of +`pi-extras.json` and `PI_EXTRAS_DEFER_TOOLS` once per new session. It writes +the list (tool names and base URLs only) into the session as a +`defer-tools/frozen` entry. In a request to Meridian it adds +`defer_loading: true` to the listed tool declarations. It changes nothing else in the request, and it leaves +requests to other providers as they are. + ## Reporting Do not open a public issue containing tokens, session links, auth files, raw diff --git a/extensions/defer-tools.ts b/extensions/defer-tools.ts new file mode 100644 index 0000000..22ce193 --- /dev/null +++ b/extensions/defer-tools.ts @@ -0,0 +1,225 @@ +/** + * Defer Tools. Requests to Meridian mark the tools named in `deferTools.names` `defer_loading: true`, + * so their declarations leave the prompt and the model loads one through tool search when it needs + * it (Meridian hands the flag to Claude Code's ToolSearch). Other providers get Pi's payload as is. + * Off until names are given: see RECOMMENDED_DEFER_TOOLS for why. + * + * A changed tool list rewrites the whole prompt cache, so each session freezes the list at its + * start and keeps it in a session entry (FROZEN_ENTRY). A resume, fork or `/reload` reads that + * entry, not pi-extras.json; a session that had turns before it had an entry never defers. The output + * depends only on the payload and that list, so a warm request repeats the real one byte for byte. + * Load it before Cache Compaction: that extension keeps the payload it sees to replay as a warm. + * + * Children do not load it: a child loads only the extensions that own its tools, and most of the + * recommended list is excluded from children anyway (lib/subagents/child.ts CHILD_TOOL_EXCLUDE). + */ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { readSection } from "../lib/extras-config.ts"; + +/** + * Tools used in under 5% of sessions on two machines over 14 days: a list to paste into + * `deferTools.names`. Not the default: Meridian builds older than 1.89.0 send deferred tools in + * full and add a hidden digest turn to each tool-call turn, so turn it on only with Meridian + * 1.89.0 or newer (upstream #1368 and #1370). + */ +export const RECOMMENDED_DEFER_TOOLS: readonly string[] = Object.freeze([ + "agent_send", "list_peers", "agent_request", "intercom", "artifact_feedback", "artifact_check", "gov_search", + "stop_subagent", "get_goal", "create_goal", "propose_goal_draft", "complete_goal", "update_goal_progress", +]); +export const DEFAULT_MERIDIAN_BASE_URLS: readonly string[] = Object.freeze(["http://127.0.0.1:3456", "http://localhost:3456"]); +/** The model's own ways to work; a deferred one would cost a search before every use. */ +const NEVER_DEFERRED: ReadonlySet = new Set(["read", "bash", "edit", "write", "codemode", "powershell"]); +const TOOL_NAME = /^[A-Za-z0-9_-]{1,64}$/; +const MERIDIAN_HEADER = "x-meridian-agent"; +const VIRTUAL_API = "pi-virtual"; +const KILL_SWITCH = "PI_EXTRAS_DEFER_TOOLS"; +const STORE_SLOT = Symbol.for("pi-extras.defer-tools.sessions"); +/** The session entry that holds a session's frozen config. */ +export const FROZEN_ENTRY = "defer-tools/frozen"; + +export interface DeferConfig { + readonly names: readonly string[]; + readonly meridianBaseUrls: readonly string[]; +} + +const neverDeferred = (name: string) => NEVER_DEFERRED.has(name.toLowerCase()); +const normalizeUrl = (url: string) => url.trim().replace(/\/+$/, "").toLowerCase(); + +export function parseDeferConfig(section: Record): DeferConfig { + // Off unless the owner names the tools (see RECOMMENDED_DEFER_TOOLS). + const raw: readonly unknown[] = Array.isArray(section.names) ? section.names : []; + const names = raw.filter((name): name is string => typeof name === "string").map((name) => name.trim()) + .filter((name) => TOOL_NAME.test(name) && !neverDeferred(name)); + const urls = Array.isArray(section.meridianBaseUrls) + ? section.meridianBaseUrls.filter((url): url is string => typeof url === "string" && /^https?:\/\/\S+$/i.test(url.trim())).map(normalizeUrl) + : DEFAULT_MERIDIAN_BASE_URLS; + return { names: [...new Set(names)], meridianBaseUrls: [...new Set(urls)] }; +} + +/** The config as the session will keep it: a copy that nothing can change, off with the kill switch. */ +export function freezeDeferConfig(section: Record, env: Record): DeferConfig { + const parsed = parseDeferConfig(section); + const names = env[KILL_SWITCH]?.trim() === "0" ? [] : parsed.names; + return Object.freeze({ names: Object.freeze([...names]), meridianBaseUrls: Object.freeze([...parsed.meridianBaseUrls]) }); +} + +interface ModelLike { + readonly provider?: string; + readonly id?: string; + readonly api?: string; + readonly baseUrl?: string; + readonly headers?: Record | undefined; +} + +function hasMeridianHeader(headers: unknown): boolean { + if (!headers || typeof headers !== "object") return false; + // A null value is a header that a hook deleted. + return Object.entries(headers).some(([key, value]) => key.toLowerCase() === MERIDIAN_HEADER && value !== null && value !== undefined); +} + +/** Meridian speaks the Anthropic Messages API, at a known base URL or with its agent header. */ +export function isMeridianModel(model: ModelLike | undefined, meridianBaseUrls: readonly string[]): boolean { + if (!model || model.api !== "anthropic-messages") return false; + if (hasMeridianHeader(model.headers)) return true; + return typeof model.baseUrl === "string" && meridianBaseUrls.includes(normalizeUrl(model.baseUrl)); +} + +const modelKey = (model: ModelLike & { provider?: string }) => `${model.provider ?? ""}/${model.id ?? ""}`; + +interface RequestContext { + readonly model?: ModelLike | undefined; + readonly modelRegistry?: { getAll?: () => readonly ModelLike[] } | undefined; +} + +/** + * Pi gives the handler no request model, only the selection (`ctx.model`). The payload names the + * model it is for, so a request for another model (a summary, a routed request) is left alone. A + * virtual selection routes each request; then every registry model with the payload's id must be + * a Meridian model, which gives the same answer on the first request as on later ones. + * `headerSeen` holds the models whose request headers had `x-meridian-agent`: Pi resolves provider + * headers at request time, so `ctx.model.headers` does not show them. + */ +export function requestGoesToMeridian(ctx: RequestContext, payload: unknown, meridianBaseUrls: readonly string[], headerSeen: ReadonlySet = new Set()): boolean { + const id = payload && typeof payload === "object" ? (payload as { model?: unknown }).model : undefined; + const selected = ctx.model; + if (typeof id !== "string" || !selected) return false; + if (selected.api !== VIRTUAL_API) { + return selected.id === id && (isMeridianModel(selected, meridianBaseUrls) || (selected.api === "anthropic-messages" && headerSeen.has(modelKey(selected)))); + } + const candidates = (ctx.modelRegistry?.getAll?.() ?? []).filter((model) => model.id === id && model.api !== VIRTUAL_API); + return candidates.length > 0 && candidates.every((model) => isMeridianModel(model, meridianBaseUrls)); +} + +type ToolLike = Record & { name?: unknown }; + +function deferrable(tool: unknown, names: ReadonlySet): tool is ToolLike { + if (!tool || typeof tool !== "object") return false; + const { name } = tool as ToolLike; + return typeof name === "string" && names.has(name) && !neverDeferred(name) + // A value someone set stays. + && !Object.hasOwn(tool, "defer_loading"); +} + +/** + * A listed tool, deferred. Pi puts `cache_control` on the last tool only while cache retention is + * not `none`, so keeping such a tool loaded would tie the deferred set to that setting. Anthropic + * rejects `cache_control` on a deferred tool, so the key goes; Meridian (the only target) rebuilds + * tools through MCP and never forwards a client's tool `cache_control`, so nothing changes upstream. + */ +function deferred(tool: ToolLike): ToolLike { + const { cache_control: _dropped, ...rest } = tool; + return { ...rest, defer_loading: true }; +} + +/** + * The payload with each listed tool marked `defer_loading: true`, or undefined to keep Pi's payload: + * when nothing matches, or when no tool would stay loaded (Anthropic needs one). Tool order and key + * order stay; unchanged tools are the same objects; the input is not changed. + */ +export function deferPayload(payload: unknown, names: readonly string[]): unknown { + if (!payload || typeof payload !== "object" || names.length === 0) return undefined; + const tools = (payload as { tools?: unknown }).tools; + if (!Array.isArray(tools)) return undefined; + const wanted = new Set(names); + const marks = tools.map((tool) => deferrable(tool, wanted)); + if (!marks.includes(true)) return undefined; + const stillLoaded = tools.some((tool, index) => !marks[index] && (tool as { defer_loading?: unknown } | null)?.defer_loading !== true); + if (!stillLoaded) return undefined; + return { ...payload, tools: tools.map((tool, index) => marks[index] ? deferred(tool as ToolLike) : tool) }; +} + +type SessionStore = Map; + +/** One record per process, so a `/reload` copy finds the list each session froze. */ +function processStore(): SessionStore { + const slot = globalThis as { [STORE_SLOT]?: SessionStore }; + return (slot[STORE_SLOT] ??= new Map()); +} + +export interface DeferToolsOptions { + /** The `deferTools` section; read from pi-extras.json when omitted. */ + readonly config?: Record; + readonly env?: Record; + /** Each session's frozen config; the process-wide one when omitted. */ + readonly store?: SessionStore; +} + +interface SessionContext extends RequestContext { + readonly sessionManager?: { getSessionId?: () => string; getBranch?: () => readonly unknown[] }; +} + +type BranchEntry = { type?: unknown; customType?: unknown; data?: unknown; message?: { role?: unknown } } | null; + +/** + * What the session's branch says: its frozen config (the latest entry on the path, so a fork or a + * tree branch keeps its source's), "turns" when it had a provider turn with no entry, or undefined + * for a session that has not talked to a model yet. + */ +export function branchState(branch: readonly unknown[]): { readonly frozen: Record } | "turns" | undefined { + let turns = false; + for (let i = branch.length - 1; i >= 0; i--) { + const entry = branch[i] as BranchEntry; + if (entry?.type === "custom" && entry.customType === FROZEN_ENTRY) { + const data = entry.data && typeof entry.data === "object" ? entry.data as Record : {}; + return { frozen: data }; + } + if (entry?.type === "message" && entry.message?.role === "assistant") turns = true; + } + return turns ? "turns" : undefined; +} + +export default function deferTools(pi: ExtensionAPI, options: DeferToolsOptions = {}): void { + const env = options.env ?? process.env; + // The kill switch is the process's own; no earlier copy in it can have run with deferral. + if (env[KILL_SWITCH]?.trim() === "0") return; + const store = options.store ?? processStore(); + // Decided once per session, at its start (or its first request), and never again for it. + const frozenFor = (ctx: SessionContext): DeferConfig => { + const id = ctx.sessionManager?.getSessionId?.() ?? ""; + const known = store.get(id); + if (known) return known; + const state = branchState(ctx.sessionManager?.getBranch?.() ?? []); + let config: DeferConfig; + if (state === "turns") config = freezeDeferConfig({ names: [] }, env); + else if (state) config = freezeDeferConfig(state.frozen, env); + else { + config = freezeDeferConfig(options.config ?? readSection("deferTools"), env); + // Off writes nothing: a later resume finds turns and no entry, so it stays off. + if (config.names.length > 0) pi.appendEntry(FROZEN_ENTRY, { names: [...config.names], meridianBaseUrls: [...config.meridianBaseUrls] }); + } + store.set(id, config); + return config; + }; + // Pi assembles a request's headers before its payload, so the first request already counts. + const headerSeen = new Set(); + pi.on("session_start", (_event, ctx) => { frozenFor(ctx as SessionContext); }); + pi.on("before_provider_headers", (event, ctx) => { + const model = (ctx as RequestContext).model; + if (model && model.api !== VIRTUAL_API && hasMeridianHeader(event.headers)) headerSeen.add(modelKey(model)); + }); + pi.on("before_provider_request", (event, ctx) => { + const config = frozenFor(ctx as SessionContext); + if (config.names.length === 0 || !requestGoesToMeridian(ctx as RequestContext, event.payload, config.meridianBaseUrls, headerSeen)) return undefined; + return deferPayload(event.payload, config.names); + }); +} diff --git a/package.json b/package.json index 80a27db..d2efd4b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "pi": { "extensions": [ "extensions/tab-status.ts", + "extensions/defer-tools.ts", "extensions/status-plus.ts", "extensions/usage-guard.ts", "extensions/rate-limit-recovery.ts", diff --git a/scripts/install-smoke.mjs b/scripts/install-smoke.mjs index 6bf57d8..a962a5b 100644 --- a/scripts/install-smoke.mjs +++ b/scripts/install-smoke.mjs @@ -61,7 +61,7 @@ try { await loader.reload(); const extensions = loader.getExtensions(); assert.deepEqual(extensions.errors, []); - assert.equal(extensions.extensions.length, 22); + assert.equal(extensions.extensions.length, 23); assert.ok(loader.getThemes().themes.some(theme => theme.name === 'quiet')); assert.deepEqual(loader.getThemes().diagnostics, []); const prompts = loader.getPrompts().prompts.map(prompt => prompt.name); @@ -99,7 +99,7 @@ try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); session.dispose(); } - console.log('Native Git install, twenty-two-extension loader and session lifecycle passed without credentials.'); + console.log('Native Git install, twenty-three-extension loader and session lifecycle passed without credentials.'); writeFileSync(join(fixture, 'lib/smoke-marker.txt'), 'updated\n'); run('git', ['add', 'lib/smoke-marker.txt'], fixture); diff --git a/tests/defer-tools.test.ts b/tests/defer-tools.test.ts new file mode 100644 index 0000000..53738de --- /dev/null +++ b/tests/defer-tools.test.ts @@ -0,0 +1,328 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import deferTools, { + RECOMMENDED_DEFER_TOOLS, + DEFAULT_MERIDIAN_BASE_URLS, + deferPayload, + freezeDeferConfig, + isMeridianModel, + parseDeferConfig, + requestGoesToMeridian, + FROZEN_ENTRY, +} from "../extensions/defer-tools.ts"; + +const tool = (name: string, extra: Record = {}) => ({ name, description: `${name} tool`, input_schema: { type: "object", properties: {}, required: [] }, ...extra }); +const payload = (...tools: unknown[]) => ({ model: "claude-opus-5-5", messages: [{ role: "user", content: "hi" }], max_tokens: 100, stream: true, tools, system: [{ type: "text", text: "sys" }] }); +const meridian = { provider: "anthropic", id: "claude-opus-5-5", api: "anthropic-messages", baseUrl: "http://127.0.0.1:3456" }; +const deferredNames = (out: unknown) => ((out as { tools: Array<{ name: string; defer_loading?: unknown }> }).tools).filter((t) => t.defer_loading === true).map((t) => t.name); + +test("config: off when names is missing or not a list, [] is off, invalid names dropped", () => { + assert.deepEqual(parseDeferConfig({}).names, []); + assert.deepEqual(parseDeferConfig({ names: "gov_search" }).names, []); + assert.deepEqual(parseDeferConfig({ names: [] }).names, []); + assert.deepEqual(parseDeferConfig({ names: [...RECOMMENDED_DEFER_TOOLS] }).names, RECOMMENDED_DEFER_TOOLS); + assert.equal(RECOMMENDED_DEFER_TOOLS.length, 13); + assert.deepEqual(parseDeferConfig({ names: ["gov_search", 3, "", " list_peers ", "gov_search", "bad name!"] }).names, ["gov_search", "list_peers"]); + assert.deepEqual(parseDeferConfig({}).meridianBaseUrls, DEFAULT_MERIDIAN_BASE_URLS); + assert.deepEqual(parseDeferConfig({ meridianBaseUrls: ["http://10.0.0.1:3456/", 5] }).meridianBaseUrls, ["http://10.0.0.1:3456"]); +}); + +test("config: the never-defer tools leave the list, whatever their case", () => { + assert.deepEqual(parseDeferConfig({ names: ["read", "Bash", "edit", "write", "codemode", "powershell", "gov_search"] }).names, ["gov_search"]); +}); + +test("the kill switch PI_EXTRAS_DEFER_TOOLS=0 turns deferral off", () => { + const section = { names: [...RECOMMENDED_DEFER_TOOLS] }; + assert.deepEqual(freezeDeferConfig(section, { PI_EXTRAS_DEFER_TOOLS: "0" }).names, []); + assert.deepEqual(freezeDeferConfig(section, { PI_EXTRAS_DEFER_TOOLS: "1" }).names, RECOMMENDED_DEFER_TOOLS); + assert.deepEqual(freezeDeferConfig(section, {}).names, RECOMMENDED_DEFER_TOOLS); +}); + +test("a frozen config does not change when its source object changes", () => { + const section = { names: ["gov_search"] }; + const frozen = freezeDeferConfig(section, {}); + section.names.push("list_peers"); + assert.deepEqual(frozen.names, ["gov_search"]); + assert.ok(Object.isFrozen(frozen) && Object.isFrozen(frozen.names)); +}); + +test("defers only the listed tools that are in the payload", () => { + const out = deferPayload(payload(tool("read"), tool("gov_search"), tool("bash")), ["gov_search", "not_present"]); + assert.deepEqual(deferredNames(out), ["gov_search"]); +}); + +test("never defers read, bash, edit, write, codemode or powershell, even when listed", () => { + const input = payload(tool("read"), tool("bash"), tool("edit"), tool("write"), tool("codemode"), tool("powershell"), tool("Read"), tool("gov_search")); + const out = deferPayload(input, ["read", "bash", "edit", "write", "codemode", "powershell", "Read", "gov_search"]); + assert.deepEqual(deferredNames(out), ["gov_search"]); +}); + +test("never defers every tool: then the payload is unchanged", () => { + assert.equal(deferPayload(payload(tool("gov_search"), tool("list_peers")), ["gov_search", "list_peers"]), undefined); + // Pi's own deferred placeholder does not count as a non-deferred tool. + const placeholder = tool("__pi_deferred_placeholder__", { defer_loading: true }); + assert.equal(deferPayload(payload(tool("gov_search"), placeholder), ["gov_search"]), undefined); +}); + +test("a tool that already has defer_loading keeps its value", () => { + const out = deferPayload(payload(tool("read"), tool("gov_search", { defer_loading: false }), tool("list_peers")), ["gov_search", "list_peers"]); + const tools = (out as { tools: Array> }).tools; + assert.equal(tools[1].defer_loading, false); + assert.equal(tools[2].defer_loading, true); +}); + +test("a listed tool with cache_control is deferred and loses its cache_control", () => { + const input = payload(tool("read"), tool("list_peers"), tool("gov_search", { cache_control: { type: "ephemeral" } })); + const out = deferPayload(input, ["gov_search", "list_peers"]) as { tools: Array> }; + assert.deepEqual(deferredNames(out), ["list_peers", "gov_search"]); + assert.deepEqual(Object.keys(out.tools[2]), ["name", "description", "input_schema", "defer_loading"]); + assert.ok(Object.hasOwn(input.tools[2] as object, "cache_control"), "the input keeps its key"); +}); + +test("cache retention does not change the deferred set: same set with and without cache_control on the last tool", () => { + const names = ["gov_search", "list_peers"]; + const plain = payload(tool("read"), tool("list_peers"), tool("gov_search")); + const cached = payload(tool("read"), tool("list_peers"), tool("gov_search", { cache_control: { type: "ephemeral", ttl: "1h" } })); + assert.deepEqual(deferredNames(deferPayload(cached, names)), deferredNames(deferPayload(plain, names))); + assert.equal(JSON.stringify(deferPayload(cached, names)), JSON.stringify(deferPayload(plain, names))); + for (const out of [deferPayload(cached, names), deferPayload(plain, names)]) { + for (const t of (out as { tools: Array> }).tools) assert.ok(!(t.defer_loading === true && Object.hasOwn(t, "cache_control")), String(t.name)); + } +}); + +test("the all-deferred guard still holds when the last tool has cache_control", () => { + assert.equal(deferPayload(payload(tool("list_peers"), tool("gov_search", { cache_control: { type: "ephemeral" } })), ["gov_search", "list_peers"]), undefined); +}); + +test("no match, no tools or a non-object payload: undefined (Pi keeps its payload)", () => { + assert.equal(deferPayload(payload(tool("read"), tool("bash")), ["gov_search"]), undefined); + assert.equal(deferPayload({ model: "x", messages: [] }, ["gov_search"]), undefined); + assert.equal(deferPayload("text", ["gov_search"]), undefined); + assert.equal(deferPayload(null, ["gov_search"]), undefined); + assert.equal(deferPayload(payload(tool("read"), tool("gov_search")), []), undefined); +}); + +test("output is byte-identical on every call, keeps tool and key order, and adds only defer_loading", () => { + const input = payload(tool("read"), tool("gov_search"), tool("bash"), tool("list_peers")); + const first = JSON.stringify(deferPayload(input, ["list_peers", "gov_search"])); + for (let i = 0; i < 5; i++) assert.equal(JSON.stringify(deferPayload(structuredClone(input), ["gov_search", "list_peers"])), first); + const out = JSON.parse(first) as Record & { tools: Array> }; + assert.deepEqual(Object.keys(out), Object.keys(input)); + assert.deepEqual(out.tools.map((t) => t.name), ["read", "gov_search", "bash", "list_peers"]); + assert.deepEqual(Object.keys(out.tools[1]), ["name", "description", "input_schema", "defer_loading"]); + const stripped = { ...out, tools: out.tools.map(({ defer_loading: _d, ...rest }) => rest) }; + assert.equal(JSON.stringify(stripped), JSON.stringify(input)); +}); + +test("the input payload is not mutated", () => { + const input = payload(tool("read"), tool("gov_search")); + const before = JSON.stringify(input); + const out = deferPayload(input, ["gov_search"]) as typeof input; + assert.equal(JSON.stringify(input), before); + assert.notEqual(out, input); + assert.notEqual(out.tools, input.tools); + assert.equal(out.tools[0], input.tools[0], "an unchanged tool object is shared, not copied"); +}); + +test("Meridian detection: anthropic-messages and a Meridian base URL or header", () => { + const urls = DEFAULT_MERIDIAN_BASE_URLS; + assert.equal(isMeridianModel(meridian, urls), true); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "http://localhost:3456/" }, urls), true); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "https://api.anthropic.com" }, urls), false); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "https://gw.example", headers: { "x-meridian-agent": "pi" } }, urls), true); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "https://gw.example", headers: { "X-Meridian-Agent": "pi" } }, urls), true); + assert.equal(isMeridianModel({ ...meridian, api: "openai-completions" }, urls), false); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "http://127.0.0.1:34567" }, urls), false); + assert.equal(isMeridianModel(undefined, urls), false); + assert.equal(isMeridianModel({ ...meridian, baseUrl: "https://gw.example", headers: { "x-meridian-agent": null } as never }, urls), false); +}); + +test("a request goes to Meridian only when its model id matches the selected Meridian model", () => { + const urls = DEFAULT_MERIDIAN_BASE_URLS; + assert.equal(requestGoesToMeridian({ model: meridian }, { model: "claude-opus-5-5" }, urls), true); + assert.equal(requestGoesToMeridian({ model: meridian }, { model: "claude-haiku-5-5" }, urls), false); + assert.equal(requestGoesToMeridian({ model: { ...meridian, baseUrl: "https://api.anthropic.com" } }, { model: "claude-opus-5-5" }, urls), false); + assert.equal(requestGoesToMeridian({ model: undefined }, { model: "claude-opus-5-5" }, urls), false); +}); + +test("a virtual selection defers only when every registry model with the payload's id is Meridian", () => { + const urls = DEFAULT_MERIDIAN_BASE_URLS; + const virtual = { provider: "auto", id: "auto", api: "pi-virtual", baseUrl: "" }; + const registry = (...models: typeof meridian[]) => ({ getAll: () => models }); + assert.equal(requestGoesToMeridian({ model: virtual, modelRegistry: registry(meridian) }, { model: "claude-opus-5-5" }, urls), true); + assert.equal(requestGoesToMeridian({ model: virtual, modelRegistry: registry(meridian, { ...meridian, provider: "direct", baseUrl: "https://api.anthropic.com" }) }, { model: "claude-opus-5-5" }, urls), false); + assert.equal(requestGoesToMeridian({ model: virtual, modelRegistry: registry() }, { model: "claude-opus-5-5" }, urls), false); +}); + +type Handler = (event: { type: string; payload: unknown }, ctx: unknown) => unknown; +function fakePi() { + const handlers = new Map(); + const appended: Array<{ customType: string; data: unknown }> = []; + return { + handlers, + appended, + appendEntry(customType: string, data: unknown) { appended.push({ customType, data: structuredClone(data) }); }, + start(ctx: unknown) { + for (const handler of handlers.get("session_start") ?? []) handler({ type: "session_start" } as never, ctx); + }, + on(name: string, handler: Handler) { handlers.set(name, [...(handlers.get(name) ?? []), handler]); }, + headers(headers: Record, ctx: unknown) { + for (const handler of handlers.get("before_provider_headers") ?? []) handler({ type: "before_provider_headers", headers } as never, ctx); + }, + emit(payloadValue: unknown, ctx: unknown) { + const list = handlers.get("before_provider_request") ?? []; + return list.map((handler) => handler({ type: "before_provider_request", payload: payloadValue }, ctx)); + }, + }; +} +const ctxFor = (model: unknown, sessionId = "s1", branch: unknown[] = []) => ({ model, sessionManager: { getSessionId: () => sessionId, getBranch: () => branch } }); +const userEntry = { type: "message", id: "u1", message: { role: "user", content: "hi" } }; +const assistantEntry = { type: "message", id: "a1", message: { role: "assistant", content: [], provider: "anthropic", model: "claude-opus-5-5" } }; +const frozenEntry = (names: string[]) => ({ type: "custom", id: "f1", customType: FROZEN_ENTRY, data: { names, meridianBaseUrls: [...DEFAULT_MERIDIAN_BASE_URLS] } }); + +test("the registered handler defers the configured tools for Meridian and leaves other providers alone", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const input = payload(tool("read"), tool("gov_search")); + const [out] = pi.emit(input, ctxFor(meridian)); + assert.deepEqual(deferredNames(out), ["gov_search"]); + const [other] = pi.emit(input, ctxFor({ ...meridian, provider: "direct", baseUrl: "https://api.anthropic.com" })); + assert.equal(other, undefined); + const [openai] = pi.emit({ model: "claude-opus-5-5", messages: [], tools: [{ type: "function", function: { name: "gov_search" } }] }, ctxFor({ ...meridian, api: "openai-completions" })); + assert.equal(openai, undefined); +}); + +test("the list is frozen for a session: a config change after load or a /reload does not reach it", () => { + const store = new Map(); + const config: Record = { names: ["gov_search"] }; + const first = fakePi(); + deferTools(first as never, { config, env: {}, store }); + const input = payload(tool("read"), tool("gov_search"), tool("list_peers")); + assert.deepEqual(deferredNames(first.emit(input, ctxFor(meridian))[0]), ["gov_search"]); + config.names = ["list_peers"]; + assert.deepEqual(deferredNames(first.emit(input, ctxFor(meridian))[0]), ["gov_search"]); + // /reload: a new copy reads the new config, but the session keeps its first list. + const reloaded = fakePi(); + deferTools(reloaded as never, { config: { names: ["list_peers"] }, env: {}, store }); + assert.deepEqual(deferredNames(reloaded.emit(input, ctxFor(meridian))[0]), ["gov_search"]); + // A new session takes the config of its own start. + assert.deepEqual(deferredNames(reloaded.emit(input, ctxFor(meridian, "s2"))[0]), ["list_peers"]); +}); + +test("the kill switch, an empty list and a missing list never change a payload", () => { + const off = fakePi(); + deferTools(off as never, { config: { names: ["gov_search"] }, env: { PI_EXTRAS_DEFER_TOOLS: "0" }, store: new Map() }); + const empty = fakePi(); + deferTools(empty as never, { config: { names: [] }, env: {}, store: new Map() }); + const input = payload(tool("read"), tool("gov_search")); + assert.deepEqual(off.emit(input, ctxFor(meridian)).filter((value) => value !== undefined), []); + assert.deepEqual(empty.emit(input, ctxFor(meridian)).filter((value) => value !== undefined), []); + const unset = fakePi(); + deferTools(unset as never, { config: {}, env: {}, store: new Map() }); + assert.deepEqual(unset.emit(input, ctxFor(meridian)).filter((value) => value !== undefined), []); +}); + +test("package.json loads Defer Tools before Cache Compaction and Status Plus, so their captures see the deferral", () => { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { pi: { extensions: string[] } }; + const at = (path: string) => pkg.pi.extensions.indexOf(path); + assert.ok(at("extensions/defer-tools.ts") >= 0); + assert.ok(at("extensions/defer-tools.ts") < at("extensions/cache-compaction.ts")); + assert.ok(at("extensions/defer-tools.ts") < at("extensions/status-plus.ts")); + assert.ok(at("extensions/defer-tools.ts") < at("extensions/phase-spinner.ts")); +}); + +test("the x-meridian-agent request header marks a model as Meridian from its first request on", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const gateway = { ...meridian, provider: "gateway", baseUrl: "https://gw.example" }; + const input = payload(tool("read"), tool("gov_search")); + pi.headers({ "x-meridian-agent": "pi" }, ctxFor(gateway)); + assert.deepEqual(deferredNames(pi.emit(input, ctxFor(gateway))[0]), ["gov_search"]); + // Another model of no Meridian provider stays as it is. + const direct = { ...meridian, provider: "direct", baseUrl: "https://api.anthropic.com" }; + pi.headers({ authorization: "x" }, ctxFor(direct)); + assert.equal(pi.emit(input, ctxFor(direct))[0], undefined); + assert.equal(requestGoesToMeridian({ model: gateway }, { model: "claude-opus-5-5" }, DEFAULT_MERIDIAN_BASE_URLS, new Set(["gateway/claude-opus-5-5"])), true); + assert.equal(requestGoesToMeridian({ model: { ...gateway, api: "openai-completions" } }, { model: "claude-opus-5-5" }, DEFAULT_MERIDIAN_BASE_URLS, new Set(["gateway/claude-opus-5-5"])), false); +}); + +test("a new session reads pi-extras.json and writes its list into the session once", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "new1"); + pi.start(ctx); + const input = payload(tool("read"), tool("gov_search")); + assert.deepEqual(deferredNames(pi.emit(input, ctx)[0]), ["gov_search"]); + pi.emit(input, ctx); + pi.start(ctx); + assert.deepEqual(pi.appended, [{ customType: FROZEN_ENTRY, data: { names: ["gov_search"], meridianBaseUrls: [...DEFAULT_MERIDIAN_BASE_URLS] } }]); +}); + +test("a session with deferral off writes no entry", () => { + const pi = fakePi(); + deferTools(pi as never, { config: {}, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "off1"); + pi.start(ctx); + assert.equal(pi.emit(payload(tool("read"), tool("gov_search")), ctx)[0], undefined); + assert.deepEqual(pi.appended, []); +}); + +test("a resumed session with an entry keeps its list, whatever pi-extras.json says now", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["list_peers"] }, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "old1", [frozenEntry(["gov_search"]), userEntry, assistantEntry]); + pi.start(ctx); + const input = payload(tool("read"), tool("gov_search"), tool("list_peers")); + assert.deepEqual(deferredNames(pi.emit(input, ctx)[0]), ["gov_search"]); + assert.deepEqual(pi.appended, []); +}); + +test("a resumed session with an empty entry stays off when pi-extras.json now lists tools", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "old2", [frozenEntry([]), userEntry, assistantEntry]); + pi.start(ctx); + assert.equal(pi.emit(payload(tool("read"), tool("gov_search")), ctx)[0], undefined); +}); + +test("a session with earlier assistant turns and no entry never starts to defer", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "old3", [userEntry, assistantEntry]); + pi.start(ctx); + assert.equal(pi.emit(payload(tool("read"), tool("gov_search")), ctx)[0], undefined); + assert.deepEqual(pi.appended, []); +}); + +test("a fork keeps the entry of its source: the entry is on the branch it copies", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: [] }, env: {}, store: new Map() }); + const ctx = ctxFor(meridian, "fork1", [frozenEntry(["gov_search"]), userEntry, assistantEntry, userEntry]); + pi.start(ctx); + assert.deepEqual(deferredNames(pi.emit(payload(tool("read"), tool("gov_search")), ctx)[0]), ["gov_search"]); + assert.deepEqual(pi.appended, []); +}); + +test("a /reload copy finds the session's entry, with no process record", () => { + const first = fakePi(); + deferTools(first as never, { config: { names: ["gov_search"] }, env: {}, store: new Map() }); + const branch: unknown[] = []; + const ctx = ctxFor(meridian, "rel1", branch); + first.start(ctx); + branch.push({ type: "custom", id: "f1", customType: first.appended[0].customType, data: first.appended[0].data }, userEntry, assistantEntry); + const reloaded = fakePi(); + deferTools(reloaded as never, { config: { names: ["list_peers"] }, env: {}, store: new Map() }); + reloaded.start(ctx); + assert.deepEqual(deferredNames(reloaded.emit(payload(tool("read"), tool("gov_search"), tool("list_peers")), ctx)[0]), ["gov_search"]); + assert.deepEqual(reloaded.appended, []); +}); + +test("an entry with bad data freezes to what parses, never to pi-extras.json", () => { + const pi = fakePi(); + deferTools(pi as never, { config: { names: ["list_peers"] }, env: {}, store: new Map() }); + const bad = { type: "custom", id: "f1", customType: FROZEN_ENTRY, data: { names: "gov_search" } }; + const ctx = ctxFor(meridian, "bad1", [bad, userEntry, assistantEntry]); + pi.start(ctx); + assert.equal(pi.emit(payload(tool("read"), tool("list_peers")), ctx)[0], undefined); +}); diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index 36d9b3e..f625f98 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -5,10 +5,10 @@ import { resolve } from 'node:path'; import test from 'node:test'; const root = fileURLToPath(new URL('../', import.meta.url)); -test('package explicitly exports twenty-two extensions and one theme', () => { +test('package explicitly exports twenty-three extensions and one theme', () => { const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')); assert.deepEqual(pkg.pi.extensions, [ - 'extensions/tab-status.ts', 'extensions/status-plus.ts', 'extensions/usage-guard.ts', 'extensions/rate-limit-recovery.ts', 'extensions/phase-spinner.ts', + 'extensions/tab-status.ts', 'extensions/defer-tools.ts', 'extensions/status-plus.ts', 'extensions/usage-guard.ts', 'extensions/rate-limit-recovery.ts', 'extensions/phase-spinner.ts', 'extensions/shell-jobs.ts', 'extensions/subagents.ts', 'extensions/tool-loadout.ts', 'extensions/bash-default-timeout.ts', 'extensions/kagi-search.ts', 'extensions/gov-search.ts', 'extensions/voice.ts', 'extensions/computer-use.ts', 'extensions/tool-display.ts', 'extensions/copy-blocks.ts', 'extensions/release-notes.ts', 'extensions/cache-compaction.ts',