Skip to content

Commit c07c978

Browse files
Keep activated tools advertised across resume and rebuild (#912)
* Keep activated tools advertised across resume and rebuild * Wire exec tool_search promotion into the call gate Exec installed the same advertised-set call gate as the TUI but left tool_search's promoter as a no-op, so MCP names the model was told to call were refused. * Include pinnedTools in the local settings load fixture
1 parent bc844cd commit c07c978

21 files changed

Lines changed: 593 additions & 50 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: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import type { SessionMode } from "../config/session-mode.js";
77
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
88

99
// Tools whose full schema is always advertised to the model. Everything else is
10-
// registered and dispatchable but discovered on demand via tool_search, keeping
11-
// the per-turn context small. Shared by the system prompt and the advertised-set
12-
// gate so the two never drift.
10+
// registered but discovered on demand via tool_search, which promotes matches
11+
// onto the advertised set and the call gate. Shared by the system prompt and
12+
// the advertised-set gate so the two never drift.
1313
//
1414
// `present` is deliberately absent: most sessions never render a view, and at
15-
// 2,793 chars it is the second-largest schema on the wire. It stays fully
16-
// dispatchable — the model finds it via tool_search when a session actually
17-
// needs it.
15+
// 2,793 chars it is the second-largest schema on the wire. It stays off the
16+
// advertised prefix — the model finds it via tool_search when a session
17+
// actually needs it.
1818
//
1919
// Product mutation tools (write_file / edit_file / delete_file) sit in CORE so
2020
// the primary Skywalker session can DIY tiny/bounded edits without a
@@ -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,16 +182,22 @@ 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

187197
export const toolSearchDefinition: ToolDefinition = {
188198
name: "tool_search",
189199
description:
190-
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are dispatchable but not advertised in the tools list. Core tools (read_file, run_shell, web_fetch, web_search, spawn_agent, wait_agents, …) are already on the wire — do not tool_search for them. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get matching tools' names, descriptions, and input schemas. The returned tools are already callable — invoke them directly, no separate load step.",
200+
"Discover callable tools by capability. Most tools — MCP servers, present, and other integrations — are not advertised until this search promotes them onto the wire. Core tools (read_file, run_shell, web_fetch, web_search, spawn_agent, wait_agents, …) are already on the wire — do not tool_search for them. Call this with a short description of what you need (e.g. 'issue tracker', 'render layout', 'granola notes') to get matching tools' names, descriptions, and input schemas. Matched tools are promoted and callable on return — invoke them directly, no separate load step.",
191201
inputSchema: {
192202
type: "object",
193203
properties: {
@@ -257,9 +267,9 @@ export function createToolIndex(
257267
export interface ToolSearchDeps {
258268
search: (query: string) => string[];
259269
lookup: (name: string) => ToolDefinition | undefined;
260-
// Make the matched tools' names part of the advertised wire set on the next
261-
// inference. Every registered tool is already dispatchable via `run`, so this
262-
// only affects what the model can see without an intervening tool_search.
270+
// Promote matches onto the advertised set and the call gate so the model can
271+
// invoke them this turn. The next inference also declares them on the wire
272+
// for strict providers.
263273
promote: (names: string[]) => void;
264274
}
265275

‎src/agent/tools.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,10 @@ export interface AgentToolsetArgs {
217217
// Real sessions always pass their detected values — see tool-search.ts for
218218
// why these must be fixed for the session's life.
219219
toolAvailability?: ToolAvailability;
220+
// Per-project pinned tool names (local settings). They join the advertised
221+
// prefix at the session layer; here they are excluded from tool_search so
222+
// discovery only surfaces names not already on the wire.
223+
pinnedTools?: readonly string[];
220224
// Records skill loads and sub-agent dispatch. Omitted (tests, ad-hoc
221225
// toolsets) means those events are never emitted.
222226
telemetry?: Telemetry;
@@ -361,10 +365,10 @@ export async function createAgentToolset(
361365
? createLazyBlobReader(getBlobReader)
362366
: undefined;
363367
const subAgentsEnabled = sessionModeEnablesSubAgents(sessionMode);
364-
const advertisedBuiltIns = advertisedToolNamesForSessionMode(
365-
sessionMode,
366-
toolAvailability,
367-
);
368+
const advertisedBuiltIns = [
369+
...advertisedToolNamesForSessionMode(sessionMode, toolAvailability),
370+
...(args.pinnedTools ?? []),
371+
];
368372
const skills =
369373
args.skills !== undefined
370374
? [...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: 88 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,
@@ -36,6 +44,7 @@ import type {
3644
ContextStore,
3745
InferenceSource,
3846
InboundMessage,
47+
ToolDefinition,
3948
} from "@intx/types/runtime";
4049
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
4150
import { loadAgentProfiles } from "../agent/profiles.js";
@@ -329,6 +338,33 @@ export interface ExecResult {
329338
model?: string;
330339
}
331340

341+
export function createExecToolCallGate(
342+
isAdvertised: (name: string) => boolean,
343+
options: { isCodex: boolean },
344+
): (name: string) => boolean {
345+
const unadvertisedCallable = new Set<string>([
346+
submitOutputDefinition.name,
347+
...(options.isCodex
348+
? [shellDefinition.name, updatePlanDefinition.name]
349+
: []),
350+
]);
351+
return (name) => unadvertisedCallable.has(name) || isAdvertised(name);
352+
}
353+
354+
export function createExecToolPromoter(args: {
355+
activate: (names: readonly string[]) => boolean;
356+
currentDefinitions: () => readonly ToolDefinition[];
357+
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
358+
updateDirectorTools: (defs: ToolDefinition[]) => void;
359+
persist?: () => void;
360+
}): (names: string[]) => void {
361+
return (names) => {
362+
if (!args.activate(names)) return;
363+
args.updateDirectorTools(args.computeAdvertised(args.currentDefinitions()));
364+
args.persist?.();
365+
};
366+
}
367+
332368
/**
333369
* Product non-TUI agent path (`corbits exec "prompt"`).
334370
*
@@ -383,6 +419,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
383419
let providerFailureObserved = false;
384420
let providerError: InferenceErrorLike | undefined;
385421
let result: ExecResult | undefined;
422+
// Assigned once the advertised toolset exists (below); persist reads it live
423+
// so a snapshot taken before that point still writes, just without the field.
424+
const activatedToolsRef: { current?: ActivatedToolTracker } = {};
386425
const activeRunHandle: RunStateHandle = {
387426
sessionId,
388427
cwd: config.cwd,
@@ -405,11 +444,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
405444
}
406445
const model = `${config.providerName}:${config.model}`;
407446
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
447+
const activatedTools = activatedToolsRef.current?.list() ?? [];
408448
syncRunStateHandle(activeRunHandle, {
409449
turnsUsed: nextTurnsUsed,
410450
task,
411451
startedAt,
412452
model,
453+
activatedTools,
413454
});
414455
const snapshot = {
415456
status,
@@ -418,6 +459,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
418459
startedAt,
419460
model,
420461
mcpServers: connectedMcp,
462+
...(activatedTools.length > 0 ? { activatedTools } : {}),
421463
...(status !== "running" ? { finishedAt: Date.now() } : {}),
422464
...(extra?.error !== undefined ? { error: extra.error } : {}),
423465
};
@@ -567,6 +609,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
567609
...(localSettingsForMode?.env !== undefined
568610
? { shellEnv: localSettingsForMode.env }
569611
: {}),
612+
...(localSettingsForMode?.pinnedTools !== undefined
613+
? { pinnedTools: localSettingsForMode.pinnedTools }
614+
: {}),
570615
getBlobWriter: () => currentStorage?.writeBlob,
571616
getEvidenceArchive: () => evidenceArchiveHolder.current,
572617
getContextDir: () => workdir,
@@ -695,13 +740,27 @@ export async function runExec(config: Config): Promise<ExecResult> {
695740
},
696741
});
697742

698-
const { activated: activatedToolNames, computeAdvertised } =
699-
createAdvertisedToolset({
700-
sessionMode,
701-
toolAvailability,
702-
getProvider: () => config,
703-
builtInPrefix: overlay.advertisedAllow,
704-
});
743+
const {
744+
activated: activatedToolNames,
745+
computeAdvertised,
746+
isAdvertised,
747+
} = createAdvertisedToolset({
748+
sessionMode,
749+
toolAvailability,
750+
getProvider: () => config,
751+
builtInPrefix: overlay.advertisedAllow,
752+
...(localSettingsForMode?.pinnedTools !== undefined
753+
? { pinnedTools: localSettingsForMode.pinnedTools }
754+
: {}),
755+
});
756+
activatedToolsRef.current = activatedToolNames;
757+
// Same wire contract as the TUI: a registered tool the model was never
758+
// shown errors toward tool_search instead of dispatching blind.
759+
agentToolset.dynamicRunner.setCallGate(
760+
createExecToolCallGate(isAdvertised, {
761+
isCodex: isCodexProviderName(config.providerName),
762+
}),
763+
);
705764

706765
const { directorHolder, buildAgent } = assembleChatAgent({
707766
toolsId: `${ID_PREFIX}/exec-tools`,
@@ -741,6 +800,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
741800
getCompactor: () =>
742801
createSessionPruningCompactor({
743802
summarize: summarizeForCompaction,
803+
summaryContext: () => {
804+
const tools = activatedToolNames.list();
805+
return tools.length > 0 ? { activatedTools: tools } : undefined;
806+
},
744807
telemetry: liveTelemetry,
745808
}),
746809
onBuilt: (agent, storage) => {
@@ -750,6 +813,23 @@ export async function runExec(config: Config): Promise<ExecResult> {
750813
evidenceArchiveHolder,
751814
});
752815

816+
// tool_search starts as a no-op promoter; without this, the call gate
817+
// refuses MCP/present/plugin names the result just told the model to
818+
// invoke.
819+
agentToolset.setToolPromoter(
820+
createExecToolPromoter({
821+
activate: (names) => activatedToolNames.activate(names),
822+
currentDefinitions: () =>
823+
agentToolset.dynamicRunner.currentDefinitions(),
824+
computeAdvertised,
825+
updateDirectorTools: (defs) =>
826+
directorHolder.instance?.updateToolDefinitions(defs),
827+
persist: () => {
828+
void persist("running");
829+
},
830+
}),
831+
);
832+
753833
const workflowHost = new WorkflowHost({
754834
cwd: config.cwd,
755835
getSessionId: () => sessionId,

‎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(

0 commit comments

Comments
 (0)