Skip to content

Commit b1e8bc0

Browse files
committed
Keep activated tools advertised across resume and rebuild
1 parent 36d5369 commit b1e8bc0

20 files changed

Lines changed: 392 additions & 40 deletions

‎docs/MCP.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,21 @@ global or local `exa` entry disables or overrides it as described above.
8888
Tools from connected servers are not advertised to the model up front; they are
8989
registered for dispatch as soon as the server connects (including later in the
9090
same turn) and surfaced on demand through dynamic tool discovery
91-
(`tool_search`).
91+
(`tool_search`). Names activated via `tool_search` persist in the session's
92+
`run.json` and are re-advertised on resume and after rebuilds.
93+
94+
For integrations a project calls constantly, `pinnedTools` in local
95+
`.corbits/settings.json` keeps those names on the wire permanently — no
96+
`tool_search` activation needed:
97+
98+
```jsonc
99+
{
100+
"pinnedTools": ["mcp__linear__save_issue", "mcp__linear__get_issue"],
101+
}
102+
```
103+
104+
Pinned names apply to any registered tool (MCP, plugin, or otherwise); a name
105+
with no matching tool is inert.
92106

93107
In the TUI, `/mcp` opens the live server surface. Press **Alt+A**
94108
to add a named absolute HTTP(S) endpoint to global settings and connect it in the

‎src/agent/tool-search.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,11 @@ export function advertisedTools(
162162
export interface ActivatedToolTracker {
163163
// Adds any new names and returns whether the set actually changed.
164164
activate(names: readonly string[]): boolean;
165+
has(name: string): boolean;
165166
list(): string[];
167+
// Session rotation (/clear, /new) mints a new transcript whose model never
168+
// saw the activations — the advertised set starts clean with it.
169+
clear(): void;
166170
}
167171

168172
export function createActivatedToolTracker(): ActivatedToolTracker {
@@ -178,9 +182,15 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
178182
}
179183
return changed;
180184
},
185+
has(name: string): boolean {
186+
return activeNames.has(name);
187+
},
181188
list(): string[] {
182189
return [...activeNames];
183190
},
191+
clear(): void {
192+
activeNames.clear();
193+
},
184194
};
185195
}
186196

‎src/agent/tools.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,10 @@ export interface AgentToolsetArgs {
218218
// Real sessions always pass their detected values — see tool-search.ts for
219219
// why these must be fixed for the session's life.
220220
toolAvailability?: ToolAvailability;
221+
// Per-project pinned tool names (local settings). They join the advertised
222+
// prefix at the session layer; here they are excluded from tool_search so
223+
// discovery only surfaces names not already on the wire.
224+
pinnedTools?: readonly string[];
221225
// Records skill loads and sub-agent dispatch. Omitted (tests, ad-hoc
222226
// toolsets) means those events are never emitted.
223227
telemetry?: Telemetry;
@@ -362,10 +366,10 @@ export async function createAgentToolset(
362366
? createLazyBlobReader(getBlobReader)
363367
: undefined;
364368
const subAgentsEnabled = sessionModeEnablesSubAgents(sessionMode);
365-
const advertisedBuiltIns = advertisedToolNamesForSessionMode(
366-
sessionMode,
367-
toolAvailability,
368-
);
369+
const advertisedBuiltIns = [
370+
...advertisedToolNamesForSessionMode(sessionMode, toolAvailability),
371+
...(args.pinnedTools ?? []),
372+
];
369373
const skills =
370374
args.skills !== undefined
371375
? [...args.skills]

‎src/config/settings.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,11 @@ export interface LocalSettings {
401401
// addition to the process's own inherited environment). Configuration
402402
// instead of a shell command that mutates the environment mid-session.
403403
env?: Record<string, string>;
404+
// Tool names always advertised on the wire for this project — e.g. hot MCP
405+
// integrations ("mcp__linear__save_issue") that should never need a
406+
// tool_search activation round-trip. Names that resolve to no registered
407+
// tool are inert.
408+
pinnedTools?: string[];
404409
}
405410

406411
// The provider fields the runtime consumes, identical to what the env vars used
@@ -603,6 +608,7 @@ const LocalSettingsSchema = type({
603608
"sessionMode?": "'single' | 'orchestrator'",
604609

605610
"env?": "Record<string, string>",
611+
"pinnedTools?": "string[]",
606612
// Reject any other key so local settings can never smuggle credentials.
607613
"+": "reject",
608614
});
@@ -793,6 +799,7 @@ export const LOCAL_SETTINGS_OPTIONAL_KEYS = [
793799
"mcpServers",
794800
"sessionMode",
795801
"env",
802+
"pinnedTools",
796803
] as const satisfies readonly (keyof OptionalLocalSettingsFields)[];
797804

798805
/**
@@ -1045,6 +1052,7 @@ function pickLocalFields(
10451052
sessionMode:
10461053
s.sessionMode === "orchestrator" ? "orchestrator" : undefined,
10471054
env: s.env as Record<string, string> | undefined,
1055+
pinnedTools: s.pinnedTools as string[] | undefined,
10481056
};
10491057
}
10501058
return {
@@ -1069,6 +1077,9 @@ function pickLocalFields(
10691077
),
10701078
)
10711079
: undefined,
1080+
pinnedTools: Array.isArray(s.pinnedTools)
1081+
? s.pinnedTools.filter((name): name is string => typeof name === "string")
1082+
: undefined,
10721083
};
10731084
}
10741085

@@ -1084,7 +1095,7 @@ function coerceLocalSettings(
10841095
{
10851096
path,
10861097
message: `Local settings in ${path} is not a JSON object.`,
1087-
fix: `Edit ${path} to a JSON object with only: provider, model, reasoningEffort, mcpServers, sessionMode, env.`,
1098+
fix: `Edit ${path} to a JSON object with only: provider, model, reasoningEffort, mcpServers, sessionMode, env, pinnedTools.`,
10881099
},
10891100
],
10901101
};
@@ -1141,7 +1152,7 @@ function coerceLocalSettings(
11411152
diagnostics.push({
11421153
path,
11431154
message: `Local settings in ${path} had invalid values and were partially ignored.`,
1144-
fix: `Edit ${path}: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", and "env" are allowed (no credentials).`,
1155+
fix: `Edit ${path}: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", "env", and "pinnedTools" are allowed (no credentials).`,
11451156
});
11461157
}
11471158
const settings = pickDefined(optional);
@@ -1336,7 +1347,7 @@ export async function saveLocalSettings(
13361347
): Promise<void> {
13371348
if (!isLocalSettings(local)) {
13381349
throw new Error(
1339-
`Refusing to write invalid local settings: only "provider", "model", "reasoningEffort", "mcpServers", and "sessionMode" are allowed.`,
1350+
`Refusing to write invalid local settings: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", "env", and "pinnedTools" are allowed.`,
13401351
);
13411352
}
13421353
const payload = JSON.stringify(local, null, 2);

‎src/exec/runner.ts‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,17 @@ import { xaiProfileFromProviderName } from "../config/xai-providers.js";
1717
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
1818
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
1919
import type { DirectorId } from "../agent/directors/types.js";
20+
import { submitOutputDefinition } from "../agent/director.js";
21+
import {
22+
shellDefinition,
23+
updatePlanDefinition,
24+
} from "../agent/codex-tool-proxies.js";
2025
import { getValidCodexToken } from "../auth/codex/session.js";
2126
import { getValidXaiToken } from "../auth/xai/session.js";
22-
import { type ToolAvailability } from "../agent/tool-search.js";
27+
import {
28+
type ActivatedToolTracker,
29+
type ToolAvailability,
30+
} from "../agent/tool-search.js";
2331
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
2432
import {
2533
resolveSessionMode,
@@ -382,6 +390,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
382390
let providerFailureObserved = false;
383391
let providerError: InferenceErrorLike | undefined;
384392
let result: ExecResult | undefined;
393+
// Assigned once the advertised toolset exists (below); persist reads it live
394+
// so a snapshot taken before that point still writes, just without the field.
395+
const activatedToolsRef: { current?: ActivatedToolTracker } = {};
385396
const activeRunHandle: RunStateHandle = {
386397
sessionId,
387398
cwd: config.cwd,
@@ -404,11 +415,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
404415
}
405416
const model = `${config.providerName}:${config.model}`;
406417
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
418+
const activatedTools = activatedToolsRef.current?.list() ?? [];
407419
syncRunStateHandle(activeRunHandle, {
408420
turnsUsed: nextTurnsUsed,
409421
task,
410422
startedAt,
411423
model,
424+
activatedTools,
412425
});
413426
const snapshot = {
414427
status,
@@ -417,6 +430,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
417430
startedAt,
418431
model,
419432
mcpServers: connectedMcp,
433+
...(activatedTools.length > 0 ? { activatedTools } : {}),
420434
...(status !== "running" ? { finishedAt: Date.now() } : {}),
421435
...(extra?.error !== undefined ? { error: extra.error } : {}),
422436
};
@@ -566,6 +580,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
566580
...(localSettingsForMode?.env !== undefined
567581
? { shellEnv: localSettingsForMode.env }
568582
: {}),
583+
...(localSettingsForMode?.pinnedTools !== undefined
584+
? { pinnedTools: localSettingsForMode.pinnedTools }
585+
: {}),
569586
getBlobWriter: () => currentStorage?.writeBlob,
570587
getEvidenceArchive: () => evidenceArchiveHolder.current,
571588
getContextDir: () => workdir,
@@ -680,13 +697,31 @@ export async function runExec(config: Config): Promise<ExecResult> {
680697
getArchive: () => evidenceArchiveHolder.current,
681698
});
682699

683-
const { activated: activatedToolNames, computeAdvertised } =
684-
createAdvertisedToolset({
685-
sessionMode,
686-
toolAvailability,
687-
getProvider: () => config,
688-
builtInPrefix: overlay.advertisedAllow,
689-
});
700+
const {
701+
activated: activatedToolNames,
702+
computeAdvertised,
703+
isAdvertised,
704+
} = createAdvertisedToolset({
705+
sessionMode,
706+
toolAvailability,
707+
getProvider: () => config,
708+
builtInPrefix: overlay.advertisedAllow,
709+
...(localSettingsForMode?.pinnedTools !== undefined
710+
? { pinnedTools: localSettingsForMode.pinnedTools }
711+
: {}),
712+
});
713+
activatedToolsRef.current = activatedToolNames;
714+
// Same wire contract as the TUI: a registered tool the model was never
715+
// shown errors toward tool_search instead of dispatching blind.
716+
const unadvertisedCallable = new Set<string>([
717+
submitOutputDefinition.name,
718+
...(isCodexProviderName(config.providerName)
719+
? [shellDefinition.name, updatePlanDefinition.name]
720+
: []),
721+
]);
722+
agentToolset.dynamicRunner.setCallGate(
723+
(name) => unadvertisedCallable.has(name) || isAdvertised(name),
724+
);
690725

691726
const { directorHolder, buildAgent } = assembleChatAgent({
692727
toolsId: `${ID_PREFIX}/exec-tools`,
@@ -726,6 +761,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
726761
getCompactor: () =>
727762
createSessionPruningCompactor({
728763
summarize: summarizeForCompaction,
764+
summaryContext: () => {
765+
const tools = activatedToolNames.list();
766+
return tools.length > 0 ? { activatedTools: tools } : undefined;
767+
},
729768
telemetry: liveTelemetry,
730769
}),
731770
onBuilt: (agent, storage) => {

‎src/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,9 @@ async function finalizeActiveRunOnCrash(error: unknown): Promise<void> {
228228
finishedAt: Date.now(),
229229
error: message,
230230
...(run.model !== undefined ? { model: run.model } : {}),
231+
...(run.activatedTools !== undefined
232+
? { activatedTools: run.activatedTools }
233+
: {}),
231234
});
232235
} catch (saveErr: unknown) {
233236
process.stderr.write(
@@ -273,6 +276,9 @@ async function finalizeActiveRunOnSignal(
273276
finishedAt: Date.now(),
274277
error: `terminated by ${signal}`,
275278
...(run.model !== undefined ? { model: run.model } : {}),
279+
...(run.activatedTools !== undefined
280+
? { activatedTools: run.activatedTools }
281+
: {}),
276282
});
277283
} catch (saveErr: unknown) {
278284
process.stderr.write(

‎src/session/active-run.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +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
26+
// crash/signal terminal write can carry them into run.json for the resume
27+
// seed.
28+
activatedTools?: string[];
2529
}
2630

2731
// Keep the crash/signal handle in step with every persisted snapshot so a
@@ -34,6 +38,7 @@ export function syncRunStateHandle(
3438
task: string;
3539
startedAt: number;
3640
model?: string;
41+
activatedTools?: string[];
3742
},
3843
): void {
3944
handle.turnsUsed = snapshot.turnsUsed;
@@ -42,6 +47,9 @@ export function syncRunStateHandle(
4247
if (snapshot.model !== undefined) {
4348
handle.model = snapshot.model;
4449
}
50+
if (snapshot.activatedTools !== undefined) {
51+
handle.activatedTools = snapshot.activatedTools;
52+
}
4553
}
4654

4755
let activeRun: RunStateHandle | null = null;

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,34 @@ describe("createAdvertisedToolset", () => {
7373
const { computeAdvertised } = createAdvertisedToolset(wiring());
7474
expect(computeAdvertised([])).toEqual([]);
7575
});
76+
77+
test("isAdvertised tracks prefix, pinned, and activated names", () => {
78+
const { activated, isAdvertised } = createAdvertisedToolset(
79+
wiring({ pinnedTools: ["mcp__linear__save_issue"] }),
80+
);
81+
expect(isAdvertised("read_file")).toBe(true);
82+
expect(isAdvertised("mcp__linear__save_issue")).toBe(true);
83+
expect(isAdvertised("mcp__acme__do")).toBe(false);
84+
activated.activate(["mcp__acme__do"]);
85+
expect(isAdvertised("mcp__acme__do")).toBe(true);
86+
});
87+
88+
test("pinned tools are advertised before any activation and survive clear", () => {
89+
const { activated, computeAdvertised } = createAdvertisedToolset(
90+
wiring({ pinnedTools: ["mcp__linear__save_issue"] }),
91+
);
92+
const defs = [def("read_file"), def("mcp__linear__save_issue")];
93+
expect(computeAdvertised(defs).map((d) => d.name)).toContain(
94+
"mcp__linear__save_issue",
95+
);
96+
// A pinned name is part of the prefix, not the activation set — session
97+
// rotation clearing activations does not drop it off the wire.
98+
activated.activate(["mcp__acme__do"]);
99+
activated.clear();
100+
const names = computeAdvertised(defs).map((d) => d.name);
101+
expect(names).toContain("mcp__linear__save_issue");
102+
expect(names).not.toContain("mcp__acme__do");
103+
});
76104
});
77105

78106
describe("loadSessionLocalSettings", () => {

‎src/session/assemble-runtime.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,22 +325,36 @@ export function resolveLiveSessionSources(
325325
export interface AdvertisedToolset {
326326
activated: ActivatedToolTracker;
327327
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
328+
// Whether a name is part of the advertised wire set: built-in prefix,
329+
// project-pinned, or tool_search-activated. The dispatch gate keys off this
330+
// so a registered-but-unadvertised call surfaces as a tool_search error
331+
// instead of silently dispatching.
332+
isAdvertised: (name: string) => boolean;
328333
}
329334

330335
/**
331336
* Fixed built-in prefix plus session-activated tools, family-gated for the
332337
* wire. The provider identity is read per call so a live model switch
333338
* re-gates without rebuilding the agent.
339+
*
340+
* `pinnedTools` (local settings) merge into the prefix — advertised from the
341+
* first turn and exempt from activation state, so a resume needs no
342+
* tool_search round-trip for the project's hottest integrations.
334343
*/
335344
export function createAdvertisedToolset(args: {
336345
sessionMode: SessionMode;
337346
toolAvailability: ToolAvailability;
338347
getProvider: () => { providerName: string; model: string };
339348
builtInPrefix?: readonly string[] | undefined;
349+
pinnedTools?: readonly string[] | undefined;
340350
}): AdvertisedToolset {
341-
const prefix =
351+
const builtIn =
342352
args.builtInPrefix ??
343353
advertisedToolNamesForSessionMode(args.sessionMode, args.toolAvailability);
354+
const prefix = [
355+
...builtIn,
356+
...(args.pinnedTools ?? []).filter((name) => !builtIn.includes(name)),
357+
];
344358
const activated = createActivatedToolTracker();
345359
// Advertise then family-gate wire schemas (kimi gets a non-recursive present).
346360
const computeAdvertised = (
@@ -352,7 +366,9 @@ export function createAdvertisedToolset(args: {
352366
...args.getProvider(),
353367
},
354368
);
355-
return { activated, computeAdvertised };
369+
const isAdvertised = (name: string): boolean =>
370+
prefix.includes(name) || activated.has(name);
371+
return { activated, computeAdvertised, isAdvertised };
356372
}
357373

358374
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)