Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 16 additions & 8 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import {
assembleSessionGate,
assembleSessionLifecycle,
assembleSessionTrust,
commitIdlePromotionPrune,
createAdvertisedToolset,
loadSessionLocalSettings,
resolveLiveSessionSources,
Expand Down Expand Up @@ -944,14 +945,21 @@ export async function runExec(config: Config): Promise<ExecResult> {
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: () =>
Expand Down
5 changes: 3 additions & 2 deletions src/session/active-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
101 changes: 100 additions & 1 deletion src/session/assemble-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> | 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"] }),
Expand Down
16 changes: 16 additions & 0 deletions src/session/assemble-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
7 changes: 4 additions & 3 deletions src/session/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/tui/resume-seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
20 changes: 14 additions & 6 deletions src/tui/runner/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
assembleChatAgent,
assembleSessionGate,
assembleSessionLifecycle,
commitIdlePromotionPrune,
createAdvertisedToolset,
loadSessionLocalSettings,
resolveLiveSessionSources,
Expand Down Expand Up @@ -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);
},
}),
Expand Down
Loading