Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/common/constants/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const EXPERIMENT_IDS = {
PROGRAMMATIC_TOOL_CALLING_EXCLUSIVE: "programmatic-tool-calling-exclusive",
CONFIGURABLE_BIND_URL: "configurable-bind-url",
SYSTEM_1: "system-1",
LSP_QUERY: "lsp-query",
EXEC_SUBAGENT_HARD_RESTART: "exec-subagent-hard-restart",
MUX_GOVERNOR: "mux-governor",
MULTI_PROJECT_WORKSPACES: "multi-project-workspaces",
Expand Down Expand Up @@ -81,6 +82,15 @@ export const EXPERIMENTS: Record<ExperimentId, ExperimentDefinition> = {
userOverridable: true,
showInSettings: true,
},
[EXPERIMENT_IDS.LSP_QUERY]: {
id: EXPERIMENT_IDS.LSP_QUERY,
name: "LSP Query Tool",
description:
"Enable the built-in lsp_query tool for definitions, references, hover, and symbol lookup",
enabledByDefault: false,
userOverridable: true,
showInSettings: true,
},
[EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART]: {
id: EXPERIMENT_IDS.EXEC_SUBAGENT_HARD_RESTART,
name: "Exec sub-agent hard restart",
Expand Down
4 changes: 4 additions & 0 deletions src/common/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
MuxAgentsReadToolResultSchema,
MuxAgentsWriteToolResultSchema,
FileReadToolResultSchema,
LspQueryToolResultSchema,
AttachFileToolResultSchema,
TaskToolResultSchema,
TaskAwaitToolResultSchema,
Expand Down Expand Up @@ -127,6 +128,9 @@ export interface ToolOutputUiOnlyFields {
// FileReadToolResult derived from Zod schema (single source of truth)
export type FileReadToolResult = z.infer<typeof FileReadToolResultSchema>;

export type LspQueryToolArgs = z.infer<typeof TOOL_DEFINITIONS.lsp_query.schema>;
export type LspQueryToolResult = z.infer<typeof LspQueryToolResultSchema>;

// AttachFileToolResult derived from Zod schema (single source of truth)
export type AttachFileToolResult = z.infer<typeof AttachFileToolResultSchema>;

Expand Down
2 changes: 2 additions & 0 deletions src/common/utils/tools/toolAvailability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface ToolAvailabilityContext {
workspaceId: string;
parentWorkspaceId?: string | null;
enableLspQuery?: boolean;
}

/**
Expand All @@ -10,6 +11,7 @@ export interface ToolAvailabilityContext {
export function getToolAvailabilityOptions(context: ToolAvailabilityContext) {
return {
enableAgentReport: Boolean(context.parentWorkspaceId),
enableLspQuery: context.enableLspQuery === true,
// skills_catalog_* tools are always available; agent tool policy controls access.
} as const;
}
113 changes: 113 additions & 0 deletions src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,51 @@ export const TOOL_DEFINITIONS = {
})
),
},
lsp_query: {
description:
"Query the built-in language server for code intelligence. " +
"Use this for hover, definitions, references, implementations, and symbol lookup. " +
"Provide line and column using 1-based positions for hover/definition/reference/implementation. " +
"For workspace_symbols, provide a representative file path to select the correct language server plus a non-empty query.",
schema: z.preprocess(
normalizeFilePath,
z
.object({
operation: z.enum([
"hover",
"definition",
"references",
"implementation",
"document_symbols",
"workspace_symbols",
]),
path: FILE_TOOL_PATH.describe(
"Path to the file being queried (absolute or relative to the current workspace)"
),
line: z
.number()
.int()
.positive()
.nullish()
.describe("1-based line number for position-based queries"),
column: z
.number()
.int()
.positive()
.nullish()
.describe("1-based column number for position-based queries"),
query: z
.string()
.nullish()
.describe("Required for workspace_symbols; ignored by other operations"),
includeDeclaration: z
.boolean()
.nullish()
.describe("For references only: whether declarations should be included"),
})
.strict()
),
},
attach_file: {
description:
"Attach a supported file from the filesystem so later model steps receive it as a real attachment instead of a huge base64 JSON blob. " +
Expand Down Expand Up @@ -1840,6 +1885,69 @@ export const FileReadToolResultSchema = z.union([
}),
]);

const LspQueryResultRangeSchema = z
.object({
start: z.object({
line: z.number().int().positive(),
character: z.number().int().positive(),
}),
end: z.object({
line: z.number().int().positive(),
character: z.number().int().positive(),
}),
})
.strict();

const LspQueryLocationSchema = z
.object({
path: z.string(),
uri: z.string(),
range: LspQueryResultRangeSchema,
preview: z.string().optional(),
})
.strict();

const LspQuerySymbolSchema = z
.object({
name: z.string(),
kind: z.number().int(),
detail: z.string().optional(),
containerName: z.string().optional(),
path: z.string(),
range: LspQueryResultRangeSchema,
preview: z.string().optional(),
})
.strict();

export const LspQueryToolResultSchema = z.union([
z
.object({
success: z.literal(true),
operation: z.enum([
"hover",
"definition",
"references",
"implementation",
"document_symbols",
"workspace_symbols",
]),
serverId: z.string(),
rootUri: z.string(),
hover: z.string().optional(),
locations: z.array(LspQueryLocationSchema).optional(),
symbols: z.array(LspQuerySymbolSchema).optional(),
warning: z.string().optional(),
})
.strict(),
z
.object({
success: z.literal(false),
error: z.string(),
warning: z.string().optional(),
})
.strict(),
]);

const AttachFileToolTextPartSchema = z
.object({
type: z.literal("text"),
Expand Down Expand Up @@ -1963,6 +2071,7 @@ export type BridgeableToolName =
| "bash_background_list"
| "bash_background_terminate"
| "file_read"
| "lsp_query"
| "attach_file"
| "agent_skill_read"
| "agent_skill_read_file"
Expand Down Expand Up @@ -1990,6 +2099,7 @@ export const RESULT_SCHEMAS: Record<BridgeableToolName, z.ZodType> = {
bash_background_list: BashBackgroundListResultSchema,
bash_background_terminate: BashBackgroundTerminateResultSchema,
file_read: FileReadToolResultSchema,
lsp_query: LspQueryToolResultSchema,
attach_file: AttachFileToolResultSchema,
agent_skill_read: AgentSkillReadToolResultSchema,
agent_skill_read_file: AgentSkillReadFileToolResultSchema,
Expand Down Expand Up @@ -2033,13 +2143,15 @@ export function getAvailableTools(
options?: {
enableAgentReport?: boolean;
enableAnalyticsQuery?: boolean;
enableLspQuery?: boolean;
/** @deprecated Mux global tools are always included. */
enableMuxGlobalAgentsTools?: boolean;
}
): string[] {
const [provider] = modelString.split(":");
const enableAgentReport = options?.enableAgentReport ?? true;
const enableAnalyticsQuery = options?.enableAnalyticsQuery ?? true;
const enableLspQuery = options?.enableLspQuery ?? false;

// Base tools available for all models
// Note: Tool availability is controlled by agent tool policy (allowlist), not mode checks here.
Expand All @@ -2066,6 +2178,7 @@ export function getAvailableTools(
"agent_skill_read",
"agent_skill_read_file",
"file_edit_replace_string",
...(enableLspQuery ? ["lsp_query"] : []),
// "file_edit_replace_lines", // DISABLED: causes models to break repo state
"file_edit_insert",
"ask_user_question",
Expand Down
46 changes: 46 additions & 0 deletions src/common/utils/tools/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, mock, test } from "bun:test";
import type { InitStateManager } from "@/node/services/initStateManager";
import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager";
import { LocalRuntime } from "@/node/runtime/LocalRuntime";
import { LspManager } from "@/node/services/lsp/lspManager";
import { getToolsForModel } from "./tools";

const DESKTOP_TOOL_NAMES = [
Expand Down Expand Up @@ -156,4 +157,49 @@ describe("getToolsForModel", () => {

expect(Object.keys(tools).filter((toolName) => toolName.startsWith("desktop_"))).toEqual([]);
});

test("only includes lsp_query when the experiment is enabled and a manager is available", async () => {
const runtime = new LocalRuntime(process.cwd());
const initStateManager = createInitStateManager();
const lspManager = new LspManager({ registry: [] });
lspManager.query = mock(() =>
Promise.resolve({
operation: "hover" as const,
serverId: "typescript",
rootUri: "file:///tmp/workspace",
hover: "",
})
);

try {
const toolsWithoutLsp = await getToolsForModel(
"noop:model",
{
cwd: process.cwd(),
runtime,
runtimeTempDir: "/tmp",
lspQueryEnabled: false,
},
"ws-1",
initStateManager
);
expect(toolsWithoutLsp.lsp_query).toBeUndefined();

const toolsWithLsp = await getToolsForModel(
"noop:model",
{
cwd: process.cwd(),
runtime,
runtimeTempDir: "/tmp",
lspManager,
lspQueryEnabled: true,
},
"ws-1",
initStateManager
);
expect(toolsWithLsp.lsp_query).toBeDefined();
} finally {
await lspManager.dispose();
}
});
});
12 changes: 12 additions & 0 deletions src/common/utils/tools/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createTodoWriteTool, createTodoReadTool } from "@/node/services/tools/t
import { createNotifyTool } from "@/node/services/tools/notify";
import { createAnalyticsQueryTool } from "@/node/services/tools/analyticsQuery";
import { createDesktopTools } from "@/node/services/tools/desktopTools";
import { createLspQueryTool } from "@/node/services/tools/lsp_query";
import type { MuxToolScope } from "@/common/types/toolScope";
import { createTaskTool } from "@/node/services/tools/task";
import { createTaskApplyGitPatchTool } from "@/node/services/tools/task_apply_git_patch";
Expand Down Expand Up @@ -54,6 +55,7 @@ import type { FileState } from "@/node/services/agentSession";
import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition";
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
import type { ProjectRef } from "@/common/types/workspace";
import type { LspManager } from "@/node/services/lsp/lspManager";

/**
* Configuration for tools that need runtime context
Expand Down Expand Up @@ -120,6 +122,10 @@ export interface ToolConfiguration {
};
/** Desktop session manager for desktop automation tools */
desktopSessionManager?: DesktopSessionManager;
/** Shared workspace-scoped LSP manager for built-in query tooling */
lspManager?: LspManager;
/** Whether the experiment-gated lsp_query tool should be exposed for this request */
lspQueryEnabled?: boolean;
}

/**
Expand Down Expand Up @@ -346,6 +352,11 @@ export async function getToolsForModel(
agent_skill_read_file: wrap(createAgentSkillReadFileTool(config)),
file_edit_replace_string: wrap(createFileEditReplaceStringTool(config)),
file_edit_insert: wrap(createFileEditInsertTool(config)),
...(config.lspManager && config.lspQueryEnabled
? {
lsp_query: wrap(createLspQueryTool(config)),
}
: {}),
// DISABLED: file_edit_replace_lines - causes models (particularly GPT-5-Codex)
// to leave repository in broken state due to issues with concurrent file modifications
// and line number miscalculations. Use file_edit_replace_string instead.
Expand Down Expand Up @@ -492,6 +503,7 @@ export async function getToolsForModel(
getAvailableTools(modelString, {
enableAgentReport: config.enableAgentReport,
enableAnalyticsQuery: Boolean(config.analyticsService),
enableLspQuery: config.lspManager != null && config.lspQueryEnabled === true,
// Mux global tools are always created; tool policy (agent frontmatter)
// controls which agents can actually use them.
enableMuxGlobalAgentsTools: true,
Expand Down
7 changes: 7 additions & 0 deletions src/constants/lsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const LSP_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
export const LSP_IDLE_CHECK_INTERVAL_MS = 60 * 1000;
export const LSP_REQUEST_TIMEOUT_MS = 10 * 1000;
export const LSP_START_TIMEOUT_MS = 5 * 1000;
export const LSP_MAX_LOCATIONS = 25;
export const LSP_MAX_SYMBOLS = 100;
export const LSP_PREVIEW_CONTEXT_LINES = 1;
15 changes: 14 additions & 1 deletion src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import {
import { applyToolPolicyAndExperiments, captureMcpToolTelemetry } from "./toolAssembly";
import { getErrorMessage } from "@/common/utils/errors";
import { isProjectTrusted } from "@/node/utils/projectTrust";
import type { LspManager } from "@/node/services/lsp/lspManager";

const STREAM_STARTUP_DIAGNOSTIC_THRESHOLD_MS = 1_000;

Expand Down Expand Up @@ -211,6 +212,7 @@ export class AIService extends EventEmitter {
private readonly config: Config;
private readonly workspaceMcpOverridesService: WorkspaceMcpOverridesService;
private mcpServerManager?: MCPServerManager;
private lspManager?: LspManager;
private readonly policyService?: PolicyService;
private readonly telemetryService?: TelemetryService;
private readonly opResolver?: ExternalSecretResolver;
Expand Down Expand Up @@ -312,6 +314,10 @@ export class AIService extends EventEmitter {
this.streamManager.setMCPServerManager(manager);
}

setLspManager(manager: LspManager): void {
this.lspManager = manager;
}

setTaskService(taskService: TaskService): void {
this.taskService = taskService;
}
Expand Down Expand Up @@ -1106,6 +1112,8 @@ export class AIService extends EventEmitter {
};

const desktopSessionManager = this.desktopSessionManager;
const lspQueryEnabled =
this.experimentsService?.isExperimentEnabled(EXPERIMENT_IDS.LSP_QUERY) ?? false;
let desktopCapabilityPromise: ReturnType<DesktopSessionManager["getCapability"]> | undefined;
const loadDesktopCapability =
desktopSessionManager == null
Expand Down Expand Up @@ -1204,7 +1212,10 @@ export class AIService extends EventEmitter {
runtime,
workspacePath,
modelString,
agentSystemPrompt
agentSystemPrompt,
{
enableLspQuery: lspQueryEnabled,
}
);
recordStartupPhaseTiming("readToolInstructionsMs", readToolInstructionsStartedAt);

Expand Down Expand Up @@ -1273,6 +1284,8 @@ export class AIService extends EventEmitter {
taskService: this.taskService,
analyticsService: this.analyticsService,
desktopSessionManager: this.desktopSessionManager,
lspManager: this.lspManager,
lspQueryEnabled,
// PTC experiments for inheritance to subagents
experiments,
// Dynamic context for tool descriptions (moved from system prompt for better model attention)
Expand Down
Loading