From d7fe0d85d89b390eb1556a88277e07660071c7a1 Mon Sep 17 00:00:00 2001 From: Jaedyn Chilton Date: Thu, 12 Nov 2026 22:22:44 -0600 Subject: [PATCH 1/3] feat: add Defer Tools, opt-in defer_loading for tools in requests to Meridian Defer Tools adds `defer_loading: true` to the tools named in `deferTools.names` (pi-extras.json), in each `before_provider_request` payload that goes to Meridian. A request goes to Meridian when the selected model uses `anthropic-messages`, the payload's model id is that model's id, and the model has a Meridian base URL (`deferTools.meridianBaseUrls`, default 127.0.0.1:3456 and localhost:3456) or its request headers carried `x-meridian-agent` (seen in `before_provider_headers`; Pi 1.5.2 resolves provider headers at request time, so `ctx.model.headers` is empty). Other providers get the payload unchanged. It is off by default: with any tool deferred, Meridian 1.89.0 lifts its single-turn cap and every tool-call turn pays an extra hidden model turn. The measured 13-tool list is exported as RECOMMENDED_DEFER_TOOLS and given in the README. Each session freezes its list at its start in a Symbol.for slot, so a config edit or /reload never changes the tools of a running session. The transform is pure: same payload, same bytes; tool and key order kept; the input is not mutated. It never defers read, bash, edit, write, codemode or powershell, keeps an existing defer_loading value, leaves a tool with cache_control loaded (Anthropic rejects cache_control on a deferred tool; Pi puts it on the last tool), and changes nothing when no tool would stay loaded. PI_EXTRAS_DEFER_TOOLS=0 turns it off. It loads second, after Tab Status and before Status Plus and Cache Compaction, so Cache Compaction captures the deferred payload for its warm requests. Pi's own cache warmer replays through onPayload, so it gets the same deferral. Subagent children do not load it. --- CHANGELOG.md | 15 +++ README.md | 57 +++++++- docs/security.md | 9 ++ extensions/defer-tools.ts | 180 ++++++++++++++++++++++++++ package.json | 1 + scripts/install-smoke.mjs | 4 +- tests/defer-tools.test.ts | 223 ++++++++++++++++++++++++++++++++ tests/package-contract.test.mjs | 4 +- 8 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 extensions/defer-tools.ts create mode 100644 tests/defer-tools.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f883b72..1b25798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ 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 a Meridian build that keeps its single-turn + limit for sessions with deferred tools: Meridian 1.89.0 removes the limit, + and each turn with a tool call then costs one more hidden model turn. Each + session keeps the list of its start, so a config edit never rewrites the + prompt cache of a running session. `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..93fe6c2 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,58 @@ 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. When a request defers a tool, Meridian removes its +single-turn limit, and each turn with a tool call then costs one more hidden +model turn. That cost is larger than the saving. Turn it on only with a +Meridian build that keeps the single-turn limit for sessions with deferred +tools. 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. A config edit, or a `/reload`, +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 +tool with `cache_control` stays loaded, because Anthropic does not accept +`cache_control` on a deferred tool. 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..3ad3a37 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1028,6 +1028,15 @@ 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. It reads the +`deferTools` section of `pi-extras.json` and `PI_EXTRAS_DEFER_TOOLS` once per +session. 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..cb5257c --- /dev/null +++ b/extensions/defer-tools.ts @@ -0,0 +1,180 @@ +/** + * 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: a later edit of pi-extras.json, or a `/reload`, reaches only new sessions. 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: with any tool deferred, Meridian lifts its single-turn cap + * and every tool-call turn pays an extra hidden model turn, which costs more than deferral saves. + */ +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"); + +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. Anthropic rejects cache_control on a deferred tool, and the breakpoint must not move. + && !Object.hasOwn(tool, "defer_loading") && !Object.hasOwn(tool, "cache_control"); +} + +/** + * 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] ? { ...(tool as ToolLike), defer_loading: true } : 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 }; +} + +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(); + // Read 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?.() ?? ""; + let config = store.get(id); + if (!config) { config = freezeDeferConfig(options.config ?? readSection("deferTools"), env); 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..b59cc51 --- /dev/null +++ b/tests/defer-tools.test.ts @@ -0,0 +1,223 @@ +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, +} 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 tool with cache_control is not deferred and keeps its cache_control in place", () => { + 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"]); + assert.deepEqual(out.tools[2], input.tools[2]); + assert.deepEqual(Object.keys(out.tools[2]), ["name", "description", "input_schema", "cache_control"]); +}); + +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(); + return { + handlers, + 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") => ({ model, sessionManager: { getSessionId: () => sessionId } }); + +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); +}); 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', From 5943dabbbc747852defaa7f2d5c84237198926e5 Mon Sep 17 00:00:00 2001 From: Jaedyn Chilton Date: Thu, 12 Nov 2026 22:33:27 -0600 Subject: [PATCH 2/3] feat: Defer Tools keeps each session's list in a session entry A resumed session read pi-extras.json again, so a config edit between two runs changed its tools and rewrote its prompt cache. Now a new session writes its frozen list once, at its start, as a `defer-tools/frozen` custom entry (pi.appendEntry), and only when the list is not empty. A resumed, forked or reloaded session takes the latest such entry on its branch (sessionManager.getBranch) and ignores pi-extras.json. A session with an assistant turn and no entry freezes to an empty list, so deferral never starts in the middle of a session. Only a session with no turns reads the config. --- CHANGELOG.md | 6 ++- README.md | 14 ++++-- docs/security.md | 9 ++-- extensions/defer-tools.ts | 43 +++++++++++++++--- tests/defer-tools.test.ts | 92 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 149 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b25798..a19af3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,10 @@ Versioning rules are in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). tools. Turn it on only with a Meridian build that keeps its single-turn limit for sessions with deferred tools: Meridian 1.89.0 removes the limit, and each turn with a tool call then costs one more hidden model turn. Each - session keeps the list of its start, so a config edit never rewrites the - prompt cache of a running session. `PI_EXTRAS_DEFER_TOOLS=0` turns it off. + 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 diff --git a/README.md b/README.md index 93fe6c2..bac5083 100644 --- a/README.md +++ b/README.md @@ -1877,9 +1877,17 @@ tools. This is the recommended list (tools used in under 5% of sessions): - `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. A config edit, or a `/reload`, -applies only to new sessions. The same request always gives the same bytes, -so a cache warm repeats the real request. +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 diff --git a/docs/security.md b/docs/security.md index 3ad3a37..cc165f4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1031,10 +1031,11 @@ the time, numbers and the labels of slow synchronous operations, such as ## Defer Tools Defer Tools is off until `deferTools.names` lists tools. It sends nothing by -itself and writes no files. It reads the -`deferTools` section of `pi-extras.json` and `PI_EXTRAS_DEFER_TOOLS` once per -session. 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 +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 diff --git a/extensions/defer-tools.ts b/extensions/defer-tools.ts index cb5257c..c64151b 100644 --- a/extensions/defer-tools.ts +++ b/extensions/defer-tools.ts @@ -5,7 +5,8 @@ * 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: a later edit of pi-extras.json, or a `/reload`, reaches only new sessions. The output + * 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. * @@ -32,6 +33,8 @@ 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[]; @@ -150,7 +153,27 @@ export interface DeferToolsOptions { } interface SessionContext extends RequestContext { - readonly sessionManager?: { getSessionId?: () => string }; + 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 { @@ -158,11 +181,21 @@ export default function deferTools(pi: ExtensionAPI, options: DeferToolsOptions // 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(); - // Read once per session, at its start (or its first request), and never again for it. + // 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?.() ?? ""; - let config = store.get(id); - if (!config) { config = freezeDeferConfig(options.config ?? readSection("deferTools"), env); store.set(id, config); } + 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. diff --git a/tests/defer-tools.test.ts b/tests/defer-tools.test.ts index b59cc51..ce3c26d 100644 --- a/tests/defer-tools.test.ts +++ b/tests/defer-tools.test.ts @@ -9,6 +9,7 @@ import deferTools, { 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 }); @@ -142,8 +143,14 @@ test("a virtual selection defers only when every registry model with the payload 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); @@ -154,7 +161,10 @@ function fakePi() { }, }; } -const ctxFor = (model: unknown, sessionId = "s1") => ({ model, sessionManager: { getSessionId: () => sessionId } }); +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(); @@ -221,3 +231,83 @@ test("the x-meridian-agent request header marks a model as Meridian from its fir 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); +}); From 86ca949d5dd4055c48092ffc0c92b535c3eb1a52 Mon Sep 17 00:00:00 2001 From: Jaedyn Chilton Date: Thu, 12 Nov 2026 22:41:47 -0600 Subject: [PATCH 3/3] fix: Defer Tools defers a listed tool with cache_control and drops the key Pi puts cache_control on the last tool only while cache retention is not `none`, so skipping such a tool tied the deferred set to that setting, and a retention change in a session changed what Meridian saw. A listed tool is now deferred whether or not it has cache_control, and its cache_control key is removed (Anthropic rejects both on one tool; Meridian rebuilds tools through MCP and never forwards a client's tool cache_control). The deferred set depends only on the frozen list and the tools present. The guard that keeps one tool loaded stays. The docs no longer say that every tool-call turn pays a digest turn: Meridian 1.89.0 keeps tool search without it (upstream #1368 and #1370); older builds send deferred tools in full and add the turn. Default stays off. --- CHANGELOG.md | 11 +++++------ README.md | 19 +++++++++++-------- extensions/defer-tools.ts | 22 +++++++++++++++++----- tests/defer-tools.test.ts | 23 +++++++++++++++++++---- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a19af3d..916ca98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,11 @@ Versioning rules are in [CONTRIBUTING.md](CONTRIBUTING.md#versioning). 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 a Meridian build that keeps its single-turn - limit for sessions with deferred tools: Meridian 1.89.0 removes the limit, - and each turn with a tool call then costs one more hidden model turn. 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 + 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. diff --git a/README.md b/README.md index bac5083..8a62584 100644 --- a/README.md +++ b/README.md @@ -1851,11 +1851,11 @@ 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. When a request defers a tool, Meridian removes its -single-turn limit, and each turn with a tool call then costs one more hidden -model turn. That cost is larger than the saving. Turn it on only with a -Meridian build that keeps the single-turn limit for sessions with deferred -tools. This is the recommended list (tools used in under 5% of sessions): +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 { @@ -1891,9 +1891,12 @@ 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 -tool with `cache_control` stays loaded, because Anthropic does not accept -`cache_control` on a deferred tool. When every tool of a request would be -deferred, the request does not change, because one tool must stay loaded. +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. diff --git a/extensions/defer-tools.ts b/extensions/defer-tools.ts index c64151b..22ce193 100644 --- a/extensions/defer-tools.ts +++ b/extensions/defer-tools.ts @@ -18,8 +18,9 @@ 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: with any tool deferred, Meridian lifts its single-turn cap - * and every tool-call turn pays an extra hidden model turn, which costs more than deferral saves. + * `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", @@ -115,8 +116,19 @@ 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. Anthropic rejects cache_control on a deferred tool, and the breakpoint must not move. - && !Object.hasOwn(tool, "defer_loading") && !Object.hasOwn(tool, "cache_control"); + // 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 }; } /** @@ -133,7 +145,7 @@ export function deferPayload(payload: unknown, names: readonly string[]): unknow 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] ? { ...(tool as ToolLike), defer_loading: true } : tool) }; + return { ...payload, tools: tools.map((tool, index) => marks[index] ? deferred(tool as ToolLike) : tool) }; } type SessionStore = Map; diff --git a/tests/defer-tools.test.ts b/tests/defer-tools.test.ts index ce3c26d..53738de 100644 --- a/tests/defer-tools.test.ts +++ b/tests/defer-tools.test.ts @@ -72,12 +72,27 @@ test("a tool that already has defer_loading keeps its value", () => { assert.equal(tools[2].defer_loading, true); }); -test("a tool with cache_control is not deferred and keeps its cache_control in place", () => { +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"]); - assert.deepEqual(out.tools[2], input.tools[2]); - assert.deepEqual(Object.keys(out.tools[2]), ["name", "description", "input_schema", "cache_control"]); + 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)", () => {