Skip to content

Commit 310858f

Browse files
committed
Advertise present, manage_goal, manage_tasks, and lsp conditionally
present now stays fully dispatchable but is discovered via tool_search instead of riding every request at 2,793 chars — the second-largest core tool schema for a feature most sessions never touch. manage_goal is advertised only when the session was resumed with an already-active goal, and lsp only when a language server was resolved at startup. Both facts are captured once, before the first inference call, since the wire tools array is a provider cache prefix: toggling either mid-session would force a re-prefill worse than the schema bytes saved. manage_tasks stays unconditional in both session modes — the goal-kickoff sequence calls manage_goal then manage_tasks back to back, so gating the second would trade one tool_search round trip for two.
1 parent c423b91 commit 310858f

9 files changed

Lines changed: 240 additions & 33 deletions

File tree

src/agent/lsp-availability.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, test, expect, afterEach } from "bun:test";
2+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import path from "node:path";
5+
import { detectLanguageServerAvailable } from "./lsp-availability.js";
6+
7+
const dirsToClean: string[] = [];
8+
9+
async function tempProject(): Promise<string> {
10+
const dir = await mkdtemp(path.join(tmpdir(), "corbits-lsp-availability-"));
11+
dirsToClean.push(dir);
12+
return dir;
13+
}
14+
15+
afterEach(async () => {
16+
await Promise.all(dirsToClean.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
17+
});
18+
19+
async function seedTsserver(dir: string): Promise<void> {
20+
const tsserverDir = path.join(dir, "node_modules", "typescript", "lib");
21+
await mkdir(tsserverDir, { recursive: true });
22+
await writeFile(path.join(tsserverDir, "tsserver.js"), "");
23+
}
24+
25+
describe("detectLanguageServerAvailable", () => {
26+
test("false when typescript is not installed in the project", async () => {
27+
const dir = await tempProject();
28+
expect(detectLanguageServerAvailable(dir)).toBe(false);
29+
});
30+
31+
test("true when tsserver is resolvable and a local .bin binary exists", async () => {
32+
const dir = await tempProject();
33+
await seedTsserver(dir);
34+
const bin = path.join(dir, "node_modules", ".bin", "typescript-language-server");
35+
await mkdir(path.dirname(bin), { recursive: true });
36+
await writeFile(bin, "#!/usr/bin/env node\n");
37+
expect(detectLanguageServerAvailable(dir)).toBe(true);
38+
});
39+
40+
test("the real project checkout has a language server available", () => {
41+
// This repo itself installs typescript and typescript-language-server as
42+
// devDependencies, so detection against the actual cwd is a live check
43+
// that the two-condition logic agrees with what createLSPPlugin would find.
44+
expect(detectLanguageServerAvailable(process.cwd())).toBe(true);
45+
});
46+
});

src/agent/lsp-availability.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { existsSync } from "node:fs";
2+
import path from "node:path";
3+
4+
// Mirrors the sole server @intx/tools-lsp registers today (Typescript):
5+
// spawning requires a resolvable tsserver plus a reachable language-server
6+
// binary. Checked once at session start via the filesystem and PATH — never
7+
// by spawning a server — because the `lsp` tool's advertisement is baked into
8+
// the wire tools array for the life of the session (see tool-search.ts).
9+
export function detectLanguageServerAvailable(cwd: string): boolean {
10+
const tsserverPath = path.join(cwd, "node_modules", "typescript", "lib", "tsserver.js");
11+
if (!existsSync(tsserverPath)) return false;
12+
const localBin = path.join(cwd, "node_modules", ".bin", "typescript-language-server");
13+
if (existsSync(localBin)) return true;
14+
return Bun.which("typescript-language-server") !== null;
15+
}

src/agent/prompts.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import type { EnvironmentInfo } from "./environment.js";
22
import type { SkillSummary } from "../extensions/skills.js";
33
import type { SessionMode } from "../config/session-mode.js";
4-
import { coreToolNamesForSessionMode, CORE_TOOL_NAMES } from "./tool-search.js";
4+
import {
5+
coreToolNamesForSessionMode,
6+
CORE_TOOL_NAMES,
7+
type ToolAvailability,
8+
} from "./tool-search.js";
9+
10+
// Advertise every gated core tool when the caller has no session-start facts
11+
// (tests, ad-hoc prompt previews). Real sessions always pass their detected
12+
// availability — see tui/runner.ts and exec/runner.ts.
13+
const DEFAULT_TOOL_AVAILABILITY: ToolAvailability = {
14+
hasGoalAtLaunch: true,
15+
languageServerAvailable: true,
16+
};
517
import { PRODUCT_NAME, SETTINGS_DIR_NAME } from "../branding.js";
618

719
// Fallback tool list for sub-agent prompts when the caller does not pass the
@@ -295,10 +307,11 @@ export function buildChatSystemPrompt(
295307
baseOverride?: string,
296308
skills: readonly SkillSummary[] = [],
297309
sessionMode: SessionMode = "orchestrator",
310+
toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY,
298311
): string {
299312
const sections = [
300313
baseSection(baseOverride, sessionMode),
301-
buildAvailableTools(coreToolNamesForSessionMode(sessionMode)),
314+
buildAvailableTools(coreToolNamesForSessionMode(sessionMode, toolAvailability)),
302315
];
303316
if (skills.length > 0) sections.push(buildSkillsSection(skills));
304317
sections.push(contextSection(env));

src/agent/tool-search.test.ts

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,21 @@ import {
66
createActivatedToolTracker,
77
advertisedTools,
88
advertisedToolNamesForSessionMode,
9+
coreToolNamesForSessionMode,
910
CORE_TOOL_NAMES,
1011
CATALOG_TOOL_NAMES,
12+
type ToolAvailability,
1113
} from "./tool-search.js";
1214

15+
const FULL_AVAILABILITY: ToolAvailability = {
16+
hasGoalAtLaunch: true,
17+
languageServerAvailable: true,
18+
};
19+
const NO_AVAILABILITY: ToolAvailability = {
20+
hasGoalAtLaunch: false,
21+
languageServerAvailable: false,
22+
};
23+
1324
const defs: ToolDefinition[] = [
1425
{ name: "read_file", description: "read a file", inputSchema: { type: "object", properties: {}, required: [] } },
1526
{ name: "web_search", description: "search the web for pages", inputSchema: { type: "object", properties: {}, required: [] } },
@@ -55,10 +66,49 @@ describe("createToolIndex", () => {
5566
});
5667

5768
test("orchestrator mode advertises task and search_agents; single mode omits them", () => {
58-
expect(advertisedToolNamesForSessionMode("orchestrator")).toContain("task");
59-
expect(advertisedToolNamesForSessionMode("orchestrator")).toContain("search_agents");
60-
expect(advertisedToolNamesForSessionMode("single")).not.toContain("task");
61-
expect(advertisedToolNamesForSessionMode("single")).not.toContain("search_agents");
69+
expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("task");
70+
expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("search_agents");
71+
expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("task");
72+
expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("search_agents");
73+
});
74+
75+
test("manage_tasks is advertised in both session modes regardless of availability", () => {
76+
expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("manage_tasks");
77+
expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks");
78+
});
79+
80+
test("present is never in the advertised core set — discovered via tool_search only", () => {
81+
expect(CORE_TOOL_NAMES).not.toContain("present");
82+
expect(coreToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).not.toContain("present");
83+
});
84+
85+
test("manage_goal is advertised only when the session starts with a goal", () => {
86+
expect(
87+
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: true, languageServerAvailable: true }),
88+
).toContain("manage_goal");
89+
expect(
90+
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }),
91+
).not.toContain("manage_goal");
92+
});
93+
94+
test("lsp is advertised only when a language server was detected at startup", () => {
95+
expect(
96+
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }),
97+
).toContain("lsp");
98+
expect(
99+
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: false }),
100+
).not.toContain("lsp");
101+
});
102+
103+
test("ask_operator is advertised regardless of session mode or availability", () => {
104+
expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("ask_operator");
105+
expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("ask_operator");
106+
});
107+
108+
test("the advertised set is deterministic — repeat calls with the same inputs are identical", () => {
109+
const first = coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY);
110+
const second = coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY);
111+
expect(second).toEqual(first);
62112
});
63113

64114
test("returns nothing for an empty query", () => {
@@ -122,9 +172,11 @@ describe("advertisedTools", () => {
122172
];
123173

124174
test("single session mode omits multi-agent tools from the wire prefix", () => {
125-
const names = advertisedTools(registry, [], advertisedToolNamesForSessionMode("single")).map(
126-
(d) => d.name,
127-
);
175+
const names = advertisedTools(
176+
registry,
177+
[],
178+
advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY),
179+
).map((d) => d.name);
128180
expect(names).not.toContain("task");
129181
expect(names).not.toContain("search_agents");
130182
expect(names).toContain("read_file");
@@ -186,6 +238,23 @@ describe("advertisedTools", () => {
186238
expect(names.slice(tailIdx)).toEqual(["mcp__acme__do", "mcp__linear__create_issue"]);
187239
});
188240

241+
test("the built-in prefix is byte-identical across repeated turns of the same session", () => {
242+
// Session-start availability is computed once and must never be
243+
// re-evaluated per turn — simulate several turns by calling with the same
244+
// captured prefix and confirm the wire array never drifts.
245+
const prefix = advertisedToolNamesForSessionMode("orchestrator", {
246+
hasGoalAtLaunch: false,
247+
languageServerAvailable: true,
248+
});
249+
const turn1 = JSON.stringify(advertisedTools(registry, [], prefix));
250+
const turn2 = JSON.stringify(advertisedTools(registry, [], prefix));
251+
const turn3 = JSON.stringify(advertisedTools(registry, ["mcp__linear__create_issue"], prefix));
252+
expect(turn2).toBe(turn1);
253+
// Growth from a mid-session discovery only appends — the prefix itself
254+
// (everything before the activated tail) still matches turn 1 exactly.
255+
expect(turn3.startsWith(turn1.slice(0, -1))).toBe(true);
256+
});
257+
189258
test("tool_search never returns an already-advertised built-in", () => {
190259
for (const name of [...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES]) {
191260
expect(index.search(name)).not.toContain(name);

src/agent/tool-search.ts

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
1010
// registered and dispatchable but discovered on demand via tool_search, keeping
1111
// the per-turn context small. Shared by the system prompt and the advertised-set
1212
// gate so the two never drift.
13+
//
14+
// `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.
1318
export const CORE_TOOL_NAMES: readonly string[] = [
1419
"read_file",
1520
"edit_file",
@@ -18,7 +23,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [
1823
"ask_operator",
1924
"manage_tasks",
2025
"manage_goal",
21-
"present",
2226
"tool_search",
2327
"use_skill",
2428
"search_agents",
@@ -29,17 +33,44 @@ export const CORE_TOOL_NAMES: readonly string[] = [
2933
"task",
3034
];
3135

32-
const MULTI_AGENT_CORE_TOOL_NAMES: readonly string[] = ["search_agents", "task"];
36+
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"];
37+
38+
// Session-start facts that gate a core tool's advertisement. Each must be
39+
// knowable once, before the first inference call, and must never change for
40+
// the life of the session — the tools array is a provider cache prefix (see
41+
// ADVERTISED_TOOL_NAMES below), so a value that could flip mid-session (e.g.
42+
// "is a goal active right now") would force a re-prefill worse than the
43+
// schema bytes it saves. `manage_tasks` is intentionally NOT gated here: the
44+
// goal-kickoff sequence (see goalKickoffUserMessage in ./goal.ts) instructs
45+
// the model to call manage_goal then manage_tasks back to back, so hiding
46+
// manage_tasks would trade one tool_search round trip for two.
47+
export type ToolAvailability = {
48+
// Whether the session was resumed with an active/paused/budget-limited goal
49+
// already persisted — not whether one exists at the current instant.
50+
hasGoalAtLaunch: boolean;
51+
// Whether a language server was resolvable for this project at startup —
52+
// not whether one currently responds.
53+
languageServerAvailable: boolean;
54+
};
3355

34-
export function coreToolNamesForSessionMode(mode: SessionMode): readonly string[] {
35-
if (!sessionModeEnablesSubAgents(mode)) {
36-
return CORE_TOOL_NAMES.filter((name) => !MULTI_AGENT_CORE_TOOL_NAMES.includes(name));
37-
}
38-
return CORE_TOOL_NAMES;
56+
export function coreToolNamesForSessionMode(
57+
mode: SessionMode,
58+
availability: ToolAvailability,
59+
): readonly string[] {
60+
const orchestratorEnabled = sessionModeEnablesSubAgents(mode);
61+
return CORE_TOOL_NAMES.filter((name) => {
62+
if (!orchestratorEnabled && ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name)) return false;
63+
if (name === "manage_goal") return availability.hasGoalAtLaunch;
64+
if (name === "lsp") return availability.languageServerAvailable;
65+
return true;
66+
});
3967
}
4068

41-
export function advertisedToolNamesForSessionMode(mode: SessionMode): readonly string[] {
42-
return [...coreToolNamesForSessionMode(mode), ...CATALOG_TOOL_NAMES];
69+
export function advertisedToolNamesForSessionMode(
70+
mode: SessionMode,
71+
availability: ToolAvailability,
72+
): readonly string[] {
73+
return [...coreToolNamesForSessionMode(mode, availability), ...CATALOG_TOOL_NAMES];
4374
}
4475

4576
// Built-in file/search tools advertised alongside the core set. They carry full
@@ -52,13 +83,16 @@ export const CATALOG_TOOL_NAMES: readonly string[] = [
5283
"list_dir",
5384
];
5485

55-
// The complete set of built-in tools whose schemas are always on the wire, in a
56-
// deterministic order. Provider prompt caches are prefix caches keyed on the
57-
// tools array (it sits before system + messages), so this order must never shift
58-
// between turns — a reordered or grown array re-prefills the whole request.
86+
// The maximal set of built-in tools — every gate open — in a deterministic
87+
// order, used as the tool_search exclusion list and as a fallback prefix for
88+
// callers with no session-start availability facts. Provider prompt caches
89+
// are prefix caches keyed on the tools array (it sits before system +
90+
// messages), so this order must never shift between turns — a reordered or
91+
// grown array re-prefills the whole request.
5992
//
60-
// Primary TUI sessions should pass `advertisedToolNamesForSessionMode(sessionMode)`
61-
// as the `builtInPrefix` to `advertisedTools` — not this constant alone.
93+
// Primary TUI/exec sessions should pass
94+
// `advertisedToolNamesForSessionMode(sessionMode, toolAvailability)` as the
95+
// `builtInPrefix` to `advertisedTools` — not this constant alone.
6296
export const ADVERTISED_TOOL_NAMES: readonly string[] = [
6397
...CORE_TOOL_NAMES,
6498
...CATALOG_TOOL_NAMES,

src/agent/tools.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
import type { ToolWatchdogConfig } from "../tui/tool-execution-watchdog.js";
3434
import type { SessionMode } from "../config/session-mode.js";
3535
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
36-
import { advertisedToolNamesForSessionMode } from "./tool-search.js";
36+
import { advertisedToolNamesForSessionMode, type ToolAvailability } from "./tool-search.js";
3737
import type { ProviderCatalogEntry } from "../config/index.js";
3838
import type { AgentProfile } from "./profiles.js";
3939
import {
@@ -109,6 +109,11 @@ export type AgentToolsetArgs = {
109109
isWorkflowActive?: () => boolean;
110110
// Primary session mode: single-agent sessions omit sub-agent tooling.
111111
sessionMode?: SessionMode;
112+
// Session-start facts gating manage_goal/lsp advertisement. Omitted callers
113+
// (tests, ad-hoc toolset construction) get both advertised, matching prior
114+
// behavior. Real sessions always pass their detected values — see
115+
// tool-search.ts for why these must be fixed for the session's life.
116+
toolAvailability?: ToolAvailability;
112117
// When a goal governor is live, manage_goal mutates its acceptance checklist.
113118
getGoalGovernor?: () => GoalGovernor | null;
114119
// When provided, the agent gets a `task` tool that delegates to autonomous
@@ -176,11 +181,12 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
176181
getBlobReader,
177182
sessionMode = "orchestrator",
178183
shellEnv,
184+
toolAvailability = { hasGoalAtLaunch: true, languageServerAvailable: true },
179185
} = args;
180186
const sessionBlobReader =
181187
getBlobReader !== undefined ? createLazyBlobReader(getBlobReader) : undefined;
182188
const subAgentsEnabled = sessionModeEnablesSubAgents(sessionMode);
183-
const advertisedBuiltIns = advertisedToolNamesForSessionMode(sessionMode);
189+
const advertisedBuiltIns = advertisedToolNamesForSessionMode(sessionMode, toolAvailability);
184190

185191
const inheritedMcpTools: AgentTool[] = [];
186192

src/exec/runner.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import {
3737
advertisedToolNamesForSessionMode,
3838
advertisedTools,
3939
createActivatedToolTracker,
40+
type ToolAvailability,
4041
} from "../agent/tool-search.js";
42+
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
4143
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
4244
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
4345
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
@@ -304,6 +306,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
304306
const subAgentSessions = createSubAgentSessionStore();
305307
const shellTimeout = shellTimeoutFromSettings(config.settings);
306308
const toolWatchdog = toolWatchdogFromSettings(config.settings);
309+
// Exec has no goal governor (headless — no /goal), so a goal never starts
310+
// at launch here. lsp is still worth detecting: exec sessions read/edit
311+
// TypeScript projects same as the TUI.
312+
const toolAvailability: ToolAvailability = {
313+
hasGoalAtLaunch: false,
314+
languageServerAvailable: detectLanguageServerAvailable(config.cwd),
315+
};
307316

308317
let currentAgent: Agent | null = null;
309318

@@ -324,6 +333,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
324333
isWorkflowActive: () => false,
325334
onOperatorGate: (question, options) => promptOperator(question, options, interactive),
326335
sessionMode,
336+
toolAvailability,
327337
...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}),
328338
mcpServersSource: config.mcpServersSource ?? "none",
329339
projectTrust,
@@ -361,9 +371,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
361371
? { systemPromptExtensions: config.systemPromptExtensions }
362372
: {}),
363373
sessionMode,
374+
toolAvailability,
364375
});
365376

366-
const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(sessionMode);
377+
const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(sessionMode, toolAvailability);
367378
const activatedToolNames = createActivatedToolTracker();
368379
// Advertise then family-gate wire schemas (kimi gets a non-recursive present).
369380
const computeAdvertised = (all: readonly ToolDefinition[]): ToolDefinition[] =>

0 commit comments

Comments
 (0)