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
16 changes: 15 additions & 1 deletion docs/MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,21 @@ global or local `exa` entry disables or overrides it as described above.
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`).
(`tool_search`). Names activated via `tool_search` persist in the session's
`run.json` and are re-advertised on resume and after rebuilds.

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

```jsonc
{
"pinnedTools": ["mcp__linear__save_issue", "mcp__linear__get_issue"],
}
```

Pinned names apply to any registered tool (MCP, plugin, or otherwise); a name
with no matching tool is inert.

In the TUI, `/mcp` opens the live server surface. Press **Alt+A**
to add a named absolute HTTP(S) endpoint to global settings and connect it in the
Expand Down
30 changes: 20 additions & 10 deletions src/agent/tool-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import type { SessionMode } from "../config/session-mode.js";
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";

// Tools whose full schema is always advertised to the model. Everything else is
// registered and dispatchable but discovered on demand via tool_search, keeping
// the per-turn context small. Shared by the system prompt and the advertised-set
// gate so the two never drift.
// registered but discovered on demand via tool_search, which promotes matches
// onto the advertised set and the call gate. Shared by the system prompt and
// the advertised-set gate so the two never drift.
//
// `present` is deliberately absent: most sessions never render a view, and at
// 2,793 chars it is the second-largest schema on the wire. It stays fully
// dispatchable — the model finds it via tool_search when a session actually
// needs it.
// 2,793 chars it is the second-largest schema on the wire. It stays off the
// advertised prefix — the model finds it via tool_search when a session
// actually needs it.
//
// Product mutation tools (write_file / edit_file / delete_file) sit in CORE so
// the primary Skywalker session can DIY tiny/bounded edits without a
Expand Down Expand Up @@ -162,7 +162,11 @@ export function advertisedTools(
export interface ActivatedToolTracker {
// Adds any new names and returns whether the set actually changed.
activate(names: readonly string[]): boolean;
has(name: string): boolean;
list(): string[];
// Session rotation (/clear, /new) mints a new transcript whose model never
// saw the activations — the advertised set starts clean with it.
clear(): void;
}

export function createActivatedToolTracker(): ActivatedToolTracker {
Expand All @@ -178,16 +182,22 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
}
return changed;
},
has(name: string): boolean {
return activeNames.has(name);
},
list(): string[] {
return [...activeNames];
},
clear(): void {
activeNames.clear();
},
};
}

export const toolSearchDefinition: ToolDefinition = {
name: "tool_search",
description:
"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.",
"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.",
inputSchema: {
type: "object",
properties: {
Expand Down Expand Up @@ -257,9 +267,9 @@ export function createToolIndex(
export interface ToolSearchDeps {
search: (query: string) => string[];
lookup: (name: string) => ToolDefinition | undefined;
// Make the matched tools' names part of the advertised wire set on the next
// inference. Every registered tool is already dispatchable via `run`, so this
// only affects what the model can see without an intervening tool_search.
// Promote matches onto the advertised set and the call gate so the model can
// invoke them this turn. The next inference also declares them on the wire
// for strict providers.
promote: (names: string[]) => void;
}

Expand Down
12 changes: 8 additions & 4 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ export interface AgentToolsetArgs {
// Real sessions always pass their detected values — see tool-search.ts for
// why these must be fixed for the session's life.
toolAvailability?: ToolAvailability;
// Per-project pinned tool names (local settings). They join the advertised
// prefix at the session layer; here they are excluded from tool_search so
// discovery only surfaces names not already on the wire.
pinnedTools?: readonly string[];
// Records skill loads and sub-agent dispatch. Omitted (tests, ad-hoc
// toolsets) means those events are never emitted.
telemetry?: Telemetry;
Expand Down Expand Up @@ -362,10 +366,10 @@ export async function createAgentToolset(
? createLazyBlobReader(getBlobReader)
: undefined;
const subAgentsEnabled = sessionModeEnablesSubAgents(sessionMode);
const advertisedBuiltIns = advertisedToolNamesForSessionMode(
sessionMode,
toolAvailability,
);
const advertisedBuiltIns = [
...advertisedToolNamesForSessionMode(sessionMode, toolAvailability),
...(args.pinnedTools ?? []),
];
const skills =
args.skills !== undefined
? [...args.skills]
Expand Down
17 changes: 14 additions & 3 deletions src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,11 @@ export interface LocalSettings {
// addition to the process's own inherited environment). Configuration
// instead of a shell command that mutates the environment mid-session.
env?: Record<string, string>;
// Tool names always advertised on the wire for this project — e.g. hot MCP
// integrations ("mcp__linear__save_issue") that should never need a
// tool_search activation round-trip. Names that resolve to no registered
// tool are inert.
pinnedTools?: string[];
}

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

"env?": "Record<string, string>",
"pinnedTools?": "string[]",
// Reject any other key so local settings can never smuggle credentials.
"+": "reject",
});
Expand Down Expand Up @@ -793,6 +799,7 @@ export const LOCAL_SETTINGS_OPTIONAL_KEYS = [
"mcpServers",
"sessionMode",
"env",
"pinnedTools",
] as const satisfies readonly (keyof OptionalLocalSettingsFields)[];

/**
Expand Down Expand Up @@ -1045,6 +1052,7 @@ function pickLocalFields(
sessionMode:
s.sessionMode === "orchestrator" ? "orchestrator" : undefined,
env: s.env as Record<string, string> | undefined,
pinnedTools: s.pinnedTools as string[] | undefined,
};
}
return {
Expand All @@ -1069,6 +1077,9 @@ function pickLocalFields(
),
)
: undefined,
pinnedTools: Array.isArray(s.pinnedTools)
? s.pinnedTools.filter((name): name is string => typeof name === "string")
: undefined,
};
}

Expand All @@ -1084,7 +1095,7 @@ function coerceLocalSettings(
{
path,
message: `Local settings in ${path} is not a JSON object.`,
fix: `Edit ${path} to a JSON object with only: provider, model, reasoningEffort, mcpServers, sessionMode, env.`,
fix: `Edit ${path} to a JSON object with only: provider, model, reasoningEffort, mcpServers, sessionMode, env, pinnedTools.`,
},
],
};
Expand Down Expand Up @@ -1141,7 +1152,7 @@ function coerceLocalSettings(
diagnostics.push({
path,
message: `Local settings in ${path} had invalid values and were partially ignored.`,
fix: `Edit ${path}: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", and "env" are allowed (no credentials).`,
fix: `Edit ${path}: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", "env", and "pinnedTools" are allowed (no credentials).`,
});
}
const settings = pickDefined(optional);
Expand Down Expand Up @@ -1336,7 +1347,7 @@ export async function saveLocalSettings(
): Promise<void> {
if (!isLocalSettings(local)) {
throw new Error(
`Refusing to write invalid local settings: only "provider", "model", "reasoningEffort", "mcpServers", and "sessionMode" are allowed.`,
`Refusing to write invalid local settings: only "provider", "model", "reasoningEffort", "mcpServers", "sessionMode", "env", and "pinnedTools" are allowed.`,
);
}
const payload = JSON.stringify(local, null, 2);
Expand Down
96 changes: 88 additions & 8 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ import { xaiProfileFromProviderName } from "../config/xai-providers.js";
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
import type { DirectorId } from "../agent/directors/types.js";
import { submitOutputDefinition } from "../agent/director.js";
import {
shellDefinition,
updatePlanDefinition,
} from "../agent/codex-tool-proxies.js";
import { getValidCodexToken } from "../auth/codex/session.js";
import { getValidXaiToken } from "../auth/xai/session.js";
import { type ToolAvailability } from "../agent/tool-search.js";
import {
type ActivatedToolTracker,
type ToolAvailability,
} from "../agent/tool-search.js";
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
import {
resolveSessionMode,
Expand All @@ -36,6 +44,7 @@ import type {
ContextStore,
InferenceSource,
InboundMessage,
ToolDefinition,
} from "@intx/types/runtime";
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
import { loadAgentProfiles } from "../agent/profiles.js";
Expand Down Expand Up @@ -328,6 +337,33 @@ export interface ExecResult {
model?: string;
}

export function createExecToolCallGate(
isAdvertised: (name: string) => boolean,
options: { isCodex: boolean },
): (name: string) => boolean {
const unadvertisedCallable = new Set<string>([
submitOutputDefinition.name,
...(options.isCodex
? [shellDefinition.name, updatePlanDefinition.name]
: []),
]);
return (name) => unadvertisedCallable.has(name) || isAdvertised(name);
}

export function createExecToolPromoter(args: {
activate: (names: readonly string[]) => boolean;
currentDefinitions: () => readonly ToolDefinition[];
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
updateDirectorTools: (defs: ToolDefinition[]) => void;
persist?: () => void;
}): (names: string[]) => void {
return (names) => {
if (!args.activate(names)) return;
args.updateDirectorTools(args.computeAdvertised(args.currentDefinitions()));
args.persist?.();
};
}

/**
* Product non-TUI agent path (`corbits exec "prompt"`).
*
Expand Down Expand Up @@ -382,6 +418,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
let providerFailureObserved = false;
let providerError: InferenceErrorLike | undefined;
let result: ExecResult | undefined;
// Assigned once the advertised toolset exists (below); persist reads it live
// so a snapshot taken before that point still writes, just without the field.
const activatedToolsRef: { current?: ActivatedToolTracker } = {};
const activeRunHandle: RunStateHandle = {
sessionId,
cwd: config.cwd,
Expand All @@ -404,11 +443,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
}
const model = `${config.providerName}:${config.model}`;
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
const activatedTools = activatedToolsRef.current?.list() ?? [];
syncRunStateHandle(activeRunHandle, {
turnsUsed: nextTurnsUsed,
task,
startedAt,
model,
activatedTools,
});
const snapshot = {
status,
Expand All @@ -417,6 +458,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
startedAt,
model,
mcpServers: connectedMcp,
...(activatedTools.length > 0 ? { activatedTools } : {}),
...(status !== "running" ? { finishedAt: Date.now() } : {}),
...(extra?.error !== undefined ? { error: extra.error } : {}),
};
Expand Down Expand Up @@ -566,6 +608,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
...(localSettingsForMode?.env !== undefined
? { shellEnv: localSettingsForMode.env }
: {}),
...(localSettingsForMode?.pinnedTools !== undefined
? { pinnedTools: localSettingsForMode.pinnedTools }
: {}),
getBlobWriter: () => currentStorage?.writeBlob,
getEvidenceArchive: () => evidenceArchiveHolder.current,
getContextDir: () => workdir,
Expand Down Expand Up @@ -680,13 +725,27 @@ export async function runExec(config: Config): Promise<ExecResult> {
getArchive: () => evidenceArchiveHolder.current,
});

const { activated: activatedToolNames, computeAdvertised } =
createAdvertisedToolset({
sessionMode,
toolAvailability,
getProvider: () => config,
builtInPrefix: overlay.advertisedAllow,
});
const {
activated: activatedToolNames,
computeAdvertised,
isAdvertised,
} = createAdvertisedToolset({
sessionMode,
toolAvailability,
getProvider: () => config,
builtInPrefix: overlay.advertisedAllow,
...(localSettingsForMode?.pinnedTools !== undefined
? { pinnedTools: localSettingsForMode.pinnedTools }
: {}),
});
activatedToolsRef.current = activatedToolNames;
// Same wire contract as the TUI: a registered tool the model was never
// shown errors toward tool_search instead of dispatching blind.
agentToolset.dynamicRunner.setCallGate(
createExecToolCallGate(isAdvertised, {
isCodex: isCodexProviderName(config.providerName),
}),
);

const { directorHolder, buildAgent } = assembleChatAgent({
toolsId: `${ID_PREFIX}/exec-tools`,
Expand Down Expand Up @@ -726,6 +785,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
getCompactor: () =>
createSessionPruningCompactor({
summarize: summarizeForCompaction,
summaryContext: () => {
const tools = activatedToolNames.list();
return tools.length > 0 ? { activatedTools: tools } : undefined;
},
telemetry: liveTelemetry,
}),
onBuilt: (agent, storage) => {
Expand All @@ -735,6 +798,23 @@ export async function runExec(config: Config): Promise<ExecResult> {
evidenceArchiveHolder,
});

// tool_search starts as a no-op promoter; without this, the call gate
// refuses MCP/present/plugin names the result just told the model to
// invoke.
agentToolset.setToolPromoter(
createExecToolPromoter({
activate: (names) => activatedToolNames.activate(names),
currentDefinitions: () =>
agentToolset.dynamicRunner.currentDefinitions(),
computeAdvertised,
updateDirectorTools: (defs) =>
directorHolder.instance?.updateToolDefinitions(defs),
persist: () => {
void persist("running");
},
}),
);

const workflowHost = new WorkflowHost({
cwd: config.cwd,
getSessionId: () => sessionId,
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ async function finalizeActiveRunOnCrash(error: unknown): Promise<void> {
finishedAt: Date.now(),
error: message,
...(run.model !== undefined ? { model: run.model } : {}),
...(run.activatedTools !== undefined
? { activatedTools: run.activatedTools }
: {}),
});
} catch (saveErr: unknown) {
process.stderr.write(
Expand Down Expand Up @@ -273,6 +276,9 @@ async function finalizeActiveRunOnSignal(
finishedAt: Date.now(),
error: `terminated by ${signal}`,
...(run.model !== undefined ? { model: run.model } : {}),
...(run.activatedTools !== undefined
? { activatedTools: run.activatedTools }
: {}),
});
} catch (saveErr: unknown) {
process.stderr.write(
Expand Down
Loading
Loading