Skip to content

Commit 7731f01

Browse files
committed
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.
1 parent b1e8bc0 commit 7731f01

4 files changed

Lines changed: 211 additions & 21 deletions

File tree

‎src/agent/tool-search.ts‎

Lines changed: 10 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
@@ -197,7 +197,7 @@ export function createActivatedToolTracker(): ActivatedToolTracker {
197197
export const toolSearchDefinition: ToolDefinition = {
198198
name: "tool_search",
199199
description:
200-
"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.",
201201
inputSchema: {
202202
type: "object",
203203
properties: {
@@ -267,9 +267,9 @@ export function createToolIndex(
267267
export interface ToolSearchDeps {
268268
search: (query: string) => string[];
269269
lookup: (name: string) => ToolDefinition | undefined;
270-
// Make the matched tools' names part of the advertised wire set on the next
271-
// inference. Every registered tool is already dispatchable via `run`, so this
272-
// 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.
273273
promote: (names: string[]) => void;
274274
}
275275

‎src/exec/runner.ts‎

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import type {
4444
ContextStore,
4545
InferenceSource,
4646
InboundMessage,
47+
ToolDefinition,
4748
} from "@intx/types/runtime";
4849
import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js";
4950
import { loadAgentProfiles } from "../agent/profiles.js";
@@ -336,6 +337,33 @@ export interface ExecResult {
336337
model?: string;
337338
}
338339

340+
export function createExecToolCallGate(
341+
isAdvertised: (name: string) => boolean,
342+
options: { isCodex: boolean },
343+
): (name: string) => boolean {
344+
const unadvertisedCallable = new Set<string>([
345+
submitOutputDefinition.name,
346+
...(options.isCodex
347+
? [shellDefinition.name, updatePlanDefinition.name]
348+
: []),
349+
]);
350+
return (name) => unadvertisedCallable.has(name) || isAdvertised(name);
351+
}
352+
353+
export function createExecToolPromoter(args: {
354+
activate: (names: readonly string[]) => boolean;
355+
currentDefinitions: () => readonly ToolDefinition[];
356+
computeAdvertised: (all: readonly ToolDefinition[]) => ToolDefinition[];
357+
updateDirectorTools: (defs: ToolDefinition[]) => void;
358+
persist?: () => void;
359+
}): (names: string[]) => void {
360+
return (names) => {
361+
if (!args.activate(names)) return;
362+
args.updateDirectorTools(args.computeAdvertised(args.currentDefinitions()));
363+
args.persist?.();
364+
};
365+
}
366+
339367
/**
340368
* Product non-TUI agent path (`corbits exec "prompt"`).
341369
*
@@ -713,14 +741,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
713741
activatedToolsRef.current = activatedToolNames;
714742
// Same wire contract as the TUI: a registered tool the model was never
715743
// 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-
]);
722744
agentToolset.dynamicRunner.setCallGate(
723-
(name) => unadvertisedCallable.has(name) || isAdvertised(name),
745+
createExecToolCallGate(isAdvertised, {
746+
isCodex: isCodexProviderName(config.providerName),
747+
}),
724748
);
725749

726750
const { directorHolder, buildAgent } = assembleChatAgent({
@@ -774,6 +798,23 @@ export async function runExec(config: Config): Promise<ExecResult> {
774798
evidenceArchiveHolder,
775799
});
776800

801+
// tool_search starts as a no-op promoter; without this, the call gate
802+
// refuses MCP/present/plugin names the result just told the model to
803+
// invoke.
804+
agentToolset.setToolPromoter(
805+
createExecToolPromoter({
806+
activate: (names) => activatedToolNames.activate(names),
807+
currentDefinitions: () =>
808+
agentToolset.dynamicRunner.currentDefinitions(),
809+
computeAdvertised,
810+
updateDirectorTools: (defs) =>
811+
directorHolder.instance?.updateToolDefinitions(defs),
812+
persist: () => {
813+
void persist("running");
814+
},
815+
}),
816+
);
817+
777818
const workflowHost = new WorkflowHost({
778819
cwd: config.cwd,
779820
getSessionId: () => sessionId,

‎src/session/summarizer.ts‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,9 @@ export interface SummaryContext {
3030
stepIndex?: number;
3131
total?: number;
3232
};
33-
// Tool names activated via tool_search (or pinned) and still on the wire.
34-
// Carried into the folded handoff so the summary and the post-compact
35-
// advertised set agree — the transcript's "these tools are available"
36-
// record survives the fold.
33+
// Tool names activated via tool_search and still on the wire. Pinned names
34+
// live in the advertised prefix, not this list — callers pass
35+
// activatedToolNames.list() only.
3736
activatedTools?: string[];
3837
}
3938

‎tests/unit/exec/runner.test.ts‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,30 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44

55
import { describe, expect, test } from "bun:test";
6+
import type { AgentTool } from "@intx/agent";
67
import type { InferenceSource } from "@intx/types/runtime";
78
import type { Config } from "../../../src/config/index.js";
89
import {
10+
createExecToolCallGate,
11+
createExecToolPromoter,
912
disposeExecRuntime,
1013
execUserFailureMessage,
1114
formatCaughtError,
1215
refreshSelectedProviderCredential,
1316
resolveExecDirectorOverlay,
1417
runExec,
1518
} from "../../../src/exec/runner.js";
19+
import { submitOutputDefinition } from "../../../src/agent/director.js";
20+
import {
21+
shellDefinition,
22+
updatePlanDefinition,
23+
} from "../../../src/agent/codex-tool-proxies.js";
24+
import {
25+
createToolIndex,
26+
createToolSearchTool,
27+
} from "../../../src/agent/tool-search.js";
28+
import { createAdvertisedToolset } from "../../../src/session/assemble-runtime.js";
29+
import { createDynamicToolRunner } from "../../../src/tui/dynamic-tool-runner.js";
1630
import {
1731
BUILD_TOOLS,
1832
SKYWALKER_TOOLS,
@@ -270,6 +284,7 @@ describe("runExec", () => {
270284
({
271285
dispose: () => Promise.resolve(),
272286
dynamicRunner: { setCallGate: () => undefined },
287+
setToolPromoter: () => undefined,
273288
}) as unknown as AgentToolset,
274289
}),
275290
async () => {
@@ -656,3 +671,138 @@ describe("resolveExecDirectorOverlay", () => {
656671
).toBeUndefined();
657672
});
658673
});
674+
675+
describe("exec tool call gate and promoter", () => {
676+
const stringTool = (
677+
name: string,
678+
reply: string,
679+
description: string,
680+
): AgentTool => ({
681+
kind: "string",
682+
definition: {
683+
name,
684+
description,
685+
inputSchema: { type: "object", properties: {}, required: [] },
686+
},
687+
handler: async () => reply,
688+
});
689+
690+
function wireExecDiscovery(isCodex: boolean) {
691+
const runner = createDynamicToolRunner([
692+
stringTool("read_file", "core", "read a file"),
693+
stringTool(
694+
"mcp__linear__save_issue",
695+
"saved",
696+
"Save an issue in the Linear tracker",
697+
),
698+
stringTool(
699+
"present",
700+
"view",
701+
"search and render layout primitives for pages",
702+
),
703+
stringTool("plugin__notes__save", "noted", "Save granola notes"),
704+
stringTool(submitOutputDefinition.name, "submitted", "submit output"),
705+
stringTool(shellDefinition.name, "sh", "run a shell command"),
706+
stringTool(updatePlanDefinition.name, "planned", "update the plan"),
707+
]);
708+
const { activated, isAdvertised, computeAdvertised } =
709+
createAdvertisedToolset({
710+
sessionMode: "orchestrator",
711+
toolAvailability: { languageServerAvailable: false },
712+
getProvider: () => ({ providerName: "test", model: "test" }),
713+
});
714+
runner.setCallGate(createExecToolCallGate(isAdvertised, { isCodex }));
715+
let persistCount = 0;
716+
const directorNames: string[][] = [];
717+
const promote = createExecToolPromoter({
718+
activate: (names) => activated.activate(names),
719+
currentDefinitions: () => runner.currentDefinitions(),
720+
computeAdvertised,
721+
updateDirectorTools: (defs) => {
722+
directorNames.push(defs.map((d) => d.name));
723+
},
724+
persist: () => {
725+
persistCount += 1;
726+
},
727+
});
728+
const search = createToolSearchTool({
729+
search: (query) =>
730+
createToolIndex(() => runner.currentDefinitions()).search(query),
731+
lookup: (name) =>
732+
runner.currentDefinitions().find((d) => d.name === name),
733+
promote,
734+
});
735+
return { runner, persistCount: () => persistCount, directorNames, search };
736+
}
737+
738+
async function dispatch(
739+
runner: ReturnType<typeof createDynamicToolRunner>,
740+
name: string,
741+
) {
742+
return runner.run(
743+
{ id: name, name, arguments: {} },
744+
new AbortController().signal,
745+
);
746+
}
747+
748+
test("tool_search then MCP dispatch with the gate on", async () => {
749+
const { runner, search, persistCount, directorNames } =
750+
wireExecDiscovery(false);
751+
const blocked = await dispatch(runner, "mcp__linear__save_issue");
752+
expect(blocked.isError).toBe(true);
753+
expect(blocked.content).toContain("tool_search");
754+
755+
if (search.kind !== "string") throw new Error("expected string tool");
756+
await search.handler({ query: "linear" }, new AbortController().signal);
757+
expect(persistCount()).toBe(1);
758+
expect(directorNames.at(-1)).toContain("mcp__linear__save_issue");
759+
760+
const allowed = await dispatch(runner, "mcp__linear__save_issue");
761+
expect(allowed.content).toBe("saved");
762+
expect(allowed.isError).toBeUndefined();
763+
});
764+
765+
test("present and plugin names pass the gate after tool_search promote", async () => {
766+
const { runner, search } = wireExecDiscovery(false);
767+
expect((await dispatch(runner, "present")).isError).toBe(true);
768+
expect((await dispatch(runner, "plugin__notes__save")).isError).toBe(true);
769+
770+
if (search.kind !== "string") throw new Error("expected string tool");
771+
await search.handler(
772+
{ query: "render layout" },
773+
new AbortController().signal,
774+
);
775+
await search.handler(
776+
{ query: "granola notes" },
777+
new AbortController().signal,
778+
);
779+
780+
expect((await dispatch(runner, "present")).content).toBe("view");
781+
expect((await dispatch(runner, "plugin__notes__save")).content).toBe(
782+
"noted",
783+
);
784+
});
785+
786+
test("gate admits submit_output without activation", async () => {
787+
const { runner } = wireExecDiscovery(false);
788+
const result = await dispatch(runner, submitOutputDefinition.name);
789+
expect(result.content).toBe("submitted");
790+
expect(result.isError).toBeUndefined();
791+
});
792+
793+
test("Codex gate admits shell and update_plan without activation", async () => {
794+
const { runner } = wireExecDiscovery(true);
795+
const shell = await dispatch(runner, shellDefinition.name);
796+
expect(shell.content).toBe("sh");
797+
expect(shell.isError).toBeUndefined();
798+
const plan = await dispatch(runner, updatePlanDefinition.name);
799+
expect(plan.content).toBe("planned");
800+
expect(plan.isError).toBeUndefined();
801+
});
802+
803+
test("non-Codex gate refuses shell until it is advertised", async () => {
804+
const { runner } = wireExecDiscovery(false);
805+
const blocked = await dispatch(runner, shellDefinition.name);
806+
expect(blocked.isError).toBe(true);
807+
});
808+
});

0 commit comments

Comments
 (0)