diff --git a/docs/MCP.md b/docs/MCP.md index 6256a43fd..d65602ebe 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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 diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index 13ca2f042..964d52697 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -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 @@ -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 { @@ -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: { @@ -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; } diff --git a/src/agent/tools.ts b/src/agent/tools.ts index f8fbb51c6..9777b4c83 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -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; @@ -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] diff --git a/src/config/settings.ts b/src/config/settings.ts index 435c12540..2ada83676 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -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; + // 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 @@ -603,6 +608,7 @@ const LocalSettingsSchema = type({ "sessionMode?": "'single' | 'orchestrator'", "env?": "Record", + "pinnedTools?": "string[]", // Reject any other key so local settings can never smuggle credentials. "+": "reject", }); @@ -793,6 +799,7 @@ export const LOCAL_SETTINGS_OPTIONAL_KEYS = [ "mcpServers", "sessionMode", "env", + "pinnedTools", ] as const satisfies readonly (keyof OptionalLocalSettingsFields)[]; /** @@ -1045,6 +1052,7 @@ function pickLocalFields( sessionMode: s.sessionMode === "orchestrator" ? "orchestrator" : undefined, env: s.env as Record | undefined, + pinnedTools: s.pinnedTools as string[] | undefined, }; } return { @@ -1069,6 +1077,9 @@ function pickLocalFields( ), ) : undefined, + pinnedTools: Array.isArray(s.pinnedTools) + ? s.pinnedTools.filter((name): name is string => typeof name === "string") + : undefined, }; } @@ -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.`, }, ], }; @@ -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); @@ -1336,7 +1347,7 @@ export async function saveLocalSettings( ): Promise { 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); diff --git a/src/exec/runner.ts b/src/exec/runner.ts index 13d71a570..49874f792 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -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, @@ -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"; @@ -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([ + 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"`). * @@ -382,6 +418,9 @@ export async function runExec(config: Config): Promise { 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, @@ -404,11 +443,13 @@ export async function runExec(config: Config): Promise { } 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, @@ -417,6 +458,7 @@ export async function runExec(config: Config): Promise { startedAt, model, mcpServers: connectedMcp, + ...(activatedTools.length > 0 ? { activatedTools } : {}), ...(status !== "running" ? { finishedAt: Date.now() } : {}), ...(extra?.error !== undefined ? { error: extra.error } : {}), }; @@ -566,6 +608,9 @@ export async function runExec(config: Config): Promise { ...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}), + ...(localSettingsForMode?.pinnedTools !== undefined + ? { pinnedTools: localSettingsForMode.pinnedTools } + : {}), getBlobWriter: () => currentStorage?.writeBlob, getEvidenceArchive: () => evidenceArchiveHolder.current, getContextDir: () => workdir, @@ -680,13 +725,27 @@ export async function runExec(config: Config): Promise { 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`, @@ -726,6 +785,10 @@ export async function runExec(config: Config): Promise { getCompactor: () => createSessionPruningCompactor({ summarize: summarizeForCompaction, + summaryContext: () => { + const tools = activatedToolNames.list(); + return tools.length > 0 ? { activatedTools: tools } : undefined; + }, telemetry: liveTelemetry, }), onBuilt: (agent, storage) => { @@ -735,6 +798,23 @@ export async function runExec(config: Config): Promise { 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, diff --git a/src/index.ts b/src/index.ts index 03d2d31b2..2b745d464 100644 --- a/src/index.ts +++ b/src/index.ts @@ -228,6 +228,9 @@ async function finalizeActiveRunOnCrash(error: unknown): Promise { finishedAt: Date.now(), error: message, ...(run.model !== undefined ? { model: run.model } : {}), + ...(run.activatedTools !== undefined + ? { activatedTools: run.activatedTools } + : {}), }); } catch (saveErr: unknown) { process.stderr.write( @@ -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( diff --git a/src/session/active-run.ts b/src/session/active-run.ts index 2bd3fb2be..dd8495fde 100644 --- a/src/session/active-run.ts +++ b/src/session/active-run.ts @@ -22,6 +22,10 @@ export interface RunStateHandle { startedAt: number; turnsUsed: number; model?: string; + // Latest tool_search-activated tool names, synced on every snapshot so the + // crash/signal terminal write can carry them into run.json for the resume + // seed. + activatedTools?: string[]; } // Keep the crash/signal handle in step with every persisted snapshot so a @@ -34,6 +38,7 @@ export function syncRunStateHandle( task: string; startedAt: number; model?: string; + activatedTools?: string[]; }, ): void { handle.turnsUsed = snapshot.turnsUsed; @@ -42,6 +47,9 @@ export function syncRunStateHandle( if (snapshot.model !== undefined) { handle.model = snapshot.model; } + if (snapshot.activatedTools !== undefined) { + handle.activatedTools = snapshot.activatedTools; + } } let activeRun: RunStateHandle | null = null; diff --git a/src/session/assemble-runtime.test.ts b/src/session/assemble-runtime.test.ts index 4c502c27b..93d28cff0 100644 --- a/src/session/assemble-runtime.test.ts +++ b/src/session/assemble-runtime.test.ts @@ -73,6 +73,34 @@ describe("createAdvertisedToolset", () => { const { computeAdvertised } = createAdvertisedToolset(wiring()); expect(computeAdvertised([])).toEqual([]); }); + + test("isAdvertised tracks prefix, pinned, and activated names", () => { + const { activated, isAdvertised } = createAdvertisedToolset( + wiring({ pinnedTools: ["mcp__linear__save_issue"] }), + ); + expect(isAdvertised("read_file")).toBe(true); + expect(isAdvertised("mcp__linear__save_issue")).toBe(true); + expect(isAdvertised("mcp__acme__do")).toBe(false); + activated.activate(["mcp__acme__do"]); + expect(isAdvertised("mcp__acme__do")).toBe(true); + }); + + test("pinned tools are advertised before any activation and survive clear", () => { + const { activated, computeAdvertised } = createAdvertisedToolset( + wiring({ pinnedTools: ["mcp__linear__save_issue"] }), + ); + const defs = [def("read_file"), def("mcp__linear__save_issue")]; + expect(computeAdvertised(defs).map((d) => d.name)).toContain( + "mcp__linear__save_issue", + ); + // A pinned name is part of the prefix, not the activation set — session + // rotation clearing activations does not drop it off the wire. + activated.activate(["mcp__acme__do"]); + activated.clear(); + const names = computeAdvertised(defs).map((d) => d.name); + expect(names).toContain("mcp__linear__save_issue"); + expect(names).not.toContain("mcp__acme__do"); + }); }); describe("loadSessionLocalSettings", () => { diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 8ca57bd3e..650e01c43 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -325,22 +325,36 @@ export function resolveLiveSessionSources( export interface AdvertisedToolset { activated: ActivatedToolTracker; computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[]; + // Whether a name is part of the advertised wire set: built-in prefix, + // project-pinned, or tool_search-activated. The dispatch gate keys off this + // so a registered-but-unadvertised call surfaces as a tool_search error + // instead of silently dispatching. + isAdvertised: (name: string) => boolean; } /** * Fixed built-in prefix plus session-activated tools, family-gated for the * wire. The provider identity is read per call so a live model switch * re-gates without rebuilding the agent. + * + * `pinnedTools` (local settings) merge into the prefix — advertised from the + * first turn and exempt from activation state, so a resume needs no + * tool_search round-trip for the project's hottest integrations. */ export function createAdvertisedToolset(args: { sessionMode: SessionMode; toolAvailability: ToolAvailability; getProvider: () => { providerName: string; model: string }; builtInPrefix?: readonly string[] | undefined; + pinnedTools?: readonly string[] | undefined; }): AdvertisedToolset { - const prefix = + const builtIn = args.builtInPrefix ?? advertisedToolNamesForSessionMode(args.sessionMode, args.toolAvailability); + const prefix = [ + ...builtIn, + ...(args.pinnedTools ?? []).filter((name) => !builtIn.includes(name)), + ]; const activated = createActivatedToolTracker(); // Advertise then family-gate wire schemas (kimi gets a non-recursive present). const computeAdvertised = ( @@ -352,7 +366,9 @@ export function createAdvertisedToolset(args: { ...args.getProvider(), }, ); - return { activated, computeAdvertised }; + const isAdvertised = (name: string): boolean => + prefix.includes(name) || activated.has(name); + return { activated, computeAdvertised, isAdvertised }; } // --------------------------------------------------------------------------- diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 83bc6724b..cff09635c 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -1030,11 +1030,12 @@ export function createPruningCompactor( ); const supersededReads = supersededReadCallIds(pathToReads); + const summaryCtx = cfg.summaryContext?.(); let summary: string; try { summary = cfg.summarize !== undefined - ? await cfg.summarize(summarizedTurns, cfg.summaryContext?.()) + ? await cfg.summarize(summarizedTurns, summaryCtx) : buildTurnSummary( summarizedTurns, cfg.summaryMaxChars, @@ -1076,9 +1077,20 @@ export function createPruningCompactor( // whenever a system-prompt override is set, and the Grok builder emits // them as a stray mid-stream system message. Framing the summary as user // content keeps it in the conversation on every provider. + // The activated set outlives the fold (it rides the wire, not the + // dropped turns), so the handoff states it verbatim — the summary would + // otherwise leave the model guessing whether the names it saw activated + // earlier are still callable. + const activatedTools = summaryCtx?.activatedTools ?? []; + const toolsLine = + activatedTools.length > 0 + ? `\n\nTools still activated and callable directly (no tool_search needed): ${activatedTools.join(", ")}` + : ""; const summaryTurn: ConversationTurn = { role: "user", - content: [{ type: "text", text: `${COMPACTED_PREFIX}\n${summary}` }], + content: [ + { type: "text", text: `${COMPACTED_PREFIX}\n${summary}${toolsLine}` }, + ], timestamp: olderTurns[olderTurns.length - 1]?.timestamp ?? Date.now(), }; diff --git a/src/session/state.ts b/src/session/state.ts index 1ad2510aa..4f8629a51 100644 --- a/src/session/state.ts +++ b/src/session/state.ts @@ -46,6 +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 onto the wire 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. + "activatedTools?": "string[]", }); export type RunState = typeof RunStateSchema.infer; diff --git a/src/session/summarizer.ts b/src/session/summarizer.ts index 4526f37ba..a9a38518f 100644 --- a/src/session/summarizer.ts +++ b/src/session/summarizer.ts @@ -30,6 +30,10 @@ export interface SummaryContext { stepIndex?: number; total?: number; }; + // Tool names activated via tool_search and still on the wire. Pinned names + // live in the advertised prefix, not this list — callers pass + // activatedToolNames.list() only. + activatedTools?: string[]; } const SYSTEM_INSTRUCTION = [ @@ -122,7 +126,7 @@ export function condenseTurns(turns: ConversationTurn[]): string { return sections.filter((s): s is string => s !== null).join("\n\n"); } -function workflowPreamble(ctx: SummaryContext | undefined): string { +function contextPreamble(ctx: SummaryContext | undefined): string { const parts: string[] = []; const wf = ctx?.workflow; if (wf !== undefined && wf.name !== undefined) { @@ -136,6 +140,12 @@ function workflowPreamble(ctx: SummaryContext | undefined): string { `Active workflow: /${wf.name}${step}\nThis session is mid-workflow — preserve everything needed to resume it.`, ); } + const tools = ctx?.activatedTools ?? []; + if (tools.length > 0) { + parts.push( + `Tools activated via tool_search and still callable directly: ${tools.join(", ")}`, + ); + } if (parts.length === 0) return ""; return `${parts.join("\n\n")}\n\n`; } @@ -150,7 +160,7 @@ export function buildSummaryPrompt( excerpt !== undefined && excerpt.length > 0 ? excerpt : condenseTurns(turns); - return `${workflowPreamble(ctx)}Session excerpt:\n\n${body}`; + return `${contextPreamble(ctx)}Session excerpt:\n\n${body}`; } // Low-level completion: one inference round-trip returning assistant text. diff --git a/src/state.test.ts b/src/state.test.ts index 8d43b5f25..ff39d3ec4 100644 --- a/src/state.test.ts +++ b/src/state.test.ts @@ -234,6 +234,16 @@ describe("state persistence", () => { expect(loaded).toEqual({ kind: "ok", state: baseRunState }); }); + test("saveState round-trips activatedTools", async () => { + const state: RunState = { + ...baseRunState, + activatedTools: ["mcp__linear__save_issue", "present"], + }; + await saveState(cwd, SESSION_ID, state, home); + const loaded = await loadState(cwd, SESSION_ID, home); + expect(loaded).toEqual({ kind: "ok", state }); + }); + test("loadState rejects a mcpServers entry missing toolCount", async () => { const stateDir = dir(); const { mkdir } = await import("node:fs/promises"); diff --git a/src/tui/dynamic-tool-runner.test.ts b/src/tui/dynamic-tool-runner.test.ts index 3cdb7e408..506a73212 100644 --- a/src/tui/dynamic-tool-runner.test.ts +++ b/src/tui/dynamic-tool-runner.test.ts @@ -14,7 +14,7 @@ const stringTool = (name: string, reply: string): AgentTool => ({ }); describe("blind tool dispatch", () => { - test("a registered-but-unadvertised tool is still callable", async () => { + test("a registered-but-unadvertised tool is still callable without a gate", async () => { const runner = createDynamicToolRunner([ stringTool("read_file", "core"), stringTool("mcp__acme__do", "blind-result"), @@ -37,6 +37,63 @@ describe("blind tool dispatch", () => { }); }); +describe("call gate", () => { + test("a registered tool off the wire errors toward tool_search", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + const advertised = new Set(["read_file"]); + runner.setCallGate((name) => advertised.has(name)); + + const result = await runner.run( + { id: "1", name: "mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toContain("mcp__acme__do"); + expect(result.content).toContain("tool_search"); + }); + + test("a name absent from the registry still reports unknown tool", async () => { + const runner = createDynamicToolRunner([stringTool("read_file", "core")]); + runner.setCallGate(() => true); + + const result = await runner.run( + { id: "1", name: "mcp__gone__tool", arguments: {} }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toBe("unknown tool: mcp__gone__tool"); + }); + + test("a gate that later admits the name (activation) dispatches it", async () => { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core"), + stringTool("mcp__acme__do", "blind-result"), + ]); + const advertised = new Set(["read_file"]); + runner.setCallGate((name) => advertised.has(name)); + + const blocked = await runner.run( + { id: "1", name: "mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + expect(blocked.isError).toBe(true); + + // tool_search promotion grows the wire set; the same call then dispatches. + advertised.add("mcp__acme__do"); + const result = await runner.run( + { id: "2", name: "mcp__acme__do", arguments: {} }, + new AbortController().signal, + ); + expect(result.content).toBe("blind-result"); + expect(result.isError).toBeUndefined(); + }); +}); + describe("terminal control stripping", () => { test("strips escape sequences from any tool's result, including MCP", async () => { const payload = "before\x1b]52;c;ZXZpbA==\x07\x1b[31mred\x1b[0m\x07after"; diff --git a/src/tui/dynamic-tool-runner.ts b/src/tui/dynamic-tool-runner.ts index 89495eb76..1cf523de1 100644 --- a/src/tui/dynamic-tool-runner.ts +++ b/src/tui/dynamic-tool-runner.ts @@ -25,6 +25,13 @@ export type DynamicToolRunner = AgentToolRunner & { addTools(tools: AgentTool[]): void; removeTools(names: string[]): void; currentDefinitions(): ToolDefinition[]; + /** + * When a gate is set, run() refuses a registered tool whose name is not on + * the current wire (built-in prefix + pinned + activated) with an error + * pointing at tool_search, instead of silently dispatching. Without a gate + * every registered tool stays dispatchable — sub-agent runners never set one. + */ + setCallGate(isCallable: (name: string) => boolean): void; }; export function createDynamicToolRunner( @@ -32,6 +39,7 @@ export function createDynamicToolRunner( watchdogConfig?: ToolWatchdogConfig, ): DynamicToolRunner { const byName = new Map(); + let callGate: ((name: string) => boolean) | undefined; const addTools = (tools: AgentTool[]): void => { const incoming = new Set(); @@ -60,6 +68,9 @@ export function createDynamicToolRunner( addTools, removeTools, currentDefinitions, + setCallGate(isCallable: (name: string) => boolean): void { + callGate = isCallable; + }, async run(call: ToolCall, signal: AbortSignal): Promise { const found = byName.get(call.name); if (found === undefined) { @@ -69,6 +80,20 @@ export function createDynamicToolRunner( isError: true, }; } + // The model can only intend a name it saw on the wire. A registered tool + // the wire no longer advertises (activation state lost across a rebuild, + // or a name the model emitted unaided) must fail loudly with a route back + // to tool_search — silently dispatching it leaves the transcript claiming + // a call the next infer's wire does not declare. + if (callGate !== undefined && !callGate(call.name)) { + return { + callId: call.id, + content: + `Error: ${call.name} is not in the currently advertised tool list. ` + + `Call tool_search to activate it, then retry the call.`, + isError: true, + }; + } const executionTimeoutMs = resolveToolExecutionTimeoutMs( watchdogConfig, call, diff --git a/src/tui/resume-seed.test.ts b/src/tui/resume-seed.test.ts index 99a9b7e7d..cff8a8ba7 100644 --- a/src/tui/resume-seed.test.ts +++ b/src/tui/resume-seed.test.ts @@ -14,7 +14,11 @@ function pickedState(overrides: Partial): RunState { describe("resolveResumeSeed", () => { test("a fresh (non-resumed) run seeds zero turns and no servers", () => { - expect(resolveResumeSeed(null)).toEqual({ turnsUsed: 0, mcpServers: [] }); + expect(resolveResumeSeed(null)).toEqual({ + turnsUsed: 0, + mcpServers: [], + activatedTools: [], + }); }); test("carries forward a resumed session's non-zero turnsUsed and non-empty mcpServers", () => { @@ -41,4 +45,23 @@ describe("resolveResumeSeed", () => { expect(seed.turnsUsed).toBe(3); expect(seed.mcpServers).toEqual([]); }); + + test("carries forward the resumed session's activated tool names", () => { + const seed = resolveResumeSeed( + pickedState({ + activatedTools: ["mcp__linear__save_issue", "mcp__linear__get_issue"], + }), + ); + + expect(seed.activatedTools).toEqual([ + "mcp__linear__save_issue", + "mcp__linear__get_issue", + ]); + }); + + test("defaults activatedTools to empty when a resumed record predates that field", () => { + const seed = resolveResumeSeed(pickedState({ turnsUsed: 3 })); + + expect(seed.activatedTools).toEqual([]); + }); }); diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 9fb640bbb..5350ac13d 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -188,11 +188,13 @@ function createRunPersistence(state: RunnerState, services: RunnerServices) { // Kept in step with every persisted snapshot so the crash handler's copy // (activeRunHandle, read by index.ts) never lags what's actually on disk. const turnsUsed = services.runSink.getTurnCount(); + const activatedTools = services.activatedToolNames.list(); syncRunStateHandle(services.activeRunHandle, { turnsUsed, task, startedAt: state.startedAt, model, + activatedTools, }); const persisted: RunState = { status, @@ -201,6 +203,7 @@ function createRunPersistence(state: RunnerState, services: RunnerServices) { startedAt: state.startedAt, model, mcpServers: state.connectedMcpServers, + ...(activatedTools.length > 0 ? { activatedTools } : {}), ...extra, }; if (clearsActiveRun(kind)) { @@ -359,6 +362,10 @@ export async function createRunLifecycle( services.toolset.dynamicRunner.currentDefinitions(), ), ); + // Activation is model-visible contract — persist it now so a crash or + // restart before the next turn boundary does not strand the transcript's + // "these tools are available" record. + void persistRunSnapshot("running"); state.pendingReload = true; reloadIfIdle(); }; @@ -615,6 +622,7 @@ export async function createRunLifecycle( : "(conversation)", startedAt: state.startedAt, model: `${rotatedBundle.selected.id}:${rotatedBundle.selected.model}`, + activatedTools: [], }); services.emitter.emit( "session.title", @@ -630,6 +638,9 @@ export async function createRunLifecycle( services.permissionGate.reset(); services.runSink.reset(); services.sessionCost.reset(); + // The rotated session's transcript never recorded the activations, so + // its wire starts at the prefix and its run.json does not inherit them. + services.activatedToolNames.clear(); state.currentAgent = await services.buildAgent(); services.cycleRecorder.reset(); state.streamPromise = consumeStream( diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 74f16d64f..d71dccd3a 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -91,7 +91,14 @@ import type { ToolWatchdogConfig } from "../tool-execution-watchdog.js"; import { deliverAgentMessage } from "../deliver-agent-message.js"; import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; import { getTelemetry, liveTelemetry } from "../../telemetry/singleton.js"; -import { createChatDirector } from "../../agent/director.js"; +import { + createChatDirector, + submitOutputDefinition, +} from "../../agent/director.js"; +import { + shellDefinition, + updatePlanDefinition, +} from "../../agent/codex-tool-proxies.js"; import { attachApprovalBudget } from "../request-approval.js"; import { createGateRequestApproval } from "../request-approval.js"; import { getActivePricingCache } from "../../cost/cost-visibility.js"; @@ -322,6 +329,9 @@ export async function assembleTUISession( ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } : {}), + ...(localSettingsForEnv?.pinnedTools !== undefined + ? { pinnedTools: localSettingsForEnv.pinnedTools } + : {}), toolWatchdog: liveToolWatchdog, getBlobReader: () => liveAgent(state).blobReader, getBlobWriter: () => state.currentStorage?.writeBlob, @@ -446,12 +456,35 @@ export async function assembleTUISession( // Dynamic tool discovery: only the fixed built-in prefix plus activated // tools reach the wire, so the provider cache prefix holds steady; MCP // tools must be promoted here before the model can invoke them. - const { activated: activatedToolNames, computeAdvertised } = - createAdvertisedToolset({ - sessionMode: liveSessionMode, - toolAvailability, - getProvider: () => state.config, - }); + const { + activated: activatedToolNames, + computeAdvertised, + isAdvertised, + } = createAdvertisedToolset({ + sessionMode: liveSessionMode, + toolAvailability, + getProvider: () => state.config, + ...(localSettingsForEnv?.pinnedTools !== undefined + ? { pinnedTools: localSettingsForEnv.pinnedTools } + : {}), + }); + // Re-activate the prior run's promoted tools before the first build so the + // post-resume wire matches the transcript the model still sees. + activatedToolNames.activate(start.resumeSeed.activatedTools); + // A registered tool the wire never advertised must error toward tool_search + // instead of dispatching blind — the transcript would otherwise claim a call + // the next infer does not declare. submit_output rides every infer via the + // director, and Codex's native proxies answer calls Codex models emit + // unaided; neither flows through the advertised set. + const unadvertisedCallable = new Set([ + submitOutputDefinition.name, + ...(isCodexProviderName(config.providerName) + ? [shellDefinition.name, updatePlanDefinition.name] + : []), + ]); + toolset.dynamicRunner.setCallGate( + (name) => unadvertisedCallable.has(name) || isAdvertised(name), + ); // Reload, interrupt, compaction continuation, and proxy deliver share one queue // so a rebuild never races an in-flight deliver. @@ -513,14 +546,20 @@ export async function assembleTUISession( }); const summaryContext = (): SummaryContext | undefined => { const status = workflowHost.status(); - if (!status.active) return undefined; + const activatedTools = activatedToolNames.list(); + if (!status.active && activatedTools.length === 0) return undefined; return { - workflow: { - ...(status.name !== undefined ? { name: status.name } : {}), - stepLabel: status.label, - stepIndex: status.stepIndex, - total: status.total, - }, + ...(status.active + ? { + workflow: { + ...(status.name !== undefined ? { name: status.name } : {}), + stepLabel: status.label, + stepIndex: status.stepIndex, + total: status.total, + }, + } + : {}), + ...(activatedTools.length > 0 ? { activatedTools } : {}), }; }; diff --git a/src/tui/session-start.ts b/src/tui/session-start.ts index 6c80b76f6..bd4d5ef2e 100644 --- a/src/tui/session-start.ts +++ b/src/tui/session-start.ts @@ -44,9 +44,16 @@ const sessionStartLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); export interface ResumeSeed { turnsUsed: number; mcpServers: ConnectedMcpServer[]; + // tool_search-promoted tool names from the prior run, re-activated before + // the first post-resume inference so the wire matches the transcript. + activatedTools: string[]; } -const FRESH_RESUME_SEED: ResumeSeed = { turnsUsed: 0, mcpServers: [] }; +const FRESH_RESUME_SEED: ResumeSeed = { + turnsUsed: 0, + mcpServers: [], + activatedTools: [], +}; /** * Fold a resumed session's run.json into a concrete seed once, at the @@ -61,6 +68,7 @@ export function resolveResumeSeed(pickedState: RunState | null): ResumeSeed { return { turnsUsed: pickedState.turnsUsed, mcpServers: pickedState.mcpServers ?? [], + activatedTools: pickedState.activatedTools ?? [], }; } @@ -113,6 +121,7 @@ export function createTUICrashGuard( // still reach that listener with the handle live, so it's cleared here // too to close that earlier window. const turnsUsed = getActiveRun()?.turnsUsed ?? 0; + const activatedTools = getActiveRun()?.activatedTools; clearActiveRun(); clearActiveDisposeHost(); await flushPartialOnCrash().catch((flushErr: unknown) => { @@ -141,6 +150,7 @@ export function createTUICrashGuard( error: message, model: `${live.providerName}:${live.model}`, mcpServers: [], + ...(activatedTools !== undefined ? { activatedTools } : {}), }).catch((saveErr: unknown) => { const saveMessage = saveErr instanceof Error ? saveErr.message : String(saveErr); @@ -254,6 +264,17 @@ export async function prepareTUISession( pickedState !== null ? { ...config, sessionId, task: pickedState.task } : { ...config, sessionId, task: runTaskTitle }; + } else if (config.resumeMode === "id") { + // `resume ` resolved the session id in loadConfig but never read + // run.json — load it here so turnsUsed, mcpServers, and activatedTools + // carry forward. runTaskTitle stays config.task: the CLI already folded + // the prior task into it when no new task text was given. + const loaded = await loadState(config.cwd, sessionId); + const pickedState = loaded.kind === "ok" ? loaded.state : null; + resumeSeed = resolveResumeSeed(pickedState); + if (pickedState !== null) { + startedAt = pickedState.startedAt; + } } const workdir = sessionContextDir(config.cwd, sessionId); @@ -272,6 +293,7 @@ export async function prepareTUISession( startedAt, model: `${config.providerName}:${config.model}`, mcpServers: resumeSeed.mcpServers, + activatedTools: resumeSeed.activatedTools, }); // Registered the moment a run starts so the top-level uncaughtException / @@ -288,6 +310,7 @@ export async function prepareTUISession( startedAt, turnsUsed: resumeSeed.turnsUsed, model: `${config.providerName}:${config.model}`, + activatedTools: resumeSeed.activatedTools, }; setActiveRun(activeRunHandle); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index d684cc4aa..e67953e64 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -347,6 +347,7 @@ test("loadSettings cannot silently drop a known optional key", async () => { mcpServers: [{ name: "s", command: "echo" }], sessionMode: "orchestrator" as const, env: { FOO: "bar" }, + pinnedTools: ["mcp__linear__save_issue"], }; await writeFile(localPath, JSON.stringify(localFixture)); const local = await loadLocalSettings(localPath); diff --git a/tests/unit/exec/runner.test.ts b/tests/unit/exec/runner.test.ts index 1c16ed955..974800b5c 100644 --- a/tests/unit/exec/runner.test.ts +++ b/tests/unit/exec/runner.test.ts @@ -3,9 +3,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; +import type { AgentTool } from "@intx/agent"; import type { InferenceSource } from "@intx/types/runtime"; import type { Config } from "../../../src/config/index.js"; import { + createExecToolCallGate, + createExecToolPromoter, disposeExecRuntime, execUserFailureMessage, formatCaughtError, @@ -13,6 +16,17 @@ import { resolveExecDirectorOverlay, runExec, } from "../../../src/exec/runner.js"; +import { submitOutputDefinition } from "../../../src/agent/director.js"; +import { + shellDefinition, + updatePlanDefinition, +} from "../../../src/agent/codex-tool-proxies.js"; +import { + createToolIndex, + createToolSearchTool, +} from "../../../src/agent/tool-search.js"; +import { createAdvertisedToolset } from "../../../src/session/assemble-runtime.js"; +import { createDynamicToolRunner } from "../../../src/tui/dynamic-tool-runner.js"; import { BUILD_TOOLS, SKYWALKER_TOOLS, @@ -269,7 +283,9 @@ describe("runExec", () => { createAgentToolset: async (): Promise => ({ dispose: () => Promise.resolve(), - }) as AgentToolset, + dynamicRunner: { setCallGate: () => undefined }, + setToolPromoter: () => undefined, + }) as unknown as AgentToolset, }), async () => { await withMockedModuleDuring( @@ -655,3 +671,138 @@ describe("resolveExecDirectorOverlay", () => { ).toBeUndefined(); }); }); + +describe("exec tool call gate and promoter", () => { + const stringTool = ( + name: string, + reply: string, + description: string, + ): AgentTool => ({ + kind: "string", + definition: { + name, + description, + inputSchema: { type: "object", properties: {}, required: [] }, + }, + handler: async () => reply, + }); + + function wireExecDiscovery(isCodex: boolean) { + const runner = createDynamicToolRunner([ + stringTool("read_file", "core", "read a file"), + stringTool( + "mcp__linear__save_issue", + "saved", + "Save an issue in the Linear tracker", + ), + stringTool( + "present", + "view", + "search and render layout primitives for pages", + ), + stringTool("plugin__notes__save", "noted", "Save granola notes"), + stringTool(submitOutputDefinition.name, "submitted", "submit output"), + stringTool(shellDefinition.name, "sh", "run a shell command"), + stringTool(updatePlanDefinition.name, "planned", "update the plan"), + ]); + const { activated, isAdvertised, computeAdvertised } = + createAdvertisedToolset({ + sessionMode: "orchestrator", + toolAvailability: { languageServerAvailable: false }, + getProvider: () => ({ providerName: "test", model: "test" }), + }); + runner.setCallGate(createExecToolCallGate(isAdvertised, { isCodex })); + let persistCount = 0; + const directorNames: string[][] = []; + const promote = createExecToolPromoter({ + activate: (names) => activated.activate(names), + currentDefinitions: () => runner.currentDefinitions(), + computeAdvertised, + updateDirectorTools: (defs) => { + directorNames.push(defs.map((d) => d.name)); + }, + persist: () => { + persistCount += 1; + }, + }); + const search = createToolSearchTool({ + search: (query) => + createToolIndex(() => runner.currentDefinitions()).search(query), + lookup: (name) => + runner.currentDefinitions().find((d) => d.name === name), + promote, + }); + return { runner, persistCount: () => persistCount, directorNames, search }; + } + + async function dispatch( + runner: ReturnType, + name: string, + ) { + return runner.run( + { id: name, name, arguments: {} }, + new AbortController().signal, + ); + } + + test("tool_search then MCP dispatch with the gate on", async () => { + const { runner, search, persistCount, directorNames } = + wireExecDiscovery(false); + const blocked = await dispatch(runner, "mcp__linear__save_issue"); + expect(blocked.isError).toBe(true); + expect(blocked.content).toContain("tool_search"); + + if (search.kind !== "string") throw new Error("expected string tool"); + await search.handler({ query: "linear" }, new AbortController().signal); + expect(persistCount()).toBe(1); + expect(directorNames.at(-1)).toContain("mcp__linear__save_issue"); + + const allowed = await dispatch(runner, "mcp__linear__save_issue"); + expect(allowed.content).toBe("saved"); + expect(allowed.isError).toBeUndefined(); + }); + + test("present and plugin names pass the gate after tool_search promote", async () => { + const { runner, search } = wireExecDiscovery(false); + expect((await dispatch(runner, "present")).isError).toBe(true); + expect((await dispatch(runner, "plugin__notes__save")).isError).toBe(true); + + if (search.kind !== "string") throw new Error("expected string tool"); + await search.handler( + { query: "render layout" }, + new AbortController().signal, + ); + await search.handler( + { query: "granola notes" }, + new AbortController().signal, + ); + + expect((await dispatch(runner, "present")).content).toBe("view"); + expect((await dispatch(runner, "plugin__notes__save")).content).toBe( + "noted", + ); + }); + + test("gate admits submit_output without activation", async () => { + const { runner } = wireExecDiscovery(false); + const result = await dispatch(runner, submitOutputDefinition.name); + expect(result.content).toBe("submitted"); + expect(result.isError).toBeUndefined(); + }); + + test("Codex gate admits shell and update_plan without activation", async () => { + const { runner } = wireExecDiscovery(true); + const shell = await dispatch(runner, shellDefinition.name); + expect(shell.content).toBe("sh"); + expect(shell.isError).toBeUndefined(); + const plan = await dispatch(runner, updatePlanDefinition.name); + expect(plan.content).toBe("planned"); + expect(plan.isError).toBeUndefined(); + }); + + test("non-Codex gate refuses shell until it is advertised", async () => { + const { runner } = wireExecDiscovery(false); + const blocked = await dispatch(runner, shellDefinition.name); + expect(blocked.isError).toBe(true); + }); +});