Skip to content

Commit 01b4e86

Browse files
committed
fix(tools): persist idle promotion prune after fold
Fold already drops execute-promoted schemas off the wire. Persist that empty set to run.json and the crash handle so resume cannot restore them.
1 parent 51d08f4 commit 01b4e86

8 files changed

Lines changed: 165 additions & 23 deletions

File tree

‎docs/MCP.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,12 @@ Tools from connected servers are not advertised to the model up front; they are
9393
registered for dispatch as soon as the server connects (including later in the
9494
same turn) and surfaced on demand through dynamic tool discovery
9595
(`tool_search`). A match is a ranked handful of names plus capped
96-
descriptions — not full input schemas. Names activated via `tool_search`
97-
join the next inference tool list (no wait for compact), persist in the
98-
session's `run.json`, and are re-advertised on resume and after rebuilds.
96+
descriptions — not full input schemas. `tool_search` does not promote
97+
names onto the next inference or into `run.json`. Calling a discovered
98+
name declares that one schema on the next infer (promote-on-execute),
99+
persists it in the session's `run.json`, and re-advertises it on resume
100+
and after rebuilds. Fold drops idle execute-promoted schemas and persists
101+
that empty set so a crash or resume does not restore them.
99102

100103
For integrations a project calls constantly, `pinnedTools` in local
101104
`.corbits/settings.json` keeps those names on the wire permanently — no

‎src/exec/runner.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ import {
128128
assembleSessionGate,
129129
assembleSessionLifecycle,
130130
assembleSessionTrust,
131+
commitIdlePromotionPrune,
131132
createAdvertisedToolset,
132133
loadSessionLocalSettings,
133134
resolveLiveSessionSources,
@@ -944,14 +945,21 @@ export async function runExec(config: Config): Promise<ExecResult> {
944945
telemetry: liveTelemetry,
945946
onFolded: () => {
946947
// Fold restarts the cached prefix — drop idle execute-promoted
947-
// schemas rather than carrying them forever.
948-
if (pruneIdlePromotions()) {
949-
directorHolder.instance?.updateToolDefinitions(
950-
computeAdvertised(
951-
agentToolset.dynamicRunner.currentDefinitions(),
952-
),
953-
);
954-
}
948+
// schemas rather than carrying them forever, and persist so a
949+
// crash resume cannot re-flush the pruned names.
950+
commitIdlePromotionPrune({
951+
pruneIdlePromotions,
952+
refreshAdvertised: () => {
953+
directorHolder.instance?.updateToolDefinitions(
954+
computeAdvertised(
955+
agentToolset.dynamicRunner.currentDefinitions(),
956+
),
957+
);
958+
},
959+
persist: () => {
960+
void persist("running");
961+
},
962+
});
955963
},
956964
}),
957965
getCacheWriteSeed: () =>

‎src/session/active-run.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ export interface RunStateHandle {
2222
startedAt: number;
2323
turnsUsed: number;
2424
model?: string;
25-
// Latest tool_search-activated tool names, synced on every snapshot so the
25+
// Latest execute-promoted tool names, synced on every snapshot so the
2626
// crash/signal terminal write can carry them into run.json for the resume
27-
// seed.
27+
// seed. Fold prune syncs an empty list so that write cannot restore
28+
// dropped schemas.
2829
activatedTools?: string[];
2930
// Last Anthropic-protocol cache write, and the run-record model that wrote
3031
// it. The crash path copies the stamp so a killed process can still fold

‎src/session/assemble-runtime.test.ts‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test";
2-
import { mkdtemp } from "node:fs/promises";
2+
import { mkdtemp, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { Agent, DirectorRegistry } from "@intx/agent";
@@ -13,11 +13,14 @@ import type {
1313
import { withMockedModuleDuring } from "../../testkit/mock-module.js";
1414
import type { ChatDirector } from "../agent/director.js";
1515
import { authzParityDefinitions } from "../agent/tool-aliases.js";
16+
import { syncRunStateHandle, type RunStateHandle } from "./active-run.js";
1617
import {
18+
commitIdlePromotionPrune,
1719
createAdvertisedToolset,
1820
loadSessionLocalSettings,
1921
type ChatAgentWiring,
2022
} from "./assemble-runtime.js";
23+
import { loadState, saveState } from "./state.js";
2124

2225
function def(name: string): ToolDefinition {
2326
return {
@@ -182,6 +185,102 @@ describe("createAdvertisedToolset", () => {
182185
expect(pruneIdlePromotions()).toBe(false);
183186
});
184187

188+
test("fold prune persist omits activatedTools from run.json and the crash handle", async () => {
189+
const cwd = await mkdtemp(join(tmpdir(), "fold-prune-persist-cwd-"));
190+
const home = await mkdtemp(join(tmpdir(), "fold-prune-persist-home-"));
191+
try {
192+
const {
193+
activated,
194+
computeAdvertised,
195+
flushPromotions,
196+
pruneIdlePromotions,
197+
} = createAdvertisedToolset(wiring());
198+
const defs = [def("read_file"), def("mcp__acme__do")];
199+
activated.activate(["mcp__acme__do"]);
200+
expect(flushPromotions()).toBe(true);
201+
202+
const sessionId = "fold-prune-persist";
203+
const base = {
204+
status: "running" as const,
205+
turnsUsed: 2,
206+
task: "task",
207+
startedAt: 1,
208+
};
209+
await saveState(
210+
cwd,
211+
sessionId,
212+
{ ...base, activatedTools: activated.list() },
213+
home,
214+
);
215+
const handle: RunStateHandle = {
216+
sessionId,
217+
cwd,
218+
task: base.task,
219+
startedAt: base.startedAt,
220+
turnsUsed: base.turnsUsed,
221+
activatedTools: activated.list(),
222+
};
223+
224+
let refreshed = false;
225+
let persistWrite: Promise<void> | undefined;
226+
expect(
227+
commitIdlePromotionPrune({
228+
pruneIdlePromotions,
229+
refreshAdvertised: () => {
230+
refreshed = true;
231+
expect(computeAdvertised(defs).map((d) => d.name)).not.toContain(
232+
"mcp__acme__do",
233+
);
234+
},
235+
persist: () => {
236+
const tools = activated.list();
237+
syncRunStateHandle(handle, {
238+
turnsUsed: base.turnsUsed,
239+
task: base.task,
240+
startedAt: base.startedAt,
241+
activatedTools: tools,
242+
});
243+
persistWrite = saveState(
244+
cwd,
245+
sessionId,
246+
{
247+
...base,
248+
...(tools.length > 0 ? { activatedTools: tools } : {}),
249+
},
250+
home,
251+
);
252+
},
253+
}),
254+
).toBe(true);
255+
expect(refreshed).toBe(true);
256+
expect(handle.activatedTools).toEqual([]);
257+
if (persistWrite === undefined) {
258+
throw new Error("fold prune persist did not write");
259+
}
260+
await persistWrite;
261+
262+
const loaded = await loadState(cwd, sessionId, home);
263+
expect(loaded.kind).toBe("ok");
264+
if (loaded.kind !== "ok") return;
265+
expect(loaded.state.activatedTools).toBeUndefined();
266+
267+
expect(
268+
commitIdlePromotionPrune({
269+
pruneIdlePromotions,
270+
refreshAdvertised: () => {
271+
throw new Error("idle prune must not refresh twice");
272+
},
273+
persist: () => {
274+
throw new Error("idle prune must not persist twice");
275+
},
276+
}),
277+
).toBe(false);
278+
} finally {
279+
await rm(cwd, { recursive: true, force: true });
280+
await rm(home, { recursive: true, force: true });
281+
}
282+
});
283+
185284
test("pinned tools are advertised before any activation and survive clear", () => {
186285
const { activated, computeAdvertised } = createAdvertisedToolset(
187286
wiring({ pinnedTools: ["mcp__linear__save_issue"] }),

‎src/session/assemble-runtime.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,22 @@ export function createAdvertisedToolset(args: {
495495
};
496496
}
497497

498+
/**
499+
* Fold already broke the cache prefix. Drop idle execute-promoted schemas,
500+
* refresh the advertised wire, and persist so resume/crash cannot restore
501+
* the pruned names from run.json.
502+
*/
503+
export function commitIdlePromotionPrune(args: {
504+
pruneIdlePromotions: () => boolean;
505+
refreshAdvertised: () => void;
506+
persist: () => void;
507+
}): boolean {
508+
if (!args.pruneIdlePromotions()) return false;
509+
args.refreshAdvertised();
510+
args.persist();
511+
return true;
512+
}
513+
498514
// ---------------------------------------------------------------------------
499515
// 7. Chat agent: director def, agent def, live builder
500516
// ---------------------------------------------------------------------------

‎src/session/state.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@ const RunStateSchema = type({
4646
// MCP servers connected during the session, with the tool count each
4747
// contributed. Empty until the first server finishes connecting.
4848
"mcpServers?": ConnectedMcpServerSchema.array(),
49-
// Tool names promoted via tool_search this session. Persisted
50-
// so a resume can re-activate them before the first post-resume inference —
51-
// the transcript still tells the model they are callable.
49+
// Tool names promoted on execute this session. Persisted so a resume can
50+
// re-activate them before the first post-resume inference — the transcript
51+
// still tells the model they are callable. Fold prune omits this field so
52+
// a crash cannot restore dropped schemas.
5253
"activatedTools?": "string[]",
5354
// Wall time of the last Anthropic-protocol inference. A later process
5455
// folds before its first infer once this is at least the published TTL old.

‎src/tui/resume-seed.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,10 @@ describe("resolveResumeSeed", () => {
6464

6565
expect(seed.activatedTools).toEqual([]);
6666
});
67+
68+
test("an empty activatedTools list from a crash write does not re-activate pruned names", () => {
69+
expect(
70+
resolveResumeSeed(pickedState({ activatedTools: [] })).activatedTools,
71+
).toEqual([]);
72+
});
6773
});

‎src/tui/runner/session.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
assembleChatAgent,
4848
assembleSessionGate,
4949
assembleSessionLifecycle,
50+
commitIdlePromotionPrune,
5051
createAdvertisedToolset,
5152
loadSessionLocalSettings,
5253
resolveLiveSessionSources,
@@ -713,12 +714,19 @@ export async function assembleTUISession(
713714
// Main-session folds only — exec runner and subagents stay silent.
714715
onFolded: (info) => {
715716
// Fold restarts the cached prefix — drop idle execute-promoted
716-
// schemas rather than carrying them forever.
717-
if (pruneIdlePromotions()) {
718-
directorHolder.instance?.updateToolDefinitions(
719-
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
720-
);
721-
}
717+
// schemas rather than carrying them forever, and persist so a
718+
// crash resume cannot re-flush the pruned names.
719+
commitIdlePromotionPrune({
720+
pruneIdlePromotions,
721+
refreshAdvertised: () => {
722+
directorHolder.instance?.updateToolDefinitions(
723+
computeAdvertised(toolset.dynamicRunner.currentDefinitions()),
724+
);
725+
},
726+
persist: () => {
727+
void state.persistRunSnapshot?.("running");
728+
},
729+
});
722730
emitter.emit("compaction", info);
723731
},
724732
}),

0 commit comments

Comments
 (0)