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
46 changes: 46 additions & 0 deletions src/agent/lsp-availability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, test, expect, afterEach } from "bun:test";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { detectLanguageServerAvailable } from "./lsp-availability.js";

const dirsToClean: string[] = [];

async function tempProject(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), "corbits-lsp-availability-"));
dirsToClean.push(dir);
return dir;
}

afterEach(async () => {
await Promise.all(dirsToClean.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

async function seedTsserver(dir: string): Promise<void> {
const tsserverDir = path.join(dir, "node_modules", "typescript", "lib");
await mkdir(tsserverDir, { recursive: true });
await writeFile(path.join(tsserverDir, "tsserver.js"), "");
}

describe("detectLanguageServerAvailable", () => {
test("false when typescript is not installed in the project", async () => {
const dir = await tempProject();
expect(detectLanguageServerAvailable(dir)).toBe(false);
});

test("true when tsserver is resolvable and a local .bin binary exists", async () => {
const dir = await tempProject();
await seedTsserver(dir);
const bin = path.join(dir, "node_modules", ".bin", "typescript-language-server");
await mkdir(path.dirname(bin), { recursive: true });
await writeFile(bin, "#!/usr/bin/env node\n");
expect(detectLanguageServerAvailable(dir)).toBe(true);
});

test("the real project checkout has a language server available", () => {
// This repo itself installs typescript and typescript-language-server as
// devDependencies, so detection against the actual cwd is a live check
// that the two-condition logic agrees with what createLSPPlugin would find.
expect(detectLanguageServerAvailable(process.cwd())).toBe(true);
});
});
15 changes: 15 additions & 0 deletions src/agent/lsp-availability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { existsSync } from "node:fs";
import path from "node:path";

// Mirrors the sole server @intx/tools-lsp registers today (Typescript):
// spawning requires a resolvable tsserver plus a reachable language-server
// binary. Checked once at session start via the filesystem and PATH — never
// by spawning a server — because the `lsp` tool's advertisement is baked into
// the wire tools array for the life of the session (see tool-search.ts).
export function detectLanguageServerAvailable(cwd: string): boolean {
const tsserverPath = path.join(cwd, "node_modules", "typescript", "lib", "tsserver.js");
if (!existsSync(tsserverPath)) return false;
const localBin = path.join(cwd, "node_modules", ".bin", "typescript-language-server");
if (existsSync(localBin)) return true;
return Bun.which("typescript-language-server") !== null;
}
17 changes: 15 additions & 2 deletions src/agent/prompts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import type { EnvironmentInfo } from "./environment.js";
import type { SkillSummary } from "../extensions/skills.js";
import type { SessionMode } from "../config/session-mode.js";
import { coreToolNamesForSessionMode, CORE_TOOL_NAMES } from "./tool-search.js";
import {
coreToolNamesForSessionMode,
CORE_TOOL_NAMES,
type ToolAvailability,
} from "./tool-search.js";

// Advertise every gated core tool when the caller has no session-start facts
// (tests, ad-hoc prompt previews). Real sessions always pass their detected
// availability — see tui/runner.ts and exec/runner.ts.
const DEFAULT_TOOL_AVAILABILITY: ToolAvailability = {
hasGoalAtLaunch: true,
languageServerAvailable: true,
};
import { PRODUCT_NAME, SETTINGS_DIR_NAME } from "../branding.js";

// Fallback tool list for sub-agent prompts when the caller does not pass the
Expand Down Expand Up @@ -295,10 +307,11 @@ export function buildChatSystemPrompt(
baseOverride?: string,
skills: readonly SkillSummary[] = [],
sessionMode: SessionMode = "orchestrator",
toolAvailability: ToolAvailability = DEFAULT_TOOL_AVAILABILITY,
): string {
const sections = [
baseSection(baseOverride, sessionMode),
buildAvailableTools(coreToolNamesForSessionMode(sessionMode)),
buildAvailableTools(coreToolNamesForSessionMode(sessionMode, toolAvailability)),
];
if (skills.length > 0) sections.push(buildSkillsSection(skills));
sections.push(contextSection(env));
Expand Down
83 changes: 76 additions & 7 deletions src/agent/tool-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,21 @@ import {
createActivatedToolTracker,
advertisedTools,
advertisedToolNamesForSessionMode,
coreToolNamesForSessionMode,
CORE_TOOL_NAMES,
CATALOG_TOOL_NAMES,
type ToolAvailability,
} from "./tool-search.js";

const FULL_AVAILABILITY: ToolAvailability = {
hasGoalAtLaunch: true,
languageServerAvailable: true,
};
const NO_AVAILABILITY: ToolAvailability = {
hasGoalAtLaunch: false,
languageServerAvailable: false,
};

const defs: ToolDefinition[] = [
{ name: "read_file", description: "read a file", inputSchema: { type: "object", properties: {}, required: [] } },
{ name: "web_search", description: "search the web for pages", inputSchema: { type: "object", properties: {}, required: [] } },
Expand Down Expand Up @@ -55,10 +66,49 @@ describe("createToolIndex", () => {
});

test("orchestrator mode advertises task and search_agents; single mode omits them", () => {
expect(advertisedToolNamesForSessionMode("orchestrator")).toContain("task");
expect(advertisedToolNamesForSessionMode("orchestrator")).toContain("search_agents");
expect(advertisedToolNamesForSessionMode("single")).not.toContain("task");
expect(advertisedToolNamesForSessionMode("single")).not.toContain("search_agents");
expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("task");
expect(advertisedToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).toContain("search_agents");
expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("task");
expect(advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY)).not.toContain("search_agents");
});

test("manage_tasks is advertised in both session modes regardless of availability", () => {
expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("manage_tasks");
expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("manage_tasks");
});

test("present is never in the advertised core set — discovered via tool_search only", () => {
expect(CORE_TOOL_NAMES).not.toContain("present");
expect(coreToolNamesForSessionMode("orchestrator", FULL_AVAILABILITY)).not.toContain("present");
});

test("manage_goal is advertised only when the session starts with a goal", () => {
expect(
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: true, languageServerAvailable: true }),
).toContain("manage_goal");
expect(
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }),
).not.toContain("manage_goal");
});

test("lsp is advertised only when a language server was detected at startup", () => {
expect(
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: true }),
).toContain("lsp");
expect(
coreToolNamesForSessionMode("orchestrator", { hasGoalAtLaunch: false, languageServerAvailable: false }),
).not.toContain("lsp");
});

test("ask_operator is advertised regardless of session mode or availability", () => {
expect(coreToolNamesForSessionMode("single", NO_AVAILABILITY)).toContain("ask_operator");
expect(coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY)).toContain("ask_operator");
});

test("the advertised set is deterministic — repeat calls with the same inputs are identical", () => {
const first = coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY);
const second = coreToolNamesForSessionMode("orchestrator", NO_AVAILABILITY);
expect(second).toEqual(first);
});

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

test("single session mode omits multi-agent tools from the wire prefix", () => {
const names = advertisedTools(registry, [], advertisedToolNamesForSessionMode("single")).map(
(d) => d.name,
);
const names = advertisedTools(
registry,
[],
advertisedToolNamesForSessionMode("single", FULL_AVAILABILITY),
).map((d) => d.name);
expect(names).not.toContain("task");
expect(names).not.toContain("search_agents");
expect(names).toContain("read_file");
Expand Down Expand Up @@ -186,6 +238,23 @@ describe("advertisedTools", () => {
expect(names.slice(tailIdx)).toEqual(["mcp__acme__do", "mcp__linear__create_issue"]);
});

test("the built-in prefix is byte-identical across repeated turns of the same session", () => {
// Session-start availability is computed once and must never be
// re-evaluated per turn — simulate several turns by calling with the same
// captured prefix and confirm the wire array never drifts.
const prefix = advertisedToolNamesForSessionMode("orchestrator", {
hasGoalAtLaunch: false,
languageServerAvailable: true,
});
const turn1 = JSON.stringify(advertisedTools(registry, [], prefix));
const turn2 = JSON.stringify(advertisedTools(registry, [], prefix));
const turn3 = JSON.stringify(advertisedTools(registry, ["mcp__linear__create_issue"], prefix));
expect(turn2).toBe(turn1);
// Growth from a mid-session discovery only appends — the prefix itself
// (everything before the activated tail) still matches turn 1 exactly.
expect(turn3.startsWith(turn1.slice(0, -1))).toBe(true);
});

test("tool_search never returns an already-advertised built-in", () => {
for (const name of [...CORE_TOOL_NAMES, ...CATALOG_TOOL_NAMES]) {
expect(index.search(name)).not.toContain(name);
Expand Down
64 changes: 49 additions & 15 deletions src/agent/tool-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
// 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.
//
// `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.
export const CORE_TOOL_NAMES: readonly string[] = [
"read_file",
"edit_file",
Expand All @@ -18,7 +23,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [
"ask_operator",
"manage_tasks",
"manage_goal",
"present",
"tool_search",
"use_skill",
"search_agents",
Expand All @@ -29,17 +33,44 @@ export const CORE_TOOL_NAMES: readonly string[] = [
"task",
];

const MULTI_AGENT_CORE_TOOL_NAMES: readonly string[] = ["search_agents", "task"];
const ORCHESTRATOR_ONLY_TOOL_NAMES: readonly string[] = ["search_agents", "task"];

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

export function coreToolNamesForSessionMode(mode: SessionMode): readonly string[] {
if (!sessionModeEnablesSubAgents(mode)) {
return CORE_TOOL_NAMES.filter((name) => !MULTI_AGENT_CORE_TOOL_NAMES.includes(name));
}
return CORE_TOOL_NAMES;
export function coreToolNamesForSessionMode(
mode: SessionMode,
availability: ToolAvailability,
): readonly string[] {
const orchestratorEnabled = sessionModeEnablesSubAgents(mode);
return CORE_TOOL_NAMES.filter((name) => {
if (!orchestratorEnabled && ORCHESTRATOR_ONLY_TOOL_NAMES.includes(name)) return false;
if (name === "manage_goal") return availability.hasGoalAtLaunch;
if (name === "lsp") return availability.languageServerAvailable;
return true;
});
}

export function advertisedToolNamesForSessionMode(mode: SessionMode): readonly string[] {
return [...coreToolNamesForSessionMode(mode), ...CATALOG_TOOL_NAMES];
export function advertisedToolNamesForSessionMode(
mode: SessionMode,
availability: ToolAvailability,
): readonly string[] {
return [...coreToolNamesForSessionMode(mode, availability), ...CATALOG_TOOL_NAMES];
}

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

// The complete set of built-in tools whose schemas are always on the wire, in a
// deterministic order. Provider prompt caches are prefix caches keyed on the
// tools array (it sits before system + messages), so this order must never shift
// between turns — a reordered or grown array re-prefills the whole request.
// The maximal set of built-in tools — every gate open — in a deterministic
// order, used as the tool_search exclusion list and as a fallback prefix for
// callers with no session-start availability facts. Provider prompt caches
// are prefix caches keyed on the tools array (it sits before system +
// messages), so this order must never shift between turns — a reordered or
// grown array re-prefills the whole request.
//
// Primary TUI sessions should pass `advertisedToolNamesForSessionMode(sessionMode)`
// as the `builtInPrefix` to `advertisedTools` — not this constant alone.
// Primary TUI/exec sessions should pass
// `advertisedToolNamesForSessionMode(sessionMode, toolAvailability)` as the
// `builtInPrefix` to `advertisedTools` — not this constant alone.
export const ADVERTISED_TOOL_NAMES: readonly string[] = [
...CORE_TOOL_NAMES,
...CATALOG_TOOL_NAMES,
Expand Down
10 changes: 8 additions & 2 deletions src/agent/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import type { ToolWatchdogConfig } from "../tui/tool-execution-watchdog.js";
import type { SessionMode } from "../config/session-mode.js";
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
import { advertisedToolNamesForSessionMode } from "./tool-search.js";
import { advertisedToolNamesForSessionMode, type ToolAvailability } from "./tool-search.js";
import type { ProviderCatalogEntry } from "../config/index.js";
import type { AgentProfile } from "./profiles.js";
import {
Expand Down Expand Up @@ -109,6 +109,11 @@ export type AgentToolsetArgs = {
isWorkflowActive?: () => boolean;
// Primary session mode: single-agent sessions omit sub-agent tooling.
sessionMode?: SessionMode;
// Session-start facts gating manage_goal/lsp advertisement. Omitted callers
// (tests, ad-hoc toolset construction) get both advertised, matching prior
// behavior. Real sessions always pass their detected values — see
// tool-search.ts for why these must be fixed for the session's life.
toolAvailability?: ToolAvailability;
// When a goal governor is live, manage_goal mutates its acceptance checklist.
getGoalGovernor?: () => GoalGovernor | null;
// When provided, the agent gets a `task` tool that delegates to autonomous
Expand Down Expand Up @@ -176,11 +181,12 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
getBlobReader,
sessionMode = "orchestrator",
shellEnv,
toolAvailability = { hasGoalAtLaunch: true, languageServerAvailable: true },
} = args;
const sessionBlobReader =
getBlobReader !== undefined ? createLazyBlobReader(getBlobReader) : undefined;
const subAgentsEnabled = sessionModeEnablesSubAgents(sessionMode);
const advertisedBuiltIns = advertisedToolNamesForSessionMode(sessionMode);
const advertisedBuiltIns = advertisedToolNamesForSessionMode(sessionMode, toolAvailability);

const inheritedMcpTools: AgentTool[] = [];

Expand Down
13 changes: 12 additions & 1 deletion src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ import {
advertisedToolNamesForSessionMode,
advertisedTools,
createActivatedToolTracker,
type ToolAvailability,
} from "../agent/tool-search.js";
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
Expand Down Expand Up @@ -304,6 +306,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
const subAgentSessions = createSubAgentSessionStore();
const shellTimeout = shellTimeoutFromSettings(config.settings);
const toolWatchdog = toolWatchdogFromSettings(config.settings);
// Exec has no goal governor (headless — no /goal), so a goal never starts
// at launch here. lsp is still worth detecting: exec sessions read/edit
// TypeScript projects same as the TUI.
const toolAvailability: ToolAvailability = {
hasGoalAtLaunch: false,
languageServerAvailable: detectLanguageServerAvailable(config.cwd),
};

let currentAgent: Agent | null = null;

Expand All @@ -324,6 +333,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
isWorkflowActive: () => false,
onOperatorGate: (question, options) => promptOperator(question, options, interactive),
sessionMode,
toolAvailability,
...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}),
mcpServersSource: config.mcpServersSource ?? "none",
projectTrust,
Expand Down Expand Up @@ -361,9 +371,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
? { systemPromptExtensions: config.systemPromptExtensions }
: {}),
sessionMode,
toolAvailability,
});

const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(sessionMode);
const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(sessionMode, toolAvailability);
const activatedToolNames = createActivatedToolTracker();
// Advertise then family-gate wire schemas (kimi gets a non-recursive present).
const computeAdvertised = (all: readonly ToolDefinition[]): ToolDefinition[] =>
Expand Down
Loading
Loading