diff --git a/docs/MCP.md b/docs/MCP.md index ff64d2bd6..7b5679a69 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -93,9 +93,12 @@ Tools from connected servers are not advertised to the model up front; they are registered for dispatch as soon as the server connects (including later in the same turn) and surfaced on demand through dynamic tool discovery (`tool_search`). A match is a ranked handful of names plus capped -descriptions — not full input schemas. Names activated via `tool_search` -join the next inference tool list (no wait for compact), persist in the -session's `run.json`, and are re-advertised on resume and after rebuilds. +descriptions — not full input schemas. `tool_search` does not promote +names onto the next inference or into `run.json`. Calling a discovered +name declares that one schema on the next infer (promote-on-execute), +persists it in the session's `run.json`, and re-advertises it on resume +and after rebuilds. Fold drops idle execute-promoted schemas and persists +that empty set so a crash or resume does not restore them. For integrations a project calls constantly, `pinnedTools` in local `.corbits/settings.json` keeps those names on the wire permanently — no diff --git a/src/exec/runner.ts b/src/exec/runner.ts index c65cf9198..76a7a3550 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -128,6 +128,7 @@ import { assembleSessionGate, assembleSessionLifecycle, assembleSessionTrust, + commitIdlePromotionPrune, createAdvertisedToolset, loadSessionLocalSettings, resolveLiveSessionSources, @@ -944,14 +945,21 @@ export async function runExec(config: Config): Promise { telemetry: liveTelemetry, onFolded: () => { // Fold restarts the cached prefix — drop idle execute-promoted - // schemas rather than carrying them forever. - if (pruneIdlePromotions()) { - directorHolder.instance?.updateToolDefinitions( - computeAdvertised( - agentToolset.dynamicRunner.currentDefinitions(), - ), - ); - } + // schemas rather than carrying them forever, and persist so a + // crash resume cannot re-flush the pruned names. + commitIdlePromotionPrune({ + pruneIdlePromotions, + refreshAdvertised: () => { + directorHolder.instance?.updateToolDefinitions( + computeAdvertised( + agentToolset.dynamicRunner.currentDefinitions(), + ), + ); + }, + persist: () => { + void persist("running"); + }, + }); }, }), getCacheWriteSeed: () => diff --git a/src/session/active-run.ts b/src/session/active-run.ts index c537c8dd7..5795591dc 100644 --- a/src/session/active-run.ts +++ b/src/session/active-run.ts @@ -22,9 +22,10 @@ export interface RunStateHandle { startedAt: number; turnsUsed: number; model?: string; - // Latest tool_search-activated tool names, synced on every snapshot so the + // Latest execute-promoted tool names, synced on every snapshot so the // crash/signal terminal write can carry them into run.json for the resume - // seed. + // seed. Fold prune syncs an empty list so that write cannot restore + // dropped schemas. activatedTools?: string[]; // Last Anthropic-protocol cache write, and the run-record model that wrote // it. The crash path copies the stamp so a killed process can still fold diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index e872fbd4a..c21426e5f 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Agent, DirectorRegistry } from "@intx/agent"; @@ -13,11 +13,14 @@ import type { import { withMockedModuleDuring } from "../../testkit/mock-module.js"; import type { ChatDirector } from "../agent/director.js"; import { authzParityDefinitions } from "../agent/tool-aliases.js"; +import { syncRunStateHandle, type RunStateHandle } from "./active-run.js"; import { + commitIdlePromotionPrune, createAdvertisedToolset, loadSessionLocalSettings, type ChatAgentWiring, } from "./assemble-runtime.js"; +import { loadState, saveState } from "./state.js"; function def(name: string): ToolDefinition { return { @@ -182,6 +185,102 @@ describe("createAdvertisedToolset", () => { expect(pruneIdlePromotions()).toBe(false); }); + test("fold prune persist omits activatedTools from run.json and the crash handle", async () => { + const cwd = await mkdtemp(join(tmpdir(), "fold-prune-persist-cwd-")); + const home = await mkdtemp(join(tmpdir(), "fold-prune-persist-home-")); + try { + const { + activated, + computeAdvertised, + flushPromotions, + pruneIdlePromotions, + } = createAdvertisedToolset(wiring()); + const defs = [def("read_file"), def("mcp__acme__do")]; + activated.activate(["mcp__acme__do"]); + expect(flushPromotions()).toBe(true); + + const sessionId = "fold-prune-persist"; + const base = { + status: "running" as const, + turnsUsed: 2, + task: "task", + startedAt: 1, + }; + await saveState( + cwd, + sessionId, + { ...base, activatedTools: activated.list() }, + home, + ); + const handle: RunStateHandle = { + sessionId, + cwd, + task: base.task, + startedAt: base.startedAt, + turnsUsed: base.turnsUsed, + activatedTools: activated.list(), + }; + + let refreshed = false; + let persistWrite: Promise | undefined; + expect( + commitIdlePromotionPrune({ + pruneIdlePromotions, + refreshAdvertised: () => { + refreshed = true; + expect(computeAdvertised(defs).map((d) => d.name)).not.toContain( + "mcp__acme__do", + ); + }, + persist: () => { + const tools = activated.list(); + syncRunStateHandle(handle, { + turnsUsed: base.turnsUsed, + task: base.task, + startedAt: base.startedAt, + activatedTools: tools, + }); + persistWrite = saveState( + cwd, + sessionId, + { + ...base, + ...(tools.length > 0 ? { activatedTools: tools } : {}), + }, + home, + ); + }, + }), + ).toBe(true); + expect(refreshed).toBe(true); + expect(handle.activatedTools).toEqual([]); + if (persistWrite === undefined) { + throw new Error("fold prune persist did not write"); + } + await persistWrite; + + const loaded = await loadState(cwd, sessionId, home); + expect(loaded.kind).toBe("ok"); + if (loaded.kind !== "ok") return; + expect(loaded.state.activatedTools).toBeUndefined(); + + expect( + commitIdlePromotionPrune({ + pruneIdlePromotions, + refreshAdvertised: () => { + throw new Error("idle prune must not refresh twice"); + }, + persist: () => { + throw new Error("idle prune must not persist twice"); + }, + }), + ).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + } + }); + test("pinned tools are advertised before any activation and survive clear", () => { const { activated, computeAdvertised } = createAdvertisedToolset( wiring({ pinnedTools: ["mcp__linear__save_issue"] }), diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 1bf440700..1e5c4007c 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -495,6 +495,22 @@ export function createAdvertisedToolset(args: { }; } +/** + * Fold already broke the cache prefix. Drop idle execute-promoted schemas, + * refresh the advertised wire, and persist so resume/crash cannot restore + * the pruned names from run.json. + */ +export function commitIdlePromotionPrune(args: { + pruneIdlePromotions: () => boolean; + refreshAdvertised: () => void; + persist: () => void; +}): boolean { + if (!args.pruneIdlePromotions()) return false; + args.refreshAdvertised(); + args.persist(); + return true; +} + // --------------------------------------------------------------------------- // 7. Chat agent: director def, agent def, live builder // --------------------------------------------------------------------------- diff --git a/src/session/state.ts b/src/session/state.ts index 231ddb03e..c069d2324 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -46,9 +46,10 @@ const RunStateSchema = type({ // MCP servers connected during the session, with the tool count each // contributed. Empty until the first server finishes connecting. "mcpServers?": ConnectedMcpServerSchema.array(), - // Tool names promoted via tool_search this session. Persisted - // so a resume can re-activate them before the first post-resume inference — - // the transcript still tells the model they are callable. + // Tool names promoted on execute this session. Persisted so a resume can + // re-activate them before the first post-resume inference — the transcript + // still tells the model they are callable. Fold prune omits this field so + // a crash cannot restore dropped schemas. "activatedTools?": "string[]", // Wall time of the last Anthropic-protocol inference. A later process // folds before its first infer once this is at least the published TTL old. diff --git a/src/tui/resume-seed.test.ts b/src/tui/resume-seed.test.ts index cff8a8ba7..02e38cb14 100644 --- a/src/tui/resume-seed.test.ts +++ b/src/tui/resume-seed.test.ts @@ -64,4 +64,10 @@ describe("resolveResumeSeed", () => { expect(seed.activatedTools).toEqual([]); }); + + test("an empty activatedTools list from a crash write does not re-activate pruned names", () => { + expect( + resolveResumeSeed(pickedState({ activatedTools: [] })).activatedTools, + ).toEqual([]); + }); }); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 1de862252..0d770ee64 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -47,6 +47,7 @@ import { assembleChatAgent, assembleSessionGate, assembleSessionLifecycle, + commitIdlePromotionPrune, createAdvertisedToolset, loadSessionLocalSettings, resolveLiveSessionSources, @@ -713,12 +714,19 @@ export async function assembleTUISession( // Main-session folds only — exec runner and subagents stay silent. onFolded: (info) => { // Fold restarts the cached prefix — drop idle execute-promoted - // schemas rather than carrying them forever. - if (pruneIdlePromotions()) { - directorHolder.instance?.updateToolDefinitions( - computeAdvertised(toolset.dynamicRunner.currentDefinitions()), - ); - } + // schemas rather than carrying them forever, and persist so a + // crash resume cannot re-flush the pruned names. + commitIdlePromotionPrune({ + pruneIdlePromotions, + refreshAdvertised: () => { + directorHolder.instance?.updateToolDefinitions( + computeAdvertised(toolset.dynamicRunner.currentDefinitions()), + ); + }, + persist: () => { + void state.persistRunSnapshot?.("running"); + }, + }); emitter.emit("compaction", info); }, }),