diff --git a/src/agent/lsp-availability.test.ts b/src/agent/lsp-availability.test.ts new file mode 100644 index 000000000..015930398 --- /dev/null +++ b/src/agent/lsp-availability.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/src/agent/lsp-availability.ts b/src/agent/lsp-availability.ts new file mode 100644 index 000000000..108e29058 --- /dev/null +++ b/src/agent/lsp-availability.ts @@ -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; +} diff --git a/src/agent/prompts.ts b/src/agent/prompts.ts index 9861a2bbb..15426c982 100644 --- a/src/agent/prompts.ts +++ b/src/agent/prompts.ts @@ -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 @@ -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)); diff --git a/src/agent/tool-search.test.ts b/src/agent/tool-search.test.ts index c7d8db047..c3e98905b 100644 --- a/src/agent/tool-search.test.ts +++ b/src/agent/tool-search.test.ts @@ -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: [] } }, @@ -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", () => { @@ -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"); @@ -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); diff --git a/src/agent/tool-search.ts b/src/agent/tool-search.ts index df9844d56..5326949c9 100644 --- a/src/agent/tool-search.ts +++ b/src/agent/tool-search.ts @@ -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", @@ -18,7 +23,6 @@ export const CORE_TOOL_NAMES: readonly string[] = [ "ask_operator", "manage_tasks", "manage_goal", - "present", "tool_search", "use_skill", "search_agents", @@ -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 @@ -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, diff --git a/src/agent/tools.ts b/src/agent/tools.ts index ec0f0e19f..31462c5aa 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -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 { @@ -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 @@ -176,11 +181,12 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise { 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; @@ -324,6 +333,7 @@ export async function runExec(config: Config): Promise { isWorkflowActive: () => false, onOperatorGate: (question, options) => promptOperator(question, options, interactive), sessionMode, + toolAvailability, ...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}), mcpServersSource: config.mcpServersSource ?? "none", projectTrust, @@ -361,9 +371,10 @@ export async function runExec(config: Config): Promise { ? { 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[] => diff --git a/src/session/runtime-assembly.ts b/src/session/runtime-assembly.ts index 91eaf99f3..8780d7265 100644 --- a/src/session/runtime-assembly.ts +++ b/src/session/runtime-assembly.ts @@ -7,6 +7,7 @@ import type { ConversationTurn, InferenceSource } from "@intx/types/runtime"; import type { Compactor } from "@intx/types/runtime"; import { buildChatSystemPrompt } from "../agent/prompts.js"; +import type { ToolAvailability } from "../agent/tool-search.js"; import { gatherEnvironment } from "../agent/environment.js"; import { loadAgentContextExtensions, @@ -171,6 +172,7 @@ export type SessionChatPromptArgs = { skillDirs: readonly string[]; systemPromptExtensions?: readonly string[]; sessionMode: SessionMode; + toolAvailability: ToolAvailability; }; export type SessionChatPrompt = { @@ -200,6 +202,7 @@ export async function loadSessionChatPrompt( overrides.base, skills, args.sessionMode, + args.toolAvailability, ), skills, }; diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 0ea1ce0a9..cf76dd692 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -97,7 +97,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 { promptSessionModeIfUnset } from "./session-mode-prompt.js"; @@ -978,7 +980,16 @@ export async function runTUI(initialConfig: Config): Promise { if (refreshed !== null) config = { ...config, settings: refreshed }; } } - const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(liveSessionMode); + // Loaded once, here, so both the wire tools array and the goal governor's + // restore (below) agree on the same snapshot — the file is only ever + // written for an active/paused/budget-limited goal, so non-null means the + // session starts with one. + const persistedGoalAtLaunch = await loadGoalState(config.cwd, sessionId); + const toolAvailability: ToolAvailability = { + hasGoalAtLaunch: persistedGoalAtLaunch !== null, + languageServerAvailable: detectLanguageServerAvailable(config.cwd), + }; + const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(liveSessionMode, toolAvailability); // The workflow controller is built below, after the toolset; the holder lets // advance_workflow's handler read live workflow-active state without a // construction-order cycle. @@ -1004,6 +1015,7 @@ export async function runTUI(initialConfig: Config): Promise { emitter.emit("operator.gate", event); }), sessionMode: liveSessionMode, + toolAvailability, ...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}), mcpServersSource: config.mcpServersSource ?? "none", projectTrust, @@ -1051,6 +1063,7 @@ export async function runTUI(initialConfig: Config): Promise { ? { systemPromptExtensions: config.systemPromptExtensions } : {}), sessionMode: liveSessionMode, + toolAvailability, }); const directorHolder: { instance?: ReturnType } = {}; @@ -1250,11 +1263,8 @@ export async function runTUI(initialConfig: Config): Promise { goalGovernorRef.current = goalGovernor; // Resume restores condition as paused so autonomy is never silently re-armed. - { - const persistedGoal = await loadGoalState(config.cwd, sessionId); - if (persistedGoal !== null) { - goalGovernor.restore(persistedGoal); - } + if (persistedGoalAtLaunch !== null) { + goalGovernor.restore(persistedGoalAtLaunch); } // Compaction summarizer: produces a structured, workflow-aware handoff via a