From a271c5df6d55a2c08a106276a95542d8ad982f94 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 28 Feb 2026 18:32:04 -0800 Subject: [PATCH 01/43] wip --- .env.development | 2 + packages/shared/src/logger.ts | 9 +- packages/web/src/features/chat/agent.ts | 5 +- .../tools/readFilesToolComponent.tsx | 2 +- packages/web/src/features/chat/logger.ts | 3 + packages/web/src/features/chat/tools.ts | 311 ------------------ .../chat/tools/findSymbolDefinitionsTool.ts | 47 +++ .../chat/tools/findSymbolReferencesTool.ts | 47 +++ packages/web/src/features/chat/tools/index.ts | 16 + .../features/chat/tools/listCommitsTool.ts | 49 +++ .../src/features/chat/tools/listReposTool.ts | 27 ++ .../src/features/chat/tools/readFilesTool.ts | 69 ++++ .../src/features/chat/tools/searchCodeTool.ts | 120 +++++++ 13 files changed, 387 insertions(+), 320 deletions(-) create mode 100644 packages/web/src/features/chat/logger.ts delete mode 100644 packages/web/src/features/chat/tools.ts create mode 100644 packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts create mode 100644 packages/web/src/features/chat/tools/findSymbolReferencesTool.ts create mode 100644 packages/web/src/features/chat/tools/index.ts create mode 100644 packages/web/src/features/chat/tools/listCommitsTool.ts create mode 100644 packages/web/src/features/chat/tools/listReposTool.ts create mode 100644 packages/web/src/features/chat/tools/readFilesTool.ts create mode 100644 packages/web/src/features/chat/tools/searchCodeTool.ts diff --git a/.env.development b/.env.development index 02e961bab..b86e25e8a 100644 --- a/.env.development +++ b/.env.development @@ -76,3 +76,5 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection # CONFIG_MAX_REPOS_NO_TOKEN= NODE_ENV=development # SOURCEBOT_TENANCY_MODE=single + +DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true \ No newline at end of file diff --git a/packages/shared/src/logger.ts b/packages/shared/src/logger.ts index a3f89e2cc..b142cb07c 100644 --- a/packages/shared/src/logger.ts +++ b/packages/shared/src/logger.ts @@ -32,12 +32,11 @@ const datadogFormat = format((info) => { return info; }); -const humanReadableFormat = printf(({ level, message, timestamp, stack, label: _label }) => { +const humanReadableFormat = printf(({ level, message, timestamp, stack, label: _label, ...rest }) => { const label = `[${_label}] `; - if (stack) { - return `${timestamp} ${level}: ${label}${message}\n${stack}`; - } - return `${timestamp} ${level}: ${label}${message}`; + const extras = Object.keys(rest).length > 0 ? ` ${JSON.stringify(rest)}` : ''; + const base = `${timestamp} ${level}: ${label}${message}${extras}`; + return stack ? `${base}\n${stack}` : base; }); const createLogger = (label: string) => { diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index ec2a30758..f50a37e79 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -2,18 +2,17 @@ import { getFileSource } from '@/features/git'; import { isServiceError } from "@/lib/utils"; import { captureEvent } from "@/lib/posthog"; import { ProviderOptions } from "@ai-sdk/provider-utils"; -import { createLogger, env } from "@sourcebot/shared"; +import { env } from "@sourcebot/shared"; import { LanguageModel, ModelMessage, StopCondition, streamText } from "ai"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants"; import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, listReposTool, listCommitsTool, readFilesTool } from "./tools"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import _dedent from "dedent"; +import { logger } from "./logger"; const dedent = _dedent.withOptions({ alignValues: true }); -const logger = createLogger('chat-agent'); - interface AgentOptions { model: LanguageModel; providerOptions?: ProviderOptions; diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx index a31ae75b4..e9f4cc74b 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx @@ -16,7 +16,7 @@ export const ReadFilesToolComponent = ({ part }: { part: ReadFilesToolUIPart }) case 'input-streaming': return 'Reading...'; case 'input-available': - return `Reading ${part.input.paths.length} file${part.input.paths.length === 1 ? '' : 's'}...`; + return `Reading ${part.input.files.length} file${part.input.files.length === 1 ? '' : 's'}...`; case 'output-error': return 'Tool call failed'; case 'output-available': diff --git a/packages/web/src/features/chat/logger.ts b/packages/web/src/features/chat/logger.ts new file mode 100644 index 000000000..bbd1b7001 --- /dev/null +++ b/packages/web/src/features/chat/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@sourcebot/shared"; + +export const logger = createLogger('ask-agent'); \ No newline at end of file diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts deleted file mode 100644 index 87a251214..000000000 --- a/packages/web/src/features/chat/tools.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { z } from "zod" -import { search } from "@/features/search" -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { FileSourceResponse, getFileSource, listCommits } from '@/features/git'; -import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "../codeNav/api"; -import { addLineNumbers } from "./utils"; -import { toolNames } from "./constants"; -import { listReposQueryParamsSchema } from "@/lib/schemas"; -import { ListReposQueryParams } from "@/lib/types"; -import { listRepos } from "@/app/api/(server)/repos/listReposApi"; -import escapeStringRegexp from "escape-string-regexp"; - -// @NOTE: When adding a new tool, follow these steps: -// 1. Add the tool to the `toolNames` constant in `constants.ts`. -// 2. Add the tool to the `SBChatMessageToolTypes` type in `types.ts`. -// 3. Add the tool to the `tools` prop in `agent.ts`. -// 4. If the tool is meant to be rendered in the UI: -// - Add the tool to the `uiVisiblePartTypes` constant in `constants.ts`. -// - Add the tool's component to the `DetailsCard` switch statement in `detailsCard.tsx`. -// -// - bk, 2025-07-25 - - -export const findSymbolReferencesTool = tool({ - description: `Finds references to a symbol in the codebase.`, - inputSchema: z.object({ - symbol: z.string().describe("The symbol to find references to"), - language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), - }), - execute: async ({ symbol, language, repository }) => { - // @todo: make revision configurable. - const revision = "HEAD"; - - const response = await findSearchBasedSymbolReferences({ - symbolName: symbol, - language, - revisionName: "HEAD", - repoName: repository, - }); - - if (isServiceError(response)) { - return response; - } - - return response.files.map((file) => ({ - fileName: file.fileName, - repository: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), - revision, - })); - }, -}); - -export type FindSymbolReferencesTool = InferUITool; -export type FindSymbolReferencesToolInput = InferToolInput; -export type FindSymbolReferencesToolOutput = InferToolOutput; -export type FindSymbolReferencesToolUIPart = ToolUIPart<{ [toolNames.findSymbolReferences]: FindSymbolReferencesTool }> - -export const findSymbolDefinitionsTool = tool({ - description: `Finds definitions of a symbol in the codebase.`, - inputSchema: z.object({ - symbol: z.string().describe("The symbol to find definitions of"), - language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), - }), - execute: async ({ symbol, language, repository }) => { - // @todo: make revision configurable. - const revision = "HEAD"; - - const response = await findSearchBasedSymbolDefinitions({ - symbolName: symbol, - language, - revisionName: revision, - repoName: repository, - }); - - if (isServiceError(response)) { - return response; - } - - return response.files.map((file) => ({ - fileName: file.fileName, - repository: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), - revision, - })); - } -}); - -export type FindSymbolDefinitionsTool = InferUITool; -export type FindSymbolDefinitionsToolInput = InferToolInput; -export type FindSymbolDefinitionsToolOutput = InferToolOutput; -export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool }> - -export const readFilesTool = tool({ - description: `Reads the contents of multiple files at the given paths.`, - inputSchema: z.object({ - paths: z.array(z.string()).describe("The paths to the files to read"), - repository: z.string().describe("The repository to read the files from"), - }), - execute: async ({ paths, repository }) => { - // @todo: make revision configurable. - const revision = "HEAD"; - - const responses = await Promise.all(paths.map(async (path) => { - return getFileSource({ - path, - repo: repository, - ref: revision, - }); - })); - - if (responses.some(isServiceError)) { - const firstError = responses.find(isServiceError); - return firstError!; - } - - return (responses as FileSourceResponse[]).map((response) => ({ - path: response.path, - repository: response.repo, - language: response.language, - source: addLineNumbers(response.source), - revision, - })); - } -}); - -export type ReadFilesTool = InferUITool; -export type ReadFilesToolInput = InferToolInput; -export type ReadFilesToolOutput = InferToolOutput; -export type ReadFilesToolUIPart = ToolUIPart<{ [toolNames.readFiles]: ReadFilesTool }> - -const DEFAULT_SEARCH_LIMIT = 100; - -export const createCodeSearchTool = (selectedRepos: string[]) => tool({ - description: `Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the \`listRepos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches.`, - inputSchema: z.object({ - query: z - .string() - .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) - // Escape backslashes first, then quotes, and wrap in double quotes - // so the query is treated as a literal phrase (like grep). - .transform((val) => { - const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; - }), - useRegex: z - .boolean() - .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) - .optional(), - filterByRepos: z - .array(z.string()) - .describe(`Scope the search to the provided repositories.`) - .optional(), - filterByLanguages: z - .array(z.string()) - .describe(`Scope the search to the provided languages.`) - .optional(), - filterByFilepaths: z - .array(z.string()) - .describe(`Scope the search to the provided filepaths.`) - .optional(), - caseSensitive: z - .boolean() - .describe(`Whether the search should be case sensitive (default: false).`) - .optional(), - ref: z - .string() - .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) - .optional(), - limit: z - .number() - .default(DEFAULT_SEARCH_LIMIT) - .describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) - .optional(), - }), - execute: async ({ - query, - useRegex = false, - filterByRepos: repos = [], - filterByLanguages: languages = [], - filterByFilepaths: filepaths = [], - caseSensitive = false, - ref, - limit = DEFAULT_SEARCH_LIMIT, - }) => { - - if (selectedRepos.length > 0) { - query += ` reposet:${selectedRepos.join(',')}`; - } - - if (repos.length > 0) { - query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; - } - - if (languages.length > 0) { - query += ` (lang:${languages.join(' or lang:')})`; - } - - if (filepaths.length > 0) { - query += ` (file:${filepaths.map(filepath => escapeStringRegexp(filepath)).join(' or file:')})`; - } - - if (ref) { - query += ` (rev:${ref})`; - } - - const response = await search({ - queryType: 'string', - query, - options: { - matches: limit, - contextLines: 3, - isCaseSensitivityEnabled: caseSensitive, - isRegexEnabled: useRegex, - } - }); - - if (isServiceError(response)) { - return response; - } - - return { - files: response.files.map((file) => ({ - fileName: file.fileName.text, - repository: file.repository, - language: file.language, - matches: file.chunks.map(({ content, contentStart }) => { - return addLineNumbers(content, contentStart.lineNumber); - }), - // @todo: make revision configurable. - revision: 'HEAD', - })), - query, - } - }, -}); - -export type SearchCodeTool = InferUITool>; -export type SearchCodeToolInput = InferToolInput>; -export type SearchCodeToolOutput = InferToolOutput>; -export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>; - -export const listReposTool = tool({ - description: 'Lists repositories in the organization with optional filtering and pagination.', - inputSchema: listReposQueryParamsSchema, - execute: async (request: ListReposQueryParams) => { - const reposResponse = await listRepos(request); - - if (isServiceError(reposResponse)) { - return reposResponse; - } - - return reposResponse.data.map((repo) => repo.repoName); - } -}); - -export type ListReposTool = InferUITool; -export type ListReposToolInput = InferToolInput; -export type ListReposToolOutput = InferToolOutput; -export type ListReposToolUIPart = ToolUIPart<{ [toolNames.listRepos]: ListReposTool }>; - -export const listCommitsTool = tool({ - description: 'Lists commits in a repository with optional filtering by date range, author, and commit message.', - inputSchema: z.object({ - repository: z.string().describe("The repository to list commits from"), - query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), - since: z.string().describe("Start date for commit range (e.g., '30 days ago', '2024-01-01', 'last week')").optional(), - until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), - author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), - maxCount: z.number().describe("Maximum number of commits to return (default: 50)").optional(), - }), - execute: async ({ repository, query, since, until, author, maxCount }) => { - const response = await listCommits({ - repo: repository, - query, - since, - until, - author, - maxCount, - }); - - if (isServiceError(response)) { - return response; - } - - return { - commits: response.commits.map((commit) => ({ - hash: commit.hash, - date: commit.date, - message: commit.message, - author: `${commit.author_name} <${commit.author_email}>`, - refs: commit.refs, - })), - totalCount: response.totalCount, - }; - } -}); - -export type ListCommitsTool = InferUITool; -export type ListCommitsToolInput = InferToolInput; -export type ListCommitsToolOutput = InferToolOutput; -export type ListCommitsToolUIPart = ToolUIPart<{ [toolNames.listCommits]: ListCommitsTool }>; diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts b/packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts new file mode 100644 index 000000000..d71c7c2de --- /dev/null +++ b/packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { findSearchBasedSymbolDefinitions } from "../../codeNav/api"; +import { addLineNumbers } from "../utils"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; + +export const findSymbolDefinitionsTool = tool({ + description: `Finds definitions of a symbol in the codebase.`, + inputSchema: z.object({ + symbol: z.string().describe("The symbol to find definitions of"), + language: z.string().describe("The programming language of the symbol"), + repository: z.string().describe("The repository to scope the search to").optional(), + }), + execute: async ({ symbol, language, repository }) => { + logger.debug('findSymbolDefinitions', { symbol, language, repository }); + // @todo: make revision configurable. + const revision = "HEAD"; + + const response = await findSearchBasedSymbolDefinitions({ + symbolName: symbol, + language, + revisionName: revision, + repoName: repository, + }); + + if (isServiceError(response)) { + return response; + } + + return response.files.map((file) => ({ + fileName: file.fileName, + repository: file.repository, + language: file.language, + matches: file.matches.map(({ lineContent, range }) => { + return addLineNumbers(lineContent, range.start.lineNumber); + }), + revision, + })); + } +}); + +export type FindSymbolDefinitionsTool = InferUITool; +export type FindSymbolDefinitionsToolInput = InferToolInput; +export type FindSymbolDefinitionsToolOutput = InferToolOutput; +export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool }> diff --git a/packages/web/src/features/chat/tools/findSymbolReferencesTool.ts b/packages/web/src/features/chat/tools/findSymbolReferencesTool.ts new file mode 100644 index 000000000..b12e1568a --- /dev/null +++ b/packages/web/src/features/chat/tools/findSymbolReferencesTool.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { findSearchBasedSymbolReferences } from "../../codeNav/api"; +import { addLineNumbers } from "../utils"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; + +export const findSymbolReferencesTool = tool({ + description: `Finds references to a symbol in the codebase.`, + inputSchema: z.object({ + symbol: z.string().describe("The symbol to find references to"), + language: z.string().describe("The programming language of the symbol"), + repository: z.string().describe("The repository to scope the search to").optional(), + }), + execute: async ({ symbol, language, repository }) => { + logger.debug('findSymbolReferences', { symbol, language, repository }); + // @todo: make revision configurable. + const revision = "HEAD"; + + const response = await findSearchBasedSymbolReferences({ + symbolName: symbol, + language, + revisionName: "HEAD", + repoName: repository, + }); + + if (isServiceError(response)) { + return response; + } + + return response.files.map((file) => ({ + fileName: file.fileName, + repository: file.repository, + language: file.language, + matches: file.matches.map(({ lineContent, range }) => { + return addLineNumbers(lineContent, range.start.lineNumber); + }), + revision, + })); + }, +}); + +export type FindSymbolReferencesTool = InferUITool; +export type FindSymbolReferencesToolInput = InferToolInput; +export type FindSymbolReferencesToolOutput = InferToolOutput; +export type FindSymbolReferencesToolUIPart = ToolUIPart<{ [toolNames.findSymbolReferences]: FindSymbolReferencesTool }> diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts new file mode 100644 index 000000000..790b8b756 --- /dev/null +++ b/packages/web/src/features/chat/tools/index.ts @@ -0,0 +1,16 @@ +// @NOTE: When adding a new tool, follow these steps: +// 1. Add the tool to the `toolNames` constant in `constants.ts`. +// 2. Add the tool to the `SBChatMessageToolTypes` type in `types.ts`. +// 3. Add the tool to the `tools` prop in `agent.ts`. +// 4. If the tool is meant to be rendered in the UI: +// - Add the tool to the `uiVisiblePartTypes` constant in `constants.ts`. +// - Add the tool's component to the `DetailsCard` switch statement in `detailsCard.tsx`. +// +// - bk, 2025-07-25 + +export * from "./findSymbolReferencesTool"; +export * from "./findSymbolDefinitionsTool"; +export * from "./readFilesTool"; +export * from "./searchCodeTool"; +export * from "./listReposTool"; +export * from "./listCommitsTool"; diff --git a/packages/web/src/features/chat/tools/listCommitsTool.ts b/packages/web/src/features/chat/tools/listCommitsTool.ts new file mode 100644 index 000000000..c0aca1583 --- /dev/null +++ b/packages/web/src/features/chat/tools/listCommitsTool.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { listCommits } from "@/features/git"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; + +export const listCommitsTool = tool({ + description: 'Lists commits in a repository with optional filtering by date range, author, and commit message.', + inputSchema: z.object({ + repository: z.string().describe("The repository to list commits from"), + query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), + since: z.string().describe("Start date for commit range (e.g., '30 days ago', '2024-01-01', 'last week')").optional(), + until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), + author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), + maxCount: z.number().describe("Maximum number of commits to return (default: 50)").optional(), + }), + execute: async ({ repository, query, since, until, author, maxCount }) => { + logger.debug('listCommits', { repository, query, since, until, author, maxCount }); + const response = await listCommits({ + repo: repository, + query, + since, + until, + author, + maxCount, + }); + + if (isServiceError(response)) { + return response; + } + + return { + commits: response.commits.map((commit) => ({ + hash: commit.hash, + date: commit.date, + message: commit.message, + author: `${commit.author_name} <${commit.author_email}>`, + refs: commit.refs, + })), + totalCount: response.totalCount, + }; + } +}); + +export type ListCommitsTool = InferUITool; +export type ListCommitsToolInput = InferToolInput; +export type ListCommitsToolOutput = InferToolOutput; +export type ListCommitsToolUIPart = ToolUIPart<{ [toolNames.listCommits]: ListCommitsTool }>; diff --git a/packages/web/src/features/chat/tools/listReposTool.ts b/packages/web/src/features/chat/tools/listReposTool.ts new file mode 100644 index 000000000..63f9dd715 --- /dev/null +++ b/packages/web/src/features/chat/tools/listReposTool.ts @@ -0,0 +1,27 @@ +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { listReposQueryParamsSchema } from "@/lib/schemas"; +import { ListReposQueryParams } from "@/lib/types"; +import { listRepos } from "@/app/api/(server)/repos/listReposApi"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; + +export const listReposTool = tool({ + description: 'Lists repositories in the organization with optional filtering and pagination.', + inputSchema: listReposQueryParamsSchema, + execute: async (request: ListReposQueryParams) => { + logger.debug('listRepos', request); + const reposResponse = await listRepos(request); + + if (isServiceError(reposResponse)) { + return reposResponse; + } + + return reposResponse.data.map((repo) => repo.repoName); + } +}); + +export type ListReposTool = InferUITool; +export type ListReposToolInput = InferToolInput; +export type ListReposToolOutput = InferToolOutput; +export type ListReposToolUIPart = ToolUIPart<{ [toolNames.listRepos]: ListReposTool }>; diff --git a/packages/web/src/features/chat/tools/readFilesTool.ts b/packages/web/src/features/chat/tools/readFilesTool.ts new file mode 100644 index 000000000..8eb4f4aba --- /dev/null +++ b/packages/web/src/features/chat/tools/readFilesTool.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { ServiceError } from "@/lib/serviceError"; +import { getFileSource } from "@/features/git"; +import { addLineNumbers } from "../utils"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; +const READ_FILES_MAX_LINES = 500; + +export const readFilesTool = tool({ + description: `Reads the contents of one or more files. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum ${READ_FILES_MAX_LINES} lines per file.`, + inputSchema: z.object({ + files: z.array(z.object({ + path: z.string().describe("The path to the file"), + offset: z.number().int().positive() + .optional() + .describe(`Line number to start reading from (1-indexed). Omit to start from the beginning.`), + limit: z.number().int().positive() + .optional() + .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), + })).describe("The files to read, with optional offset and limit"), + repository: z.string().describe("The repository to read the files from"), + }), + execute: async ({ files, repository }) => { + logger.debug('readFiles', { files, repository }); + // @todo: make revision configurable. + const revision = "HEAD"; + + const responses = await Promise.all(files.map(async ({ path, offset, limit }) => { + const fileSource = await getFileSource({ + path, + repo: repository, + ref: revision, + }); + + if (isServiceError(fileSource)) { + return fileSource; + } + + const lines = fileSource.source.split('\n'); + const start = (offset ?? 1) - 1; + const end = start + Math.min(limit ?? READ_FILES_MAX_LINES, READ_FILES_MAX_LINES); + const slicedLines = lines.slice(start, end); + const truncated = end < lines.length; + + return { + path: fileSource.path, + repository: fileSource.repo, + language: fileSource.language, + source: addLineNumbers(slicedLines.join('\n'), offset ?? 1), + truncated, + totalLines: lines.length, + revision, + }; + })); + + if (responses.some(isServiceError)) { + return responses.find(isServiceError)!; + } + + return responses as Exclude<(typeof responses)[number], ServiceError>[]; + } +}); + +export type ReadFilesTool = InferUITool; +export type ReadFilesToolInput = InferToolInput; +export type ReadFilesToolOutput = InferToolOutput; +export type ReadFilesToolUIPart = ToolUIPart<{ [toolNames.readFiles]: ReadFilesTool }> diff --git a/packages/web/src/features/chat/tools/searchCodeTool.ts b/packages/web/src/features/chat/tools/searchCodeTool.ts new file mode 100644 index 000000000..61003a814 --- /dev/null +++ b/packages/web/src/features/chat/tools/searchCodeTool.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { search } from "@/features/search"; +import { addLineNumbers } from "../utils"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; +import escapeStringRegexp from "escape-string-regexp"; + +const DEFAULT_SEARCH_LIMIT = 100; + +export const createCodeSearchTool = (selectedRepos: string[]) => tool({ + description: `Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the \`listRepos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches.`, + inputSchema: z.object({ + query: z + .string() + .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) + // Escape backslashes first, then quotes, and wrap in double quotes + // so the query is treated as a literal phrase (like grep). + .transform((val) => { + const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; + }), + useRegex: z + .boolean() + .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) + .optional(), + filterByRepos: z + .array(z.string()) + .describe(`Scope the search to the provided repositories.`) + .optional(), + filterByLanguages: z + .array(z.string()) + .describe(`Scope the search to the provided languages.`) + .optional(), + filterByFilepaths: z + .array(z.string()) + .describe(`Scope the search to the provided filepaths.`) + .optional(), + caseSensitive: z + .boolean() + .describe(`Whether the search should be case sensitive (default: false).`) + .optional(), + ref: z + .string() + .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) + .optional(), + limit: z + .number() + .default(DEFAULT_SEARCH_LIMIT) + .describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) + .optional(), + }), + execute: async ({ + query, + useRegex = false, + filterByRepos: repos = [], + filterByLanguages: languages = [], + filterByFilepaths: filepaths = [], + caseSensitive = false, + ref, + limit = DEFAULT_SEARCH_LIMIT, + }) => { + logger.debug('searchCode', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); + + if (selectedRepos.length > 0) { + query += ` reposet:${selectedRepos.join(',')}`; + } + + if (repos.length > 0) { + query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; + } + + if (languages.length > 0) { + query += ` (lang:${languages.join(' or lang:')})`; + } + + if (filepaths.length > 0) { + query += ` (file:${filepaths.map(filepath => escapeStringRegexp(filepath)).join(' or file:')})`; + } + + if (ref) { + query += ` (rev:${ref})`; + } + + const response = await search({ + queryType: 'string', + query, + options: { + matches: limit, + contextLines: 3, + isCaseSensitivityEnabled: caseSensitive, + isRegexEnabled: useRegex, + } + }); + + if (isServiceError(response)) { + return response; + } + + return { + files: response.files.map((file) => ({ + fileName: file.fileName.text, + repository: file.repository, + language: file.language, + matches: file.chunks.map(({ content, contentStart }) => { + return addLineNumbers(content, contentStart.lineNumber); + }), + // @todo: make revision configurable. + revision: 'HEAD', + })), + query, + } + }, +}); + +export type SearchCodeTool = InferUITool>; +export type SearchCodeToolInput = InferToolInput>; +export type SearchCodeToolOutput = InferToolOutput>; +export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>; From 337816a8ac0a727ee9e09ea732270df305de5815 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 28 Feb 2026 20:23:18 -0800 Subject: [PATCH 02/43] wip --- packages/web/next.config.mjs | 9 ++- packages/web/package.json | 1 + ...itionsTool.ts => findSymbolDefinitions.ts} | 3 +- .../chat/tools/findSymbolDefinitions.txt | 1 + ...erencesTool.ts => findSymbolReferences.ts} | 3 +- .../chat/tools/findSymbolReferences.txt | 1 + packages/web/src/features/chat/tools/index.ts | 12 ++-- .../{listCommitsTool.ts => listCommits.ts} | 3 +- .../src/features/chat/tools/listCommits.txt | 1 + .../tools/{listReposTool.ts => listRepos.ts} | 3 +- .../web/src/features/chat/tools/listRepos.txt | 1 + .../tools/{readFilesTool.ts => readFiles.ts} | 5 +- .../web/src/features/chat/tools/readFiles.txt | 1 + .../{searchCodeTool.ts => searchCode.ts} | 3 +- .../src/features/chat/tools/searchCode.txt | 1 + packages/web/types.d.ts | 4 ++ yarn.lock | 64 ++++++++++++++++++- 17 files changed, 100 insertions(+), 16 deletions(-) rename packages/web/src/features/chat/tools/{findSymbolDefinitionsTool.ts => findSymbolDefinitions.ts} (96%) create mode 100644 packages/web/src/features/chat/tools/findSymbolDefinitions.txt rename packages/web/src/features/chat/tools/{findSymbolReferencesTool.ts => findSymbolReferences.ts} (96%) create mode 100644 packages/web/src/features/chat/tools/findSymbolReferences.txt rename packages/web/src/features/chat/tools/{listCommitsTool.ts => listCommits.ts} (94%) create mode 100644 packages/web/src/features/chat/tools/listCommits.txt rename packages/web/src/features/chat/tools/{listReposTool.ts => listRepos.ts} (91%) create mode 100644 packages/web/src/features/chat/tools/listRepos.txt rename packages/web/src/features/chat/tools/{readFilesTool.ts => readFiles.ts} (92%) create mode 100644 packages/web/src/features/chat/tools/readFiles.txt rename packages/web/src/features/chat/tools/{searchCodeTool.ts => searchCode.ts} (89%) create mode 100644 packages/web/src/features/chat/tools/searchCode.txt create mode 100644 packages/web/types.d.ts diff --git a/packages/web/next.config.mjs b/packages/web/next.config.mjs index df13bd2c8..1006f956a 100644 --- a/packages/web/next.config.mjs +++ b/packages/web/next.config.mjs @@ -38,7 +38,14 @@ const nextConfig = { ] }, - turbopack: {}, + turbopack: { + rules: { + '*.txt': { + loaders: ['raw-loader'], + as: '*.js', + }, + }, + }, // @see: https://github.com/vercel/next.js/issues/58019#issuecomment-1910531929 ...(process.env.NODE_ENV === 'development' ? { diff --git a/packages/web/package.json b/packages/web/package.json index e0b0e2488..d6a9a8cf1 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -214,6 +214,7 @@ "jsdom": "^25.0.1", "npm-run-all": "^4.1.5", "postcss": "^8", + "raw-loader": "^4.0.2", "react-email": "^5.1.0", "tailwindcss": "^3.4.1", "tsx": "^4.19.2", diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts b/packages/web/src/features/chat/tools/findSymbolDefinitions.ts similarity index 96% rename from packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts rename to packages/web/src/features/chat/tools/findSymbolDefinitions.ts index d71c7c2de..b76ca1eba 100644 --- a/packages/web/src/features/chat/tools/findSymbolDefinitionsTool.ts +++ b/packages/web/src/features/chat/tools/findSymbolDefinitions.ts @@ -5,9 +5,10 @@ import { findSearchBasedSymbolDefinitions } from "../../codeNav/api"; import { addLineNumbers } from "../utils"; import { toolNames } from "../constants"; import { logger } from "../logger"; +import description from './findSymbolDefinitions.txt'; export const findSymbolDefinitionsTool = tool({ - description: `Finds definitions of a symbol in the codebase.`, + description, inputSchema: z.object({ symbol: z.string().describe("The symbol to find definitions of"), language: z.string().describe("The programming language of the symbol"), diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitions.txt b/packages/web/src/features/chat/tools/findSymbolDefinitions.txt new file mode 100644 index 000000000..0ba87ff08 --- /dev/null +++ b/packages/web/src/features/chat/tools/findSymbolDefinitions.txt @@ -0,0 +1 @@ +Finds definitions of a symbol in the codebase. diff --git a/packages/web/src/features/chat/tools/findSymbolReferencesTool.ts b/packages/web/src/features/chat/tools/findSymbolReferences.ts similarity index 96% rename from packages/web/src/features/chat/tools/findSymbolReferencesTool.ts rename to packages/web/src/features/chat/tools/findSymbolReferences.ts index b12e1568a..0b86af935 100644 --- a/packages/web/src/features/chat/tools/findSymbolReferencesTool.ts +++ b/packages/web/src/features/chat/tools/findSymbolReferences.ts @@ -5,9 +5,10 @@ import { findSearchBasedSymbolReferences } from "../../codeNav/api"; import { addLineNumbers } from "../utils"; import { toolNames } from "../constants"; import { logger } from "../logger"; +import description from './findSymbolReferences.txt'; export const findSymbolReferencesTool = tool({ - description: `Finds references to a symbol in the codebase.`, + description, inputSchema: z.object({ symbol: z.string().describe("The symbol to find references to"), language: z.string().describe("The programming language of the symbol"), diff --git a/packages/web/src/features/chat/tools/findSymbolReferences.txt b/packages/web/src/features/chat/tools/findSymbolReferences.txt new file mode 100644 index 000000000..e35a2c87b --- /dev/null +++ b/packages/web/src/features/chat/tools/findSymbolReferences.txt @@ -0,0 +1 @@ +Finds references to a symbol in the codebase. diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts index 790b8b756..96f218171 100644 --- a/packages/web/src/features/chat/tools/index.ts +++ b/packages/web/src/features/chat/tools/index.ts @@ -8,9 +8,9 @@ // // - bk, 2025-07-25 -export * from "./findSymbolReferencesTool"; -export * from "./findSymbolDefinitionsTool"; -export * from "./readFilesTool"; -export * from "./searchCodeTool"; -export * from "./listReposTool"; -export * from "./listCommitsTool"; +export * from "./findSymbolReferences"; +export * from "./findSymbolDefinitions"; +export * from "./readFiles"; +export * from "./searchCode"; +export * from "./listRepos"; +export * from "./listCommits"; diff --git a/packages/web/src/features/chat/tools/listCommitsTool.ts b/packages/web/src/features/chat/tools/listCommits.ts similarity index 94% rename from packages/web/src/features/chat/tools/listCommitsTool.ts rename to packages/web/src/features/chat/tools/listCommits.ts index c0aca1583..61ade8ef0 100644 --- a/packages/web/src/features/chat/tools/listCommitsTool.ts +++ b/packages/web/src/features/chat/tools/listCommits.ts @@ -4,9 +4,10 @@ import { isServiceError } from "@/lib/utils"; import { listCommits } from "@/features/git"; import { toolNames } from "../constants"; import { logger } from "../logger"; +import description from './listCommits.txt'; export const listCommitsTool = tool({ - description: 'Lists commits in a repository with optional filtering by date range, author, and commit message.', + description, inputSchema: z.object({ repository: z.string().describe("The repository to list commits from"), query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), diff --git a/packages/web/src/features/chat/tools/listCommits.txt b/packages/web/src/features/chat/tools/listCommits.txt new file mode 100644 index 000000000..b82afe97a --- /dev/null +++ b/packages/web/src/features/chat/tools/listCommits.txt @@ -0,0 +1 @@ +Lists commits in a repository with optional filtering by date range, author, and commit message. diff --git a/packages/web/src/features/chat/tools/listReposTool.ts b/packages/web/src/features/chat/tools/listRepos.ts similarity index 91% rename from packages/web/src/features/chat/tools/listReposTool.ts rename to packages/web/src/features/chat/tools/listRepos.ts index 63f9dd715..dda382db2 100644 --- a/packages/web/src/features/chat/tools/listReposTool.ts +++ b/packages/web/src/features/chat/tools/listRepos.ts @@ -5,9 +5,10 @@ import { ListReposQueryParams } from "@/lib/types"; import { listRepos } from "@/app/api/(server)/repos/listReposApi"; import { toolNames } from "../constants"; import { logger } from "../logger"; +import description from './listRepos.txt'; export const listReposTool = tool({ - description: 'Lists repositories in the organization with optional filtering and pagination.', + description, inputSchema: listReposQueryParamsSchema, execute: async (request: ListReposQueryParams) => { logger.debug('listRepos', request); diff --git a/packages/web/src/features/chat/tools/listRepos.txt b/packages/web/src/features/chat/tools/listRepos.txt new file mode 100644 index 000000000..343546d27 --- /dev/null +++ b/packages/web/src/features/chat/tools/listRepos.txt @@ -0,0 +1 @@ +Lists repositories in the organization with optional filtering and pagination. diff --git a/packages/web/src/features/chat/tools/readFilesTool.ts b/packages/web/src/features/chat/tools/readFiles.ts similarity index 92% rename from packages/web/src/features/chat/tools/readFilesTool.ts rename to packages/web/src/features/chat/tools/readFiles.ts index 8eb4f4aba..a33e9695e 100644 --- a/packages/web/src/features/chat/tools/readFilesTool.ts +++ b/packages/web/src/features/chat/tools/readFiles.ts @@ -6,10 +6,13 @@ import { getFileSource } from "@/features/git"; import { addLineNumbers } from "../utils"; import { toolNames } from "../constants"; import { logger } from "../logger"; +import description from './readFiles.txt'; + +// NOTE: if you change this value, update readFiles.txt to match. const READ_FILES_MAX_LINES = 500; export const readFilesTool = tool({ - description: `Reads the contents of one or more files. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum ${READ_FILES_MAX_LINES} lines per file.`, + description, inputSchema: z.object({ files: z.array(z.object({ path: z.string().describe("The path to the file"), diff --git a/packages/web/src/features/chat/tools/readFiles.txt b/packages/web/src/features/chat/tools/readFiles.txt new file mode 100644 index 000000000..4938aa037 --- /dev/null +++ b/packages/web/src/features/chat/tools/readFiles.txt @@ -0,0 +1 @@ +Reads the contents of one or more files. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum 500 lines per file. diff --git a/packages/web/src/features/chat/tools/searchCodeTool.ts b/packages/web/src/features/chat/tools/searchCode.ts similarity index 89% rename from packages/web/src/features/chat/tools/searchCodeTool.ts rename to packages/web/src/features/chat/tools/searchCode.ts index 61003a814..79acee78a 100644 --- a/packages/web/src/features/chat/tools/searchCodeTool.ts +++ b/packages/web/src/features/chat/tools/searchCode.ts @@ -6,11 +6,12 @@ import { addLineNumbers } from "../utils"; import { toolNames } from "../constants"; import { logger } from "../logger"; import escapeStringRegexp from "escape-string-regexp"; +import description from './searchCode.txt'; const DEFAULT_SEARCH_LIMIT = 100; export const createCodeSearchTool = (selectedRepos: string[]) => tool({ - description: `Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the \`listRepos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches.`, + description, inputSchema: z.object({ query: z .string() diff --git a/packages/web/src/features/chat/tools/searchCode.txt b/packages/web/src/features/chat/tools/searchCode.txt new file mode 100644 index 000000000..15b5850a5 --- /dev/null +++ b/packages/web/src/features/chat/tools/searchCode.txt @@ -0,0 +1 @@ +Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the `listRepos` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches. diff --git a/packages/web/types.d.ts b/packages/web/types.d.ts new file mode 100644 index 000000000..bceb5175d --- /dev/null +++ b/packages/web/types.d.ts @@ -0,0 +1,4 @@ +declare module '*.txt' { + const content: string; + export default content; +} diff --git a/yarn.lock b/yarn.lock index fd6fda477..a24796b5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8952,6 +8952,7 @@ __metadata: posthog-node: "npm:^5.24.15" pretty-bytes: "npm:^6.1.1" psl: "npm:^1.15.0" + raw-loader: "npm:^4.0.2" react: "npm:19.2.4" react-device-detect: "npm:^2.2.3" react-dom: "npm:19.2.4" @@ -9411,7 +9412,7 @@ __metadata: languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15": +"@types/json-schema@npm:^7.0.15, @types/json-schema@npm:^7.0.8": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db @@ -10435,6 +10436,15 @@ __metadata: languageName: node linkType: hard +"ajv-keywords@npm:^3.5.2": + version: 3.5.2 + resolution: "ajv-keywords@npm:3.5.2" + peerDependencies: + ajv: ^6.9.1 + checksum: 10c0/0c57a47cbd656e8cdfd99d7c2264de5868918ffa207c8d7a72a7f63379d4333254b2ba03d69e3c035e996a3fd3eb6d5725d7a1597cca10694296e32510546360 + languageName: node + linkType: hard + "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -10447,7 +10457,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.14.0": +"ajv@npm:^6.12.5, ajv@npm:^6.14.0": version: 6.14.0 resolution: "ajv@npm:6.14.0" dependencies: @@ -10860,6 +10870,13 @@ __metadata: languageName: node linkType: hard +"big.js@npm:^5.2.2": + version: 5.2.2 + resolution: "big.js@npm:5.2.2" + checksum: 10c0/230520f1ff920b2d2ce3e372d77a33faa4fa60d802fe01ca4ffbc321ee06023fe9a741ac02793ee778040a16b7e497f7d60c504d1c402b8fdab6f03bb785a25f + languageName: node + linkType: hard + "bignumber.js@npm:^9.0.0": version: 9.3.0 resolution: "bignumber.js@npm:9.3.0" @@ -12602,6 +12619,13 @@ __metadata: languageName: node linkType: hard +"emojis-list@npm:^3.0.0": + version: 3.0.0 + resolution: "emojis-list@npm:3.0.0" + checksum: 10c0/7dc4394b7b910444910ad64b812392159a21e1a7ecc637c775a440227dcb4f80eff7fe61f4453a7d7603fa23d23d30cc93fe9e4b5ed985b88d6441cd4a35117b + languageName: node + linkType: hard + "enabled@npm:2.0.x": version: 2.0.0 resolution: "enabled@npm:2.0.0" @@ -15821,7 +15845,7 @@ __metadata: languageName: node linkType: hard -"json5@npm:^2.2.1, json5@npm:^2.2.2, json5@npm:^2.2.3": +"json5@npm:^2.1.2, json5@npm:^2.2.1, json5@npm:^2.2.2, json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" bin: @@ -16063,6 +16087,17 @@ __metadata: languageName: node linkType: hard +"loader-utils@npm:^2.0.0": + version: 2.0.4 + resolution: "loader-utils@npm:2.0.4" + dependencies: + big.js: "npm:^5.2.2" + emojis-list: "npm:^3.0.0" + json5: "npm:^2.1.2" + checksum: 10c0/d5654a77f9d339ec2a03d88221a5a695f337bf71eb8dea031b3223420bb818964ba8ed0069145c19b095f6c8b8fd386e602a3fc7ca987042bd8bb1dcc90d7100 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -18834,6 +18869,18 @@ __metadata: languageName: node linkType: hard +"raw-loader@npm:^4.0.2": + version: 4.0.2 + resolution: "raw-loader@npm:4.0.2" + dependencies: + loader-utils: "npm:^2.0.0" + schema-utils: "npm:^3.0.0" + peerDependencies: + webpack: ^4.0.0 || ^5.0.0 + checksum: 10c0/981ebe65e1cee7230300d21ba6dcd8bd23ea81ef4ad2b167c0f62d93deba347f27921d330be848634baab3831cf9f38900af6082d6416c2e937fe612fa6a74ff + languageName: node + linkType: hard + "react-device-detect@npm:^2.2.3": version: 2.2.3 resolution: "react-device-detect@npm:2.2.3" @@ -19723,6 +19770,17 @@ __metadata: languageName: node linkType: hard +"schema-utils@npm:^3.0.0": + version: 3.3.0 + resolution: "schema-utils@npm:3.3.0" + dependencies: + "@types/json-schema": "npm:^7.0.8" + ajv: "npm:^6.12.5" + ajv-keywords: "npm:^3.5.2" + checksum: 10c0/fafdbde91ad8aa1316bc543d4b61e65ea86970aebbfb750bfb6d8a6c287a23e415e0e926c2498696b242f63af1aab8e585252637fabe811fd37b604351da6500 + languageName: node + linkType: hard + "scroll-into-view-if-needed@npm:^3.1.0": version: 3.1.0 resolution: "scroll-into-view-if-needed@npm:3.1.0" From 0469fe0ddc8543392d6749cc0e452fc0afa4a0bf Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 2 Mar 2026 16:22:08 -0800 Subject: [PATCH 03/43] wip --- packages/web/src/features/chat/agent.ts | 6 +- .../components/chatThread/detailsCard.tsx | 6 +- ...omponent.tsx => readFileToolComponent.tsx} | 25 +++---- packages/web/src/features/chat/constants.ts | 4 +- packages/web/src/features/chat/tools/index.ts | 2 +- .../web/src/features/chat/tools/readFile.ts | 61 ++++++++++++++++ .../web/src/features/chat/tools/readFile.txt | 1 + .../web/src/features/chat/tools/readFiles.ts | 72 ------------------- .../web/src/features/chat/tools/readFiles.txt | 1 - packages/web/src/features/chat/types.ts | 4 +- 10 files changed, 84 insertions(+), 98 deletions(-) rename packages/web/src/features/chat/components/chatThread/tools/{readFilesToolComponent.tsx => readFileToolComponent.tsx} (67%) create mode 100644 packages/web/src/features/chat/tools/readFile.ts create mode 100644 packages/web/src/features/chat/tools/readFile.txt delete mode 100644 packages/web/src/features/chat/tools/readFiles.ts delete mode 100644 packages/web/src/features/chat/tools/readFiles.txt diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index f50a37e79..5b3d3aa56 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -5,7 +5,7 @@ import { ProviderOptions } from "@ai-sdk/provider-utils"; import { env } from "@sourcebot/shared"; import { LanguageModel, ModelMessage, StopCondition, streamText } from "ai"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants"; -import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, listReposTool, listCommitsTool, readFilesTool } from "./tools"; +import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, listReposTool, listCommitsTool, readFileTool } from "./tools"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import _dedent from "dedent"; @@ -71,7 +71,7 @@ export const createAgentStream = async ({ system: systemPrompt, tools: { [toolNames.searchCode]: createCodeSearchTool(selectedRepos), - [toolNames.readFiles]: readFilesTool, + [toolNames.readFile]: readFileTool, [toolNames.findSymbolReferences]: findSymbolReferencesTool, [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, [toolNames.listRepos]: listReposTool, @@ -94,7 +94,7 @@ export const createAgentStream = async ({ return; } - if (toolName === toolNames.readFiles) { + if (toolName === toolNames.readFile) { output.forEach((file) => { onWriteSource({ type: 'file', diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index ff155ea00..a3f029def 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -12,7 +12,7 @@ import useCaptureEvent from '@/hooks/useCaptureEvent'; import { MarkdownRenderer } from './markdownRenderer'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; -import { ReadFilesToolComponent } from './tools/readFilesToolComponent'; +import { ReadFileToolComponent } from './tools/readFileToolComponent'; import { SearchCodeToolComponent } from './tools/searchCodeToolComponent'; import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; @@ -166,9 +166,9 @@ const DetailsCardComponent = ({ className="text-sm" /> ) - case 'tool-readFiles': + case 'tool-readFile': return ( - diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx similarity index 67% rename from packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx rename to packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index e9f4cc74b..ebf3a072c 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFilesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -2,13 +2,13 @@ import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; -import { ReadFilesToolUIPart } from "@/features/chat/tools"; +import { ReadFileToolUIPart } from "@/features/chat/tools"; import { isServiceError } from "@/lib/utils"; import { EyeIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; -export const ReadFilesToolComponent = ({ part }: { part: ReadFilesToolUIPart }) => { +export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => { const [isExpanded, setIsExpanded] = useState(false); const label = useMemo(() => { @@ -16,14 +16,14 @@ export const ReadFilesToolComponent = ({ part }: { part: ReadFilesToolUIPart }) case 'input-streaming': return 'Reading...'; case 'input-available': - return `Reading ${part.input.files.length} file${part.input.files.length === 1 ? '' : 's'}...`; + return `Reading ${part.input.path}...`; case 'output-error': return 'Tool call failed'; case 'output-available': if (isServiceError(part.output)) { - return 'Failed to read files'; + return 'Failed to read file'; } - return `Read ${part.output.length} file${part.output.length === 1 ? '' : 's'}`; + return `Read ${part.output.path}`; } }, [part]); @@ -42,15 +42,12 @@ export const ReadFilesToolComponent = ({ part }: { part: ReadFilesToolUIPart }) {isServiceError(part.output) ? ( Failed with the following error: {part.output.message} - ) : part.output.map((file) => { - return ( - - ) - })} + ) : ( + + )} diff --git a/packages/web/src/features/chat/constants.ts b/packages/web/src/features/chat/constants.ts index aca101a3c..1989ca440 100644 --- a/packages/web/src/features/chat/constants.ts +++ b/packages/web/src/features/chat/constants.ts @@ -11,7 +11,7 @@ export const ANSWER_TAG = ''; export const toolNames = { searchCode: 'searchCode', - readFiles: 'readFiles', + readFile: 'readFile', findSymbolReferences: 'findSymbolReferences', findSymbolDefinitions: 'findSymbolDefinitions', listRepos: 'listRepos', @@ -23,7 +23,7 @@ export const uiVisiblePartTypes: SBChatMessagePart['type'][] = [ 'reasoning', 'text', 'tool-searchCode', - 'tool-readFiles', + 'tool-readFile', 'tool-findSymbolDefinitions', 'tool-findSymbolReferences', 'tool-listRepos', diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts index 96f218171..91bc0d7a0 100644 --- a/packages/web/src/features/chat/tools/index.ts +++ b/packages/web/src/features/chat/tools/index.ts @@ -10,7 +10,7 @@ export * from "./findSymbolReferences"; export * from "./findSymbolDefinitions"; -export * from "./readFiles"; +export * from "./readFile"; export * from "./searchCode"; export * from "./listRepos"; export * from "./listCommits"; diff --git a/packages/web/src/features/chat/tools/readFile.ts b/packages/web/src/features/chat/tools/readFile.ts new file mode 100644 index 000000000..9e28c66c7 --- /dev/null +++ b/packages/web/src/features/chat/tools/readFile.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; +import { isServiceError } from "@/lib/utils"; +import { getFileSource } from "@/features/git"; +import { addLineNumbers } from "../utils"; +import { toolNames } from "../constants"; +import { logger } from "../logger"; +import description from './readFile.txt'; + +// NOTE: if you change this value, update readFile.txt to match. +const READ_FILES_MAX_LINES = 500; + +export const readFileTool = tool({ + description, + inputSchema: z.object({ + path: z.string().describe("The path to the file"), + repository: z.string().describe("The repository to read the file from"), + offset: z.number().int().positive() + .optional() + .describe("Line number to start reading from (1-indexed). Omit to start from the beginning."), + limit: z.number().int().positive() + .optional() + .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), + }), + execute: async ({ path, repository, offset, limit }) => { + logger.debug('readFiles', { path, repository, offset, limit }); + // @todo: make revision configurable. + const revision = "HEAD"; + + const fileSource = await getFileSource({ + path, + repo: repository, + ref: revision, + }); + + if (isServiceError(fileSource)) { + return fileSource; + } + + const lines = fileSource.source.split('\n'); + const start = (offset ?? 1) - 1; + const end = start + Math.min(limit ?? READ_FILES_MAX_LINES, READ_FILES_MAX_LINES); + const slicedLines = lines.slice(start, end); + const truncated = end < lines.length; + + return { + path: fileSource.path, + repository: fileSource.repo, + language: fileSource.language, + source: addLineNumbers(slicedLines.join('\n'), offset ?? 1), + truncated, + totalLines: lines.length, + revision, + }; + } +}); + +export type ReadFileTool = InferUITool; +export type ReadFileToolInput = InferToolInput; +export type ReadFileToolOutput = InferToolOutput; +export type ReadFileToolUIPart = ToolUIPart<{ [toolNames.readFile]: ReadFileTool }> diff --git a/packages/web/src/features/chat/tools/readFile.txt b/packages/web/src/features/chat/tools/readFile.txt new file mode 100644 index 000000000..94d7e7191 --- /dev/null +++ b/packages/web/src/features/chat/tools/readFile.txt @@ -0,0 +1 @@ +Reads the contents of a file. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum 500 lines per call. To read multiple files, call this tool in parallel. diff --git a/packages/web/src/features/chat/tools/readFiles.ts b/packages/web/src/features/chat/tools/readFiles.ts deleted file mode 100644 index a33e9695e..000000000 --- a/packages/web/src/features/chat/tools/readFiles.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { ServiceError } from "@/lib/serviceError"; -import { getFileSource } from "@/features/git"; -import { addLineNumbers } from "../utils"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './readFiles.txt'; - -// NOTE: if you change this value, update readFiles.txt to match. -const READ_FILES_MAX_LINES = 500; - -export const readFilesTool = tool({ - description, - inputSchema: z.object({ - files: z.array(z.object({ - path: z.string().describe("The path to the file"), - offset: z.number().int().positive() - .optional() - .describe(`Line number to start reading from (1-indexed). Omit to start from the beginning.`), - limit: z.number().int().positive() - .optional() - .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), - })).describe("The files to read, with optional offset and limit"), - repository: z.string().describe("The repository to read the files from"), - }), - execute: async ({ files, repository }) => { - logger.debug('readFiles', { files, repository }); - // @todo: make revision configurable. - const revision = "HEAD"; - - const responses = await Promise.all(files.map(async ({ path, offset, limit }) => { - const fileSource = await getFileSource({ - path, - repo: repository, - ref: revision, - }); - - if (isServiceError(fileSource)) { - return fileSource; - } - - const lines = fileSource.source.split('\n'); - const start = (offset ?? 1) - 1; - const end = start + Math.min(limit ?? READ_FILES_MAX_LINES, READ_FILES_MAX_LINES); - const slicedLines = lines.slice(start, end); - const truncated = end < lines.length; - - return { - path: fileSource.path, - repository: fileSource.repo, - language: fileSource.language, - source: addLineNumbers(slicedLines.join('\n'), offset ?? 1), - truncated, - totalLines: lines.length, - revision, - }; - })); - - if (responses.some(isServiceError)) { - return responses.find(isServiceError)!; - } - - return responses as Exclude<(typeof responses)[number], ServiceError>[]; - } -}); - -export type ReadFilesTool = InferUITool; -export type ReadFilesToolInput = InferToolInput; -export type ReadFilesToolOutput = InferToolOutput; -export type ReadFilesToolUIPart = ToolUIPart<{ [toolNames.readFiles]: ReadFilesTool }> diff --git a/packages/web/src/features/chat/tools/readFiles.txt b/packages/web/src/features/chat/tools/readFiles.txt deleted file mode 100644 index 4938aa037..000000000 --- a/packages/web/src/features/chat/tools/readFiles.txt +++ /dev/null @@ -1 +0,0 @@ -Reads the contents of one or more files. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum 500 lines per file. diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index fbf840538..5501568ee 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -3,7 +3,7 @@ import { BaseEditor, Descendant } from "slate"; import { HistoryEditor } from "slate-history"; import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; -import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFilesTool, SearchCodeTool, ListReposTool, ListCommitsTool } from "./tools"; +import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFileTool, SearchCodeTool, ListReposTool, ListCommitsTool } from "./tools"; import { toolNames } from "./constants"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; @@ -80,7 +80,7 @@ export type SBChatMessageMetadata = z.infer; export type SBChatMessageToolTypes = { [toolNames.searchCode]: SearchCodeTool, - [toolNames.readFiles]: ReadFilesTool, + [toolNames.readFile]: ReadFileTool, [toolNames.findSymbolReferences]: FindSymbolReferencesTool, [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool, [toolNames.listRepos]: ListReposTool, From c5adc10994044fd6ab8455680978c24fbd845525 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 2 Mar 2026 17:37:35 -0800 Subject: [PATCH 04/43] wip - improve readFile --- packages/web/src/features/chat/agent.ts | 16 ++- .../tools/readFileToolComponent.tsx | 32 +++-- .../chat/tools/findSymbolDefinitions.ts | 2 +- .../chat/tools/findSymbolReferences.ts | 2 +- .../src/features/chat/tools/listCommits.ts | 2 +- .../web/src/features/chat/tools/listRepos.ts | 2 +- .../src/features/chat/tools/readFile.test.ts | 118 ++++++++++++++++++ .../web/src/features/chat/tools/readFile.ts | 60 +++++++-- .../web/src/features/chat/tools/readFile.txt | 10 +- .../web/src/features/chat/tools/searchCode.ts | 2 +- packages/web/src/features/chat/utils.ts | 2 +- 11 files changed, 217 insertions(+), 31 deletions(-) create mode 100644 packages/web/src/features/chat/tools/readFile.test.ts diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 5b3d3aa56..436e81384 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -95,15 +95,13 @@ export const createAgentStream = async ({ } if (toolName === toolNames.readFile) { - output.forEach((file) => { - onWriteSource({ - type: 'file', - language: file.language, - repo: file.repository, - path: file.path, - revision: file.revision, - name: file.path.split('/').pop() ?? file.path, - }); + onWriteSource({ + type: 'file', + language: output.language, + repo: output.repository, + path: output.path, + revision: output.revision, + name: output.path.split('/').pop() ?? output.path, }); } else if (toolName === toolNames.searchCode) { output.files.forEach((file) => { diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index ebf3a072c..949486d31 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -6,11 +6,18 @@ import { ReadFileToolUIPart } from "@/features/chat/tools"; import { isServiceError } from "@/lib/utils"; import { EyeIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import { CopyIconButton } from "@/app/[domain]/components/copyIconButton"; import { FileListItem, ToolHeader, TreeList } from "./shared"; export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => { const [isExpanded, setIsExpanded] = useState(false); + const onCopy = () => { + if (part.state !== 'output-available' || isServiceError(part.output)) return false; + navigator.clipboard.writeText(part.output.source); + return true; + }; + const label = useMemo(() => { switch (part.state) { case 'input-streaming': @@ -29,14 +36,23 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => return (
- +
+ + {part.state === 'output-available' && !isServiceError(part.output) && ( + + )} +
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitions.ts b/packages/web/src/features/chat/tools/findSymbolDefinitions.ts index b76ca1eba..36952007e 100644 --- a/packages/web/src/features/chat/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/chat/tools/findSymbolDefinitions.ts @@ -27,7 +27,7 @@ export const findSymbolDefinitionsTool = tool({ }); if (isServiceError(response)) { - return response; + throw new Error(response.message); } return response.files.map((file) => ({ diff --git a/packages/web/src/features/chat/tools/findSymbolReferences.ts b/packages/web/src/features/chat/tools/findSymbolReferences.ts index 0b86af935..265bec6d2 100644 --- a/packages/web/src/features/chat/tools/findSymbolReferences.ts +++ b/packages/web/src/features/chat/tools/findSymbolReferences.ts @@ -27,7 +27,7 @@ export const findSymbolReferencesTool = tool({ }); if (isServiceError(response)) { - return response; + throw new Error(response.message); } return response.files.map((file) => ({ diff --git a/packages/web/src/features/chat/tools/listCommits.ts b/packages/web/src/features/chat/tools/listCommits.ts index 61ade8ef0..4cb2ffaf1 100644 --- a/packages/web/src/features/chat/tools/listCommits.ts +++ b/packages/web/src/features/chat/tools/listCommits.ts @@ -28,7 +28,7 @@ export const listCommitsTool = tool({ }); if (isServiceError(response)) { - return response; + throw new Error(response.message); } return { diff --git a/packages/web/src/features/chat/tools/listRepos.ts b/packages/web/src/features/chat/tools/listRepos.ts index dda382db2..6a68d2869 100644 --- a/packages/web/src/features/chat/tools/listRepos.ts +++ b/packages/web/src/features/chat/tools/listRepos.ts @@ -15,7 +15,7 @@ export const listReposTool = tool({ const reposResponse = await listRepos(request); if (isServiceError(reposResponse)) { - return reposResponse; + throw new Error(reposResponse.message); } return reposResponse.data.map((repo) => repo.repoName); diff --git a/packages/web/src/features/chat/tools/readFile.test.ts b/packages/web/src/features/chat/tools/readFile.test.ts new file mode 100644 index 000000000..8ede158b1 --- /dev/null +++ b/packages/web/src/features/chat/tools/readFile.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test, vi } from 'vitest'; +import { readFileTool } from './readFile'; + +vi.mock('@/features/git', () => ({ + getFileSource: vi.fn(), +})); + +vi.mock('../logger', () => ({ + logger: { debug: vi.fn() }, +})); + +vi.mock('./readFile.txt', () => ({ default: 'description' })); + +import { getFileSource } from '@/features/git'; + +const mockGetFileSource = vi.mocked(getFileSource); + +function makeSource(source: string) { + mockGetFileSource.mockResolvedValue({ + source, + path: 'test.ts', + repo: 'github.com/org/repo', + language: 'typescript', + revision: 'HEAD', + } as any); +} + +describe('readFileTool byte cap', () => { + test('truncates output at 5KB and shows byte cap message', async () => { + // Each line is ~100 bytes; 60 lines = ~6KB, over the 5KB cap + const lines = Array.from({ length: 60 }, (_, i) => `line${i + 1}: ${'x'.repeat(90)}`).join('\n'); + makeSource(lines); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain('Output capped at 5KB'); + expect('source' in result && result.source).toContain('Use offset='); + expect('source' in result && result.source).toContain('Output capped at 5KB'); + }); + + test('does not cap output under 5KB', async () => { + makeSource('short line\n'.repeat(10).trimEnd()); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).not.toContain('Output capped at 5KB'); + }); +}); + +describe('readFileTool hasMoreLines message', () => { + test('appends continuation message when file is truncated', async () => { + const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n'); + makeSource(lines); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain('Showing lines 1-500 of 600'); + expect('source' in result && result.source).toContain('offset=501'); + }); + + test('shows end of file message when all lines fit', async () => { + makeSource('line1\nline2\nline3'); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).not.toContain('Showing lines'); + expect('source' in result && result.source).toContain('End of file - 3 lines total'); + }); + + test('continuation message reflects offset parameter', async () => { + const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n'); + makeSource(lines); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo', offset: 100 }, {} as any); + expect('source' in result && result.source).toContain('Showing lines 100-599 of 600'); + expect('source' in result && result.source).toContain('offset=600'); + }); +}); + +describe('readFileTool line truncation', () => { + test('does not truncate lines under the limit', async () => { + const line = 'x'.repeat(100); + makeSource(line); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain(line); + expect('source' in result && result.source).not.toContain('line truncated'); + }); + + test('truncates lines longer than 2000 chars', async () => { + const line = 'x'.repeat(3000); + makeSource(line); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); + expect('source' in result && result.source).not.toContain('x'.repeat(2001)); + }); + + test('truncates only the long lines, leaving normal lines intact', async () => { + const longLine = 'a'.repeat(3000); + const normalLine = 'normal line'; + makeSource(`${normalLine}\n${longLine}\n${normalLine}`); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain(normalLine); + expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); + }); + + test('truncates a line at exactly 2001 chars', async () => { + makeSource('b'.repeat(2001)); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); + }); + + test('does not truncate a line at exactly 2000 chars', async () => { + makeSource('c'.repeat(2000)); + + const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); + expect('source' in result && result.source).not.toContain('line truncated'); + }); +}); diff --git a/packages/web/src/features/chat/tools/readFile.ts b/packages/web/src/features/chat/tools/readFile.ts index 9e28c66c7..78c5996fa 100644 --- a/packages/web/src/features/chat/tools/readFile.ts +++ b/packages/web/src/features/chat/tools/readFile.ts @@ -2,13 +2,16 @@ import { z } from "zod"; import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; import { isServiceError } from "@/lib/utils"; import { getFileSource } from "@/features/git"; -import { addLineNumbers } from "../utils"; import { toolNames } from "../constants"; import { logger } from "../logger"; import description from './readFile.txt'; -// NOTE: if you change this value, update readFile.txt to match. +// NOTE: if you change these values, update readFile.txt to match. const READ_FILES_MAX_LINES = 500; +const MAX_LINE_LENGTH = 2000; +const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; +const MAX_BYTES = 5 * 1024; +const MAX_BYTES_LABEL = `${MAX_BYTES / 1024}KB`; export const readFileTool = tool({ description, @@ -34,24 +37,67 @@ export const readFileTool = tool({ }); if (isServiceError(fileSource)) { - return fileSource; + throw new Error(fileSource.message); } const lines = fileSource.source.split('\n'); const start = (offset ?? 1) - 1; const end = start + Math.min(limit ?? READ_FILES_MAX_LINES, READ_FILES_MAX_LINES); - const slicedLines = lines.slice(start, end); - const truncated = end < lines.length; + + let bytes = 0; + let truncatedByBytes = false; + const slicedLines: string[] = []; + for (const raw of lines.slice(start, end)) { + const line = raw.length > MAX_LINE_LENGTH ? raw.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : raw; + const size = Buffer.byteLength(line, 'utf-8') + (slicedLines.length > 0 ? 1 : 0); + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true; + break; + } + slicedLines.push(line); + bytes += size; + } + + const truncatedByLines = end < lines.length; + const startLine = (offset ?? 1); + const lastReadLine = startLine + slicedLines.length - 1; + const nextOffset = lastReadLine + 1; + + let output = [ + `${fileSource.repo}`, + `${fileSource.path}`, + '\n' + ].join('\n'); + + output += slicedLines.map((line, i) => `${startLine + i}: ${line}`).join('\n'); + + if (truncatedByBytes) { + output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${startLine}-${lastReadLine} of ${lines.length}. Use offset=${nextOffset} to continue.)`; + } else if (truncatedByLines) { + output += `\n\n(Showing lines ${startLine}-${lastReadLine} of ${lines.length}. Use offset=${nextOffset} to continue.)`; + } else { + output += `\n\n(End of file - ${lines.length} lines total)`; + } + + output += `\n`; return { path: fileSource.path, repository: fileSource.repo, language: fileSource.language, - source: addLineNumbers(slicedLines.join('\n'), offset ?? 1), - truncated, + source: output, totalLines: lines.length, revision, }; + }, + toModelOutput: ({ output }) => { + return { + type: 'content', + value: [{ + type: 'text', + text: output.source, + }] + } } }); diff --git a/packages/web/src/features/chat/tools/readFile.txt b/packages/web/src/features/chat/tools/readFile.txt index 94d7e7191..9e1590bb6 100644 --- a/packages/web/src/features/chat/tools/readFile.txt +++ b/packages/web/src/features/chat/tools/readFile.txt @@ -1 +1,9 @@ -Reads the contents of a file. Use offset/limit to read a specific portion, which is strongly preferred for large files when only a specific section is needed. Maximum 500 lines per call. To read multiple files, call this tool in parallel. +Read the contents of a file in a repository. + +Usage: +- Use offset/limit to read a specific portion of a file, which is strongly preferred for large files when only a specific section is needed. +- Maximum 500 lines per call. Output is also capped at 5KB — if the cap is hit, call again with a larger offset to continue reading. +- Any line longer than 2000 characters is truncated. +- The response content includes the line range read and total line count. If the output was truncated, the next offset to continue reading is also included. +- Call this tool in parallel when you need to read multiple files simultaneously. +- Avoid tiny repeated slices. If you need more context, read a larger window. diff --git a/packages/web/src/features/chat/tools/searchCode.ts b/packages/web/src/features/chat/tools/searchCode.ts index 79acee78a..3792db985 100644 --- a/packages/web/src/features/chat/tools/searchCode.ts +++ b/packages/web/src/features/chat/tools/searchCode.ts @@ -96,7 +96,7 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({ }); if (isServiceError(response)) { - return response; + throw new Error(response.message); } return { diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index ca412618e..0e8f7ec32 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -173,7 +173,7 @@ export const resetEditor = (editor: CustomEditor) => { } export const addLineNumbers = (source: string, lineOffset = 1) => { - return source.split('\n').map((line, index) => `${index + lineOffset}:${line}`).join('\n'); + return source.split('\n').map((line, index) => `${index + lineOffset}: ${line}`).join('\n'); } export const createUIMessage = (text: string, mentions: MentionData[], selectedSearchScopes: SearchScope[]): CreateUIMessage => { From 7927a8489ac537e695c1d71d893cede3a2344272 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 16:24:04 -0700 Subject: [PATCH 05/43] migrate readFile --- packages/web/src/features/chat/agent.ts | 20 +-- .../components/chatThread/detailsCard.tsx | 9 ++ .../tools/readFileToolComponent.tsx | 13 +- packages/web/src/features/chat/tools/index.ts | 1 - .../src/features/chat/tools/readFile.test.ts | 118 ------------------ .../web/src/features/chat/tools/searchCode.ts | 12 +- packages/web/src/features/chat/types.ts | 5 +- packages/web/src/features/tools/adapters.ts | 36 ++++++ .../src/features/{chat => }/tools/readFile.ts | 71 ++++++----- .../features/{chat => }/tools/readFile.txt | 0 packages/web/src/features/tools/registry.ts | 23 ++++ packages/web/src/features/tools/types.ts | 22 ++++ packages/web/src/features/tools/weather.ts | 35 ++++++ packages/web/src/features/tools/weather.txt | 1 + 14 files changed, 190 insertions(+), 176 deletions(-) delete mode 100644 packages/web/src/features/chat/tools/readFile.test.ts create mode 100644 packages/web/src/features/tools/adapters.ts rename packages/web/src/features/{chat => }/tools/readFile.ts (64%) rename packages/web/src/features/{chat => }/tools/readFile.txt (100%) create mode 100644 packages/web/src/features/tools/registry.ts create mode 100644 packages/web/src/features/tools/types.ts create mode 100644 packages/web/src/features/tools/weather.ts create mode 100644 packages/web/src/features/tools/weather.txt diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 57f2b1448..8ff1ece8f 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -15,7 +15,9 @@ import { import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants"; -import { createCodeSearchTool, findSymbolDefinitionsTool, findSymbolReferencesTool, listCommitsTool, listReposTool, readFileTool } from "./tools"; +import { findSymbolDefinitionsTool, findSymbolReferencesTool, listCommitsTool, listReposTool, searchCodeTool } from "./tools"; +import { toVercelAITool } from "@/features/tools/adapters"; +import { readFileDefinition } from "@/features/tools/readFile"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; @@ -199,8 +201,8 @@ const createAgentStream = async ({ messages: inputMessages, system: systemPrompt, tools: { - [toolNames.searchCode]: createCodeSearchTool(selectedRepos), - [toolNames.readFile]: readFileTool, + [toolNames.searchCode]: searchCodeTool, + [toolNames.readFile]: toVercelAITool(readFileDefinition), [toolNames.findSymbolReferences]: findSymbolReferencesTool, [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, [toolNames.listRepos]: listReposTool, @@ -226,11 +228,11 @@ const createAgentStream = async ({ if (toolName === toolNames.readFile) { onWriteSource({ type: 'file', - language: output.language, - repo: output.repository, - path: output.path, - revision: output.revision, - name: output.path.split('/').pop() ?? output.path, + language: output.metadata.language, + repo: output.metadata.repository, + path: output.metadata.path, + revision: output.metadata.revision, + name: output.metadata.path.split('/').pop() ?? output.metadata.path, }); } else if (toolName === toolNames.searchCode) { output.files.forEach((file) => { @@ -310,6 +312,8 @@ const createPrompt = ({ The user has explicitly selected the following repositories for analysis: ${repos.map(repo => `- ${repo}`).join('\n')} + + When calling the searchCode tool, always pass these repositories as \`filterByRepos\` to scope results to the selected repositories. ` : ''} diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index a3f029def..5673a590b 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -208,7 +208,16 @@ const DetailsCardComponent = ({ part={part} /> ) + case 'data-source': + case 'dynamic-tool': + case 'file': + case 'source-document': + case 'source-url': + case 'step-start': + return null; default: + // Guarantees this switch-case to be exhaustive + part satisfies never; return null; } })} diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 949486d31..dc2c7fcb1 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -2,7 +2,7 @@ import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; -import { ReadFileToolUIPart } from "@/features/chat/tools"; +import { ReadFileToolUIPart } from "@/features/tools/registry"; import { isServiceError } from "@/lib/utils"; import { EyeIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -14,7 +14,7 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => const onCopy = () => { if (part.state !== 'output-available' || isServiceError(part.output)) return false; - navigator.clipboard.writeText(part.output.source); + navigator.clipboard.writeText(part.output.output); return true; }; @@ -30,7 +30,10 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => if (isServiceError(part.output)) { return 'Failed to read file'; } - return `Read ${part.output.path}`; + if (part.output.metadata.isTruncated || part.output.metadata.startLine > 1) { + return `Read ${part.output.metadata.path} (lines ${part.output.metadata.startLine}–${part.output.metadata.endLine})`; + } + return `Read ${part.output.metadata.path}`; } }, [part]); @@ -60,8 +63,8 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => Failed with the following error: {part.output.message} ) : ( )} diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts index 91bc0d7a0..bf1ef0069 100644 --- a/packages/web/src/features/chat/tools/index.ts +++ b/packages/web/src/features/chat/tools/index.ts @@ -10,7 +10,6 @@ export * from "./findSymbolReferences"; export * from "./findSymbolDefinitions"; -export * from "./readFile"; export * from "./searchCode"; export * from "./listRepos"; export * from "./listCommits"; diff --git a/packages/web/src/features/chat/tools/readFile.test.ts b/packages/web/src/features/chat/tools/readFile.test.ts deleted file mode 100644 index 8ede158b1..000000000 --- a/packages/web/src/features/chat/tools/readFile.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { readFileTool } from './readFile'; - -vi.mock('@/features/git', () => ({ - getFileSource: vi.fn(), -})); - -vi.mock('../logger', () => ({ - logger: { debug: vi.fn() }, -})); - -vi.mock('./readFile.txt', () => ({ default: 'description' })); - -import { getFileSource } from '@/features/git'; - -const mockGetFileSource = vi.mocked(getFileSource); - -function makeSource(source: string) { - mockGetFileSource.mockResolvedValue({ - source, - path: 'test.ts', - repo: 'github.com/org/repo', - language: 'typescript', - revision: 'HEAD', - } as any); -} - -describe('readFileTool byte cap', () => { - test('truncates output at 5KB and shows byte cap message', async () => { - // Each line is ~100 bytes; 60 lines = ~6KB, over the 5KB cap - const lines = Array.from({ length: 60 }, (_, i) => `line${i + 1}: ${'x'.repeat(90)}`).join('\n'); - makeSource(lines); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain('Output capped at 5KB'); - expect('source' in result && result.source).toContain('Use offset='); - expect('source' in result && result.source).toContain('Output capped at 5KB'); - }); - - test('does not cap output under 5KB', async () => { - makeSource('short line\n'.repeat(10).trimEnd()); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).not.toContain('Output capped at 5KB'); - }); -}); - -describe('readFileTool hasMoreLines message', () => { - test('appends continuation message when file is truncated', async () => { - const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n'); - makeSource(lines); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain('Showing lines 1-500 of 600'); - expect('source' in result && result.source).toContain('offset=501'); - }); - - test('shows end of file message when all lines fit', async () => { - makeSource('line1\nline2\nline3'); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).not.toContain('Showing lines'); - expect('source' in result && result.source).toContain('End of file - 3 lines total'); - }); - - test('continuation message reflects offset parameter', async () => { - const lines = Array.from({ length: 600 }, (_, i) => `line${i + 1}`).join('\n'); - makeSource(lines); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo', offset: 100 }, {} as any); - expect('source' in result && result.source).toContain('Showing lines 100-599 of 600'); - expect('source' in result && result.source).toContain('offset=600'); - }); -}); - -describe('readFileTool line truncation', () => { - test('does not truncate lines under the limit', async () => { - const line = 'x'.repeat(100); - makeSource(line); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain(line); - expect('source' in result && result.source).not.toContain('line truncated'); - }); - - test('truncates lines longer than 2000 chars', async () => { - const line = 'x'.repeat(3000); - makeSource(line); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); - expect('source' in result && result.source).not.toContain('x'.repeat(2001)); - }); - - test('truncates only the long lines, leaving normal lines intact', async () => { - const longLine = 'a'.repeat(3000); - const normalLine = 'normal line'; - makeSource(`${normalLine}\n${longLine}\n${normalLine}`); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain(normalLine); - expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); - }); - - test('truncates a line at exactly 2001 chars', async () => { - makeSource('b'.repeat(2001)); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).toContain('... (line truncated to 2000 chars)'); - }); - - test('does not truncate a line at exactly 2000 chars', async () => { - makeSource('c'.repeat(2000)); - - const result = await readFileTool.execute!({ path: 'test.ts', repository: 'github.com/org/repo' }, {} as any); - expect('source' in result && result.source).not.toContain('line truncated'); - }); -}); diff --git a/packages/web/src/features/chat/tools/searchCode.ts b/packages/web/src/features/chat/tools/searchCode.ts index f46f9d3cd..d8d531c37 100644 --- a/packages/web/src/features/chat/tools/searchCode.ts +++ b/packages/web/src/features/chat/tools/searchCode.ts @@ -10,7 +10,7 @@ import description from './searchCode.txt'; const DEFAULT_SEARCH_LIMIT = 100; -export const createCodeSearchTool = (selectedRepos: string[]) => tool({ +export const searchCodeTool = tool({ description, inputSchema: z.object({ query: z @@ -64,10 +64,6 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({ }) => { logger.debug('searchCode', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); - if (selectedRepos.length > 0) { - query += ` reposet:${selectedRepos.join(',')}`; - } - if (repos.length > 0) { query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; } @@ -115,7 +111,7 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({ }, }); -export type SearchCodeTool = InferUITool>; -export type SearchCodeToolInput = InferToolInput>; -export type SearchCodeToolOutput = InferToolOutput>; +export type SearchCodeTool = InferUITool; +export type SearchCodeToolInput = InferToolInput; +export type SearchCodeToolOutput = InferToolOutput; export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>; diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index ec2395787..7585229dc 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -3,8 +3,9 @@ import { BaseEditor, Descendant } from "slate"; import { HistoryEditor } from "slate-history"; import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; -import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, ReadFileTool, SearchCodeTool, ListReposTool, ListCommitsTool } from "./tools"; +import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, SearchCodeTool, ListReposTool, ListCommitsTool } from "./tools"; import { toolNames } from "./constants"; +import { ToolTypes } from "@/features/tools/registry"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; const fileSourceSchema = z.object({ @@ -80,7 +81,7 @@ export type SBChatMessageMetadata = z.infer; export type SBChatMessageToolTypes = { [toolNames.searchCode]: SearchCodeTool, - [toolNames.readFile]: ReadFileTool, + [toolNames.readFile]: ToolTypes['readFile'], [toolNames.findSymbolReferences]: FindSymbolReferencesTool, [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool, [toolNames.listRepos]: ListReposTool, diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts new file mode 100644 index 000000000..f7f574f1e --- /dev/null +++ b/packages/web/src/features/tools/adapters.ts @@ -0,0 +1,36 @@ +import { tool } from "ai"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { ToolDefinition } from "./types"; + +export function toVercelAITool( + def: ToolDefinition, +) { + return tool({ + description: def.description, + inputSchema: def.inputSchema, + execute: def.execute, + toModelOutput: ({ output }) => ({ + type: "content", + value: [{ type: "text", text: output.output }], + }), + }); +} + +export function registerMcpTool( + server: McpServer, + def: ToolDefinition, +) { + // Widening .shape to z.ZodRawShape (its base constraint) gives TypeScript a + // concrete InputArgs so it can fully resolve BaseToolCallback's conditional + // type. def.inputSchema.parse() recovers the correctly typed value inside. + server.registerTool( + def.name, + { description: def.description, inputSchema: def.inputSchema.shape as z.ZodRawShape }, + async (input) => { + const parsed = def.inputSchema.parse(input); + const result = await def.execute(parsed); + return { content: [{ type: "text" as const, text: result.output }] }; + }, + ); +} diff --git a/packages/web/src/features/chat/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts similarity index 64% rename from packages/web/src/features/chat/tools/readFile.ts rename to packages/web/src/features/tools/readFile.ts index 78c5996fa..8d61753fc 100644 --- a/packages/web/src/features/chat/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -1,10 +1,11 @@ import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; import { isServiceError } from "@/lib/utils"; import { getFileSource } from "@/features/git"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './readFile.txt'; +import { createLogger } from "@sourcebot/shared"; +import { ToolDefinition } from "./types"; +import description from "./readFile.txt"; + +const logger = createLogger('tool-readFile'); // NOTE: if you change these values, update readFile.txt to match. const READ_FILES_MAX_LINES = 500; @@ -13,20 +14,33 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; const MAX_BYTES = 5 * 1024; const MAX_BYTES_LABEL = `${MAX_BYTES / 1024}KB`; -export const readFileTool = tool({ +const readFileShape = { + path: z.string().describe("The path to the file"), + repository: z.string().describe("The repository to read the file from"), + offset: z.number().int().positive() + .optional() + .describe("Line number to start reading from (1-indexed). Omit to start from the beginning."), + limit: z.number().int().positive() + .optional() + .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), +}; + +export type ReadFileMetadata = { + path: string; + repository: string; + language: string; + startLine: number; + endLine: number; + isTruncated: boolean; + revision: string; +}; + +export const readFileDefinition: ToolDefinition<"readFile", typeof readFileShape, ReadFileMetadata> = { + name: "readFile", description, - inputSchema: z.object({ - path: z.string().describe("The path to the file"), - repository: z.string().describe("The repository to read the file from"), - offset: z.number().int().positive() - .optional() - .describe("Line number to start reading from (1-indexed). Omit to start from the beginning."), - limit: z.number().int().positive() - .optional() - .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), - }), + inputSchema: z.object(readFileShape), execute: async ({ path, repository, offset, limit }) => { - logger.debug('readFiles', { path, repository, offset, limit }); + logger.debug('readFile', { path, repository, offset, limit }); // @todo: make revision configurable. const revision = "HEAD"; @@ -81,27 +95,16 @@ export const readFileTool = tool({ output += `\n`; - return { + const metadata: ReadFileMetadata = { path: fileSource.path, repository: fileSource.repo, language: fileSource.language, - source: output, - totalLines: lines.length, + startLine, + endLine: lastReadLine, + isTruncated: truncatedByBytes || truncatedByLines, revision, }; - }, - toModelOutput: ({ output }) => { - return { - type: 'content', - value: [{ - type: 'text', - text: output.source, - }] - } - } -}); -export type ReadFileTool = InferUITool; -export type ReadFileToolInput = InferToolInput; -export type ReadFileToolOutput = InferToolOutput; -export type ReadFileToolUIPart = ToolUIPart<{ [toolNames.readFile]: ReadFileTool }> + return { output, metadata }; + }, +}; diff --git a/packages/web/src/features/chat/tools/readFile.txt b/packages/web/src/features/tools/readFile.txt similarity index 100% rename from packages/web/src/features/chat/tools/readFile.txt rename to packages/web/src/features/tools/readFile.txt diff --git a/packages/web/src/features/tools/registry.ts b/packages/web/src/features/tools/registry.ts new file mode 100644 index 000000000..72d49bfe9 --- /dev/null +++ b/packages/web/src/features/tools/registry.ts @@ -0,0 +1,23 @@ +import { InferUITool, ToolUIPart } from "ai"; +import { weatherDefinition } from "./weather"; +import { readFileDefinition } from "./readFile"; +import { toVercelAITool } from "./adapters"; + +export const toolRegistry = { + [weatherDefinition.name]: weatherDefinition, + [readFileDefinition.name]: readFileDefinition, +} as const; + +// Vercel AI tool wrappers, keyed by tool name — pass directly to streamText. +export const vercelAITools = { + [weatherDefinition.name]: toVercelAITool(weatherDefinition), + [readFileDefinition.name]: toVercelAITool(readFileDefinition), +} as const; + +// Derive SBChatMessageToolTypes from the registry so that adding a tool here +// automatically updates the message type. Import this into chat/types.ts. +export type ToolTypes = { + [K in keyof typeof vercelAITools]: InferUITool; +}; + +export type ReadFileToolUIPart = ToolUIPart<{ readFile: ToolTypes['readFile'] }>; diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts new file mode 100644 index 000000000..a69988516 --- /dev/null +++ b/packages/web/src/features/tools/types.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +// TShape is constrained to ZodRawShape (i.e. Record) so that +// both adapters receive a statically-known object schema. Tool inputs are +// always key-value objects, so this constraint is semantically correct and also +// lets the MCP adapter pass `.shape` (a ZodRawShapeCompat) to registerTool, +// which avoids an unresolvable conditional type in BaseToolCallback. +export interface ToolDefinition< + TName extends string, + TShape extends z.ZodRawShape, + TMetadata = Record, +> { + name: TName; + description: string; + inputSchema: z.ZodObject; + execute: (input: z.infer>) => Promise>; +} + +export interface ToolResult> { + output: string; + metadata: TMetadata; +} diff --git a/packages/web/src/features/tools/weather.ts b/packages/web/src/features/tools/weather.ts new file mode 100644 index 000000000..b6d45baf8 --- /dev/null +++ b/packages/web/src/features/tools/weather.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { ToolDefinition } from "./types"; +import description from "./weather.txt"; + +// ----------------------------------------------------------------------- +// Weather tool +// ----------------------------------------------------------------------- + +const weatherShape = { + city: z.string().describe("The name of the city to get the weather for."), +}; + +export type WeatherMetadata = { + city: string; + temperatureC: number; + condition: string; +}; + +export const weatherDefinition: ToolDefinition<"weather", typeof weatherShape, WeatherMetadata> = { + name: "weather", + description, + inputSchema: z.object(weatherShape), + execute: async ({ city }) => { + // Dummy response + const metadata: WeatherMetadata = { + city, + temperatureC: 22, + condition: "Partly cloudy", + }; + return { + output: `The weather in ${city} is ${metadata.condition} with a temperature of ${metadata.temperatureC}°C.`, + metadata, + }; + }, +}; diff --git a/packages/web/src/features/tools/weather.txt b/packages/web/src/features/tools/weather.txt new file mode 100644 index 000000000..08e2b69ef --- /dev/null +++ b/packages/web/src/features/tools/weather.txt @@ -0,0 +1 @@ +Get the current weather for a given city. From e9c4b3dfe0a77d713ca8932318dc5c086c909f7b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 16:57:42 -0700 Subject: [PATCH 06/43] migrate listRepos & listCommits --- packages/web/src/features/chat/agent.ts | 8 ++- .../tools/listCommitsToolComponent.tsx | 8 +-- .../tools/listReposToolComponent.tsx | 8 +-- packages/web/src/features/chat/tools/index.ts | 2 - .../src/features/chat/tools/listCommits.ts | 50 -------------- .../web/src/features/chat/tools/listRepos.ts | 28 -------- packages/web/src/features/chat/types.ts | 6 +- .../web/src/features/tools/listCommits.ts | 69 +++++++++++++++++++ .../features/{chat => }/tools/listCommits.txt | 0 packages/web/src/features/tools/listRepos.ts | 48 +++++++++++++ .../features/{chat => }/tools/listRepos.txt | 0 packages/web/src/features/tools/registry.ts | 8 +++ 12 files changed, 141 insertions(+), 94 deletions(-) delete mode 100644 packages/web/src/features/chat/tools/listCommits.ts delete mode 100644 packages/web/src/features/chat/tools/listRepos.ts create mode 100644 packages/web/src/features/tools/listCommits.ts rename packages/web/src/features/{chat => }/tools/listCommits.txt (100%) create mode 100644 packages/web/src/features/tools/listRepos.ts rename packages/web/src/features/{chat => }/tools/listRepos.txt (100%) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 8ff1ece8f..aa30f9ed0 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -15,9 +15,11 @@ import { import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants"; -import { findSymbolDefinitionsTool, findSymbolReferencesTool, listCommitsTool, listReposTool, searchCodeTool } from "./tools"; +import { findSymbolDefinitionsTool, findSymbolReferencesTool, searchCodeTool } from "./tools"; import { toVercelAITool } from "@/features/tools/adapters"; import { readFileDefinition } from "@/features/tools/readFile"; +import { listCommitsDefinition } from "@/features/tools/listCommits"; +import { listReposDefinition } from "@/features/tools/listRepos"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; @@ -205,8 +207,8 @@ const createAgentStream = async ({ [toolNames.readFile]: toVercelAITool(readFileDefinition), [toolNames.findSymbolReferences]: findSymbolReferencesTool, [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, - [toolNames.listRepos]: listReposTool, - [toolNames.listCommits]: listCommitsTool, + [toolNames.listRepos]: toVercelAITool(listReposDefinition), + [toolNames.listCommits]: toVercelAITool(listCommitsDefinition), }, temperature: env.SOURCEBOT_CHAT_MODEL_TEMPERATURE, stopWhen: [ diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index f1cc6890e..dc80477ae 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ListCommitsToolUIPart } from "@/features/chat/tools"; +import { ListCommitsToolUIPart } from "@/features/tools/registry"; import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { ToolHeader, TreeList } from "./shared"; @@ -41,14 +41,14 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart ) : ( <> - {part.output.commits.length === 0 ? ( + {part.output.metadata.commits.length === 0 ? ( No commits found ) : (
- Found {part.output.commits.length} of {part.output.totalCount} total commits: + Found {part.output.metadata.commits.length} of {part.output.metadata.totalCount} total commits:
- {part.output.commits.map((commit) => ( + {part.output.metadata.commits.map((commit) => (
diff --git a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx index 3639b598e..c6d02cefd 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ListReposToolUIPart } from "@/features/chat/tools"; +import { ListReposToolUIPart } from "@/features/tools/registry"; import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { ToolHeader, TreeList } from "./shared"; @@ -41,14 +41,14 @@ export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart }) ) : ( <> - {part.output.length === 0 ? ( + {part.output.metadata.repos.length === 0 ? ( No repositories found ) : (
- Found {part.output.length} repositories: + Found {part.output.metadata.repos.length} repositories:
- {part.output.map((repoName, index) => ( + {part.output.metadata.repos.map((repoName, index) => (
{repoName} diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts index bf1ef0069..cf4481b14 100644 --- a/packages/web/src/features/chat/tools/index.ts +++ b/packages/web/src/features/chat/tools/index.ts @@ -11,5 +11,3 @@ export * from "./findSymbolReferences"; export * from "./findSymbolDefinitions"; export * from "./searchCode"; -export * from "./listRepos"; -export * from "./listCommits"; diff --git a/packages/web/src/features/chat/tools/listCommits.ts b/packages/web/src/features/chat/tools/listCommits.ts deleted file mode 100644 index 4cb2ffaf1..000000000 --- a/packages/web/src/features/chat/tools/listCommits.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { listCommits } from "@/features/git"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './listCommits.txt'; - -export const listCommitsTool = tool({ - description, - inputSchema: z.object({ - repository: z.string().describe("The repository to list commits from"), - query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), - since: z.string().describe("Start date for commit range (e.g., '30 days ago', '2024-01-01', 'last week')").optional(), - until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), - author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), - maxCount: z.number().describe("Maximum number of commits to return (default: 50)").optional(), - }), - execute: async ({ repository, query, since, until, author, maxCount }) => { - logger.debug('listCommits', { repository, query, since, until, author, maxCount }); - const response = await listCommits({ - repo: repository, - query, - since, - until, - author, - maxCount, - }); - - if (isServiceError(response)) { - throw new Error(response.message); - } - - return { - commits: response.commits.map((commit) => ({ - hash: commit.hash, - date: commit.date, - message: commit.message, - author: `${commit.author_name} <${commit.author_email}>`, - refs: commit.refs, - })), - totalCount: response.totalCount, - }; - } -}); - -export type ListCommitsTool = InferUITool; -export type ListCommitsToolInput = InferToolInput; -export type ListCommitsToolOutput = InferToolOutput; -export type ListCommitsToolUIPart = ToolUIPart<{ [toolNames.listCommits]: ListCommitsTool }>; diff --git a/packages/web/src/features/chat/tools/listRepos.ts b/packages/web/src/features/chat/tools/listRepos.ts deleted file mode 100644 index 6a68d2869..000000000 --- a/packages/web/src/features/chat/tools/listRepos.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { listReposQueryParamsSchema } from "@/lib/schemas"; -import { ListReposQueryParams } from "@/lib/types"; -import { listRepos } from "@/app/api/(server)/repos/listReposApi"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './listRepos.txt'; - -export const listReposTool = tool({ - description, - inputSchema: listReposQueryParamsSchema, - execute: async (request: ListReposQueryParams) => { - logger.debug('listRepos', request); - const reposResponse = await listRepos(request); - - if (isServiceError(reposResponse)) { - throw new Error(reposResponse.message); - } - - return reposResponse.data.map((repo) => repo.repoName); - } -}); - -export type ListReposTool = InferUITool; -export type ListReposToolInput = InferToolInput; -export type ListReposToolOutput = InferToolOutput; -export type ListReposToolUIPart = ToolUIPart<{ [toolNames.listRepos]: ListReposTool }>; diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 7585229dc..a6523f8e4 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -3,7 +3,7 @@ import { BaseEditor, Descendant } from "slate"; import { HistoryEditor } from "slate-history"; import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; -import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, SearchCodeTool, ListReposTool, ListCommitsTool } from "./tools"; +import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, SearchCodeTool } from "./tools"; import { toolNames } from "./constants"; import { ToolTypes } from "@/features/tools/registry"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; @@ -84,8 +84,8 @@ export type SBChatMessageToolTypes = { [toolNames.readFile]: ToolTypes['readFile'], [toolNames.findSymbolReferences]: FindSymbolReferencesTool, [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool, - [toolNames.listRepos]: ListReposTool, - [toolNames.listCommits]: ListCommitsTool, + [toolNames.listRepos]: ToolTypes['listRepos'], + [toolNames.listCommits]: ToolTypes['listCommits'], } export type SBChatMessageDataParts = { diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts new file mode 100644 index 000000000..1f32cf599 --- /dev/null +++ b/packages/web/src/features/tools/listCommits.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { listCommits } from "@/features/git"; +import { createLogger } from "@sourcebot/shared"; +import { ToolDefinition } from "./types"; +import description from "./listCommits.txt"; + +const logger = createLogger('tool-listCommits'); + +const listCommitsShape = { + repository: z.string().describe("The repository to list commits from"), + query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), + since: z.string().describe("Start date for commit range (e.g., '30 days ago', '2024-01-01', 'last week')").optional(), + until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), + author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), + maxCount: z.number().describe("Maximum number of commits to return (default: 50)").optional(), +}; + +export type Commit = { + hash: string; + date: string; + message: string; + author: string; + refs: string; +}; + +export type ListCommitsMetadata = { + commits: Commit[]; + totalCount: number; +}; + +export const listCommitsDefinition: ToolDefinition<"listCommits", typeof listCommitsShape, ListCommitsMetadata> = { + name: "listCommits", + description, + inputSchema: z.object(listCommitsShape), + execute: async ({ repository, query, since, until, author, maxCount }) => { + logger.debug('listCommits', { repository, query, since, until, author, maxCount }); + const response = await listCommits({ + repo: repository, + query, + since, + until, + author, + maxCount, + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const commits: Commit[] = response.commits.map((commit) => ({ + hash: commit.hash, + date: commit.date, + message: commit.message, + author: `${commit.author_name} <${commit.author_email}>`, + refs: commit.refs, + })); + + const metadata: ListCommitsMetadata = { + commits, + totalCount: response.totalCount, + }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/chat/tools/listCommits.txt b/packages/web/src/features/tools/listCommits.txt similarity index 100% rename from packages/web/src/features/chat/tools/listCommits.txt rename to packages/web/src/features/tools/listCommits.txt diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts new file mode 100644 index 000000000..93cf5dd47 --- /dev/null +++ b/packages/web/src/features/tools/listRepos.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { listRepos } from "@/app/api/(server)/repos/listReposApi"; +import { ToolDefinition } from "./types"; +import description from './listRepos.txt'; + +const listReposShape = { + page: z.coerce.number().int().positive().default(1).describe("Page number for pagination"), + perPage: z.coerce.number().int().positive().max(100).default(30).describe("Number of repositories per page (max 100)"), + sort: z.enum(['name', 'pushed']).default('name').describe("Sort repositories by name or last pushed date"), + direction: z.enum(['asc', 'desc']).default('asc').describe("Sort direction"), + query: z.string().optional().describe("Filter repositories by name"), +}; + +type ListReposMetadata = { + repos: string[]; +}; + +export const listReposDefinition: ToolDefinition< + 'listRepos', + typeof listReposShape, + ListReposMetadata +> = { + name: 'listRepos', + description, + inputSchema: z.object(listReposShape), + execute: async ({ page, perPage, sort, direction, query }) => { + const reposResponse = await listRepos({ + page, + perPage, + sort, + direction, + query, + }); + + if (isServiceError(reposResponse)) { + throw new Error(reposResponse.message); + } + + const repos = reposResponse.data.map((repo) => repo.repoName); + const metadata: ListReposMetadata = { repos }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/chat/tools/listRepos.txt b/packages/web/src/features/tools/listRepos.txt similarity index 100% rename from packages/web/src/features/chat/tools/listRepos.txt rename to packages/web/src/features/tools/listRepos.txt diff --git a/packages/web/src/features/tools/registry.ts b/packages/web/src/features/tools/registry.ts index 72d49bfe9..1bef7b139 100644 --- a/packages/web/src/features/tools/registry.ts +++ b/packages/web/src/features/tools/registry.ts @@ -1,17 +1,23 @@ import { InferUITool, ToolUIPart } from "ai"; import { weatherDefinition } from "./weather"; import { readFileDefinition } from "./readFile"; +import { listCommitsDefinition } from "./listCommits"; +import { listReposDefinition } from "./listRepos"; import { toVercelAITool } from "./adapters"; export const toolRegistry = { [weatherDefinition.name]: weatherDefinition, [readFileDefinition.name]: readFileDefinition, + [listCommitsDefinition.name]: listCommitsDefinition, + [listReposDefinition.name]: listReposDefinition, } as const; // Vercel AI tool wrappers, keyed by tool name — pass directly to streamText. export const vercelAITools = { [weatherDefinition.name]: toVercelAITool(weatherDefinition), [readFileDefinition.name]: toVercelAITool(readFileDefinition), + [listCommitsDefinition.name]: toVercelAITool(listCommitsDefinition), + [listReposDefinition.name]: toVercelAITool(listReposDefinition), } as const; // Derive SBChatMessageToolTypes from the registry so that adding a tool here @@ -21,3 +27,5 @@ export type ToolTypes = { }; export type ReadFileToolUIPart = ToolUIPart<{ readFile: ToolTypes['readFile'] }>; +export type ListCommitsToolUIPart = ToolUIPart<{ listCommits: ToolTypes['listCommits'] }>; +export type ListReposToolUIPart = ToolUIPart<{ listRepos: ToolTypes['listRepos'] }>; From 92fd313ca957f6f3f48342224b905b1ff0c9b9de Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 17:36:53 -0700 Subject: [PATCH 07/43] migrate the rest --- packages/web/src/features/chat/agent.ts | 29 ++-- .../chatThread/chatThreadListItem.tsx | 8 +- .../findSymbolDefinitionsToolComponent.tsx | 4 +- .../findSymbolReferencesToolComponent.tsx | 4 +- .../tools/listCommitsToolComponent.tsx | 2 +- .../tools/listReposToolComponent.tsx | 2 +- .../tools/readFileToolComponent.tsx | 2 +- .../tools/searchCodeToolComponent.tsx | 4 +- packages/web/src/features/chat/constants.ts | 23 --- packages/web/src/features/chat/tools.ts | 27 ++++ .../chat/tools/findSymbolDefinitions.ts | 48 ------- .../chat/tools/findSymbolReferences.ts | 48 ------- packages/web/src/features/chat/tools/index.ts | 13 -- .../web/src/features/chat/tools/searchCode.ts | 117 ---------------- packages/web/src/features/chat/types.ts | 15 +- .../features/tools/findSymbolDefinitions.ts | 62 ++++++++ .../tools/findSymbolDefinitions.txt | 0 .../features/tools/findSymbolReferences.ts | 69 +++++++++ .../{chat => }/tools/findSymbolReferences.txt | 0 packages/web/src/features/tools/index.ts | 7 + packages/web/src/features/tools/registry.ts | 31 ---- packages/web/src/features/tools/searchCode.ts | 132 ++++++++++++++++++ .../features/{chat => }/tools/searchCode.txt | 0 packages/web/src/features/tools/weather.ts | 35 ----- packages/web/src/features/tools/weather.txt | 1 - 25 files changed, 327 insertions(+), 356 deletions(-) create mode 100644 packages/web/src/features/chat/tools.ts delete mode 100644 packages/web/src/features/chat/tools/findSymbolDefinitions.ts delete mode 100644 packages/web/src/features/chat/tools/findSymbolReferences.ts delete mode 100644 packages/web/src/features/chat/tools/index.ts delete mode 100644 packages/web/src/features/chat/tools/searchCode.ts create mode 100644 packages/web/src/features/tools/findSymbolDefinitions.ts rename packages/web/src/features/{chat => }/tools/findSymbolDefinitions.txt (100%) create mode 100644 packages/web/src/features/tools/findSymbolReferences.ts rename packages/web/src/features/{chat => }/tools/findSymbolReferences.txt (100%) create mode 100644 packages/web/src/features/tools/index.ts delete mode 100644 packages/web/src/features/tools/registry.ts create mode 100644 packages/web/src/features/tools/searchCode.ts rename packages/web/src/features/{chat => }/tools/searchCode.txt (100%) delete mode 100644 packages/web/src/features/tools/weather.ts delete mode 100644 packages/web/src/features/tools/weather.txt diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index aa30f9ed0..6fcf34385 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -14,14 +14,14 @@ import { } from "ai"; import { randomUUID } from "crypto"; import _dedent from "dedent"; -import { ANSWER_TAG, FILE_REFERENCE_PREFIX, toolNames } from "./constants"; -import { findSymbolDefinitionsTool, findSymbolReferencesTool, searchCodeTool } from "./tools"; -import { toVercelAITool } from "@/features/tools/adapters"; +import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; +import { findSymbolReferencesDefinition } from "@/features/tools/findSymbolReferences"; +import { findSymbolDefinitionsDefinition } from "@/features/tools/findSymbolDefinitions"; import { readFileDefinition } from "@/features/tools/readFile"; -import { listCommitsDefinition } from "@/features/tools/listCommits"; -import { listReposDefinition } from "@/features/tools/listRepos"; +import { searchCodeDefinition } from "@/features/tools/searchCode"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; +import { tools } from "./tools"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -202,14 +202,7 @@ const createAgentStream = async ({ providerOptions, messages: inputMessages, system: systemPrompt, - tools: { - [toolNames.searchCode]: searchCodeTool, - [toolNames.readFile]: toVercelAITool(readFileDefinition), - [toolNames.findSymbolReferences]: findSymbolReferencesTool, - [toolNames.findSymbolDefinitions]: findSymbolDefinitionsTool, - [toolNames.listRepos]: toVercelAITool(listReposDefinition), - [toolNames.listCommits]: toVercelAITool(listCommitsDefinition), - }, + tools, temperature: env.SOURCEBOT_CHAT_MODEL_TEMPERATURE, stopWhen: [ stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT), @@ -227,7 +220,7 @@ const createAgentStream = async ({ return; } - if (toolName === toolNames.readFile) { + if (toolName === readFileDefinition.name) { onWriteSource({ type: 'file', language: output.metadata.language, @@ -236,8 +229,8 @@ const createAgentStream = async ({ revision: output.metadata.revision, name: output.metadata.path.split('/').pop() ?? output.metadata.path, }); - } else if (toolName === toolNames.searchCode) { - output.files.forEach((file) => { + } else if (toolName === searchCodeDefinition.name) { + output.metadata.files.forEach((file) => { onWriteSource({ type: 'file', language: file.language, @@ -247,8 +240,8 @@ const createAgentStream = async ({ name: file.fileName.split('/').pop() ?? file.fileName, }); }); - } else if (toolName === toolNames.findSymbolDefinitions || toolName === toolNames.findSymbolReferences) { - output.forEach((file) => { + } else if (toolName === findSymbolDefinitionsDefinition.name || toolName === findSymbolReferencesDefinition.name) { + output.metadata.files.forEach((file) => { onWriteSource({ type: 'file', language: file.language, diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index 1734a04d5..9be161fde 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -13,7 +13,6 @@ import { AnswerCard } from './answerCard'; import { DetailsCard } from './detailsCard'; import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer'; import { ReferencedSourcesListView } from './referencedSourcesListView'; -import { uiVisiblePartTypes } from '../../constants'; import isEqual from "fast-deep-equal/react"; interface ChatThreadListItemProps { @@ -102,7 +101,12 @@ const ChatThreadListItemComponent = forwardRef { - return uiVisiblePartTypes.includes(part.type); + // Only include text, reasoning, and tool parts + return ( + part.type === 'text' || + part.type === 'reasoning' || + part.type.startsWith('tool-') + ) }) ) // Then, filter out any steps that are empty diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 792efd434..828a710c4 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -43,11 +43,11 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD ) : ( <> - {part.output.length === 0 ? ( + {part.output.metadata.files.length === 0 ? ( No matches found ) : ( - {part.output.map((file) => { + {part.output.metadata.files.map((file) => { return ( ) : ( <> - {part.output.length === 0 ? ( + {part.output.metadata.files.length === 0 ? ( No matches found ) : ( - {part.output.map((file) => { + {part.output.metadata.files.map((file) => { return ( ) : ( <> - {part.output.files.length === 0 ? ( + {part.output.metadata.files.length === 0 ? ( No matches found ) : ( - {part.output.files.map((file) => { + {part.output.metadata.files.map((file) => { return ( ; +export type ListCommitsToolUIPart = ToolUIPart<{ listCommits: SBChatMessageToolTypes['listCommits'] }>; +export type ListReposToolUIPart = ToolUIPart<{ listRepos: SBChatMessageToolTypes['listRepos'] }>; +export type SearchCodeToolUIPart = ToolUIPart<{ searchCode: SBChatMessageToolTypes['searchCode'] }>; +export type FindSymbolReferencesToolUIPart = ToolUIPart<{ findSymbolReferences: SBChatMessageToolTypes['findSymbolReferences'] }>; +export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ findSymbolDefinitions: SBChatMessageToolTypes['findSymbolDefinitions'] }>; diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitions.ts b/packages/web/src/features/chat/tools/findSymbolDefinitions.ts deleted file mode 100644 index 36952007e..000000000 --- a/packages/web/src/features/chat/tools/findSymbolDefinitions.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { findSearchBasedSymbolDefinitions } from "../../codeNav/api"; -import { addLineNumbers } from "../utils"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './findSymbolDefinitions.txt'; - -export const findSymbolDefinitionsTool = tool({ - description, - inputSchema: z.object({ - symbol: z.string().describe("The symbol to find definitions of"), - language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), - }), - execute: async ({ symbol, language, repository }) => { - logger.debug('findSymbolDefinitions', { symbol, language, repository }); - // @todo: make revision configurable. - const revision = "HEAD"; - - const response = await findSearchBasedSymbolDefinitions({ - symbolName: symbol, - language, - revisionName: revision, - repoName: repository, - }); - - if (isServiceError(response)) { - throw new Error(response.message); - } - - return response.files.map((file) => ({ - fileName: file.fileName, - repository: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), - revision, - })); - } -}); - -export type FindSymbolDefinitionsTool = InferUITool; -export type FindSymbolDefinitionsToolInput = InferToolInput; -export type FindSymbolDefinitionsToolOutput = InferToolOutput; -export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool }> diff --git a/packages/web/src/features/chat/tools/findSymbolReferences.ts b/packages/web/src/features/chat/tools/findSymbolReferences.ts deleted file mode 100644 index 265bec6d2..000000000 --- a/packages/web/src/features/chat/tools/findSymbolReferences.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { findSearchBasedSymbolReferences } from "../../codeNav/api"; -import { addLineNumbers } from "../utils"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import description from './findSymbolReferences.txt'; - -export const findSymbolReferencesTool = tool({ - description, - inputSchema: z.object({ - symbol: z.string().describe("The symbol to find references to"), - language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), - }), - execute: async ({ symbol, language, repository }) => { - logger.debug('findSymbolReferences', { symbol, language, repository }); - // @todo: make revision configurable. - const revision = "HEAD"; - - const response = await findSearchBasedSymbolReferences({ - symbolName: symbol, - language, - revisionName: "HEAD", - repoName: repository, - }); - - if (isServiceError(response)) { - throw new Error(response.message); - } - - return response.files.map((file) => ({ - fileName: file.fileName, - repository: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), - revision, - })); - }, -}); - -export type FindSymbolReferencesTool = InferUITool; -export type FindSymbolReferencesToolInput = InferToolInput; -export type FindSymbolReferencesToolOutput = InferToolOutput; -export type FindSymbolReferencesToolUIPart = ToolUIPart<{ [toolNames.findSymbolReferences]: FindSymbolReferencesTool }> diff --git a/packages/web/src/features/chat/tools/index.ts b/packages/web/src/features/chat/tools/index.ts deleted file mode 100644 index cf4481b14..000000000 --- a/packages/web/src/features/chat/tools/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -// @NOTE: When adding a new tool, follow these steps: -// 1. Add the tool to the `toolNames` constant in `constants.ts`. -// 2. Add the tool to the `SBChatMessageToolTypes` type in `types.ts`. -// 3. Add the tool to the `tools` prop in `agent.ts`. -// 4. If the tool is meant to be rendered in the UI: -// - Add the tool to the `uiVisiblePartTypes` constant in `constants.ts`. -// - Add the tool's component to the `DetailsCard` switch statement in `detailsCard.tsx`. -// -// - bk, 2025-07-25 - -export * from "./findSymbolReferences"; -export * from "./findSymbolDefinitions"; -export * from "./searchCode"; diff --git a/packages/web/src/features/chat/tools/searchCode.ts b/packages/web/src/features/chat/tools/searchCode.ts deleted file mode 100644 index d8d531c37..000000000 --- a/packages/web/src/features/chat/tools/searchCode.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { z } from "zod"; -import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "ai"; -import { isServiceError } from "@/lib/utils"; -import { search } from "@/features/search"; -import { addLineNumbers } from "../utils"; -import { toolNames } from "../constants"; -import { logger } from "../logger"; -import escapeStringRegexp from "escape-string-regexp"; -import description from './searchCode.txt'; - -const DEFAULT_SEARCH_LIMIT = 100; - -export const searchCodeTool = tool({ - description, - inputSchema: z.object({ - query: z - .string() - .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) - // Escape backslashes first, then quotes, and wrap in double quotes - // so the query is treated as a literal phrase (like grep). - .transform((val) => { - const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; - }), - useRegex: z - .boolean() - .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) - .optional(), - filterByRepos: z - .array(z.string()) - .describe(`Scope the search to the provided repositories.`) - .optional(), - filterByLanguages: z - .array(z.string()) - .describe(`Scope the search to the provided languages.`) - .optional(), - filterByFilepaths: z - .array(z.string()) - .describe(`Scope the search to the provided filepaths. Each filepath is a regular expression matched against the full file path.`) - .optional(), - caseSensitive: z - .boolean() - .describe(`Whether the search should be case sensitive (default: false).`) - .optional(), - ref: z - .string() - .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) - .optional(), - limit: z - .number() - .default(DEFAULT_SEARCH_LIMIT) - .describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) - .optional(), - }), - execute: async ({ - query, - useRegex = false, - filterByRepos: repos = [], - filterByLanguages: languages = [], - filterByFilepaths: filepaths = [], - caseSensitive = false, - ref, - limit = DEFAULT_SEARCH_LIMIT, - }) => { - logger.debug('searchCode', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); - - if (repos.length > 0) { - query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; - } - - if (languages.length > 0) { - query += ` (lang:${languages.join(' or lang:')})`; - } - - if (filepaths.length > 0) { - query += ` (file:${filepaths.join(' or file:')})`; - } - - if (ref) { - query += ` (rev:${ref})`; - } - - const response = await search({ - queryType: 'string', - query, - options: { - matches: limit, - contextLines: 3, - isCaseSensitivityEnabled: caseSensitive, - isRegexEnabled: useRegex, - } - }); - - if (isServiceError(response)) { - throw new Error(response.message); - } - - return { - files: response.files.map((file) => ({ - fileName: file.fileName.text, - repository: file.repository, - language: file.language, - matches: file.chunks.map(({ content, contentStart }) => { - return addLineNumbers(content, contentStart.lineNumber); - }), - // @todo: make revision configurable. - revision: 'HEAD', - })), - query, - } - }, -}); - -export type SearchCodeTool = InferUITool; -export type SearchCodeToolInput = InferToolInput; -export type SearchCodeToolOutput = InferToolOutput; -export type SearchCodeToolUIPart = ToolUIPart<{ [toolNames.searchCode]: SearchCodeTool }>; diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index a6523f8e4..5565a65be 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -1,12 +1,10 @@ -import { CreateUIMessage, UIMessage, UIMessagePart } from "ai"; +import { CreateUIMessage, InferUITool, UIMessage, UIMessagePart } from "ai"; import { BaseEditor, Descendant } from "slate"; import { HistoryEditor } from "slate-history"; import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; -import { FindSymbolDefinitionsTool, FindSymbolReferencesTool, SearchCodeTool } from "./tools"; -import { toolNames } from "./constants"; -import { ToolTypes } from "@/features/tools/registry"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; +import { tools } from "./tools"; const fileSourceSchema = z.object({ type: z.literal('file'), @@ -80,13 +78,8 @@ export const sbChatMessageMetadataSchema = z.object({ export type SBChatMessageMetadata = z.infer; export type SBChatMessageToolTypes = { - [toolNames.searchCode]: SearchCodeTool, - [toolNames.readFile]: ToolTypes['readFile'], - [toolNames.findSymbolReferences]: FindSymbolReferencesTool, - [toolNames.findSymbolDefinitions]: FindSymbolDefinitionsTool, - [toolNames.listRepos]: ToolTypes['listRepos'], - [toolNames.listCommits]: ToolTypes['listCommits'], -} + [K in keyof typeof tools]: InferUITool; +}; export type SBChatMessageDataParts = { // The `source` data type allows us to know what sources the LLM saw diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts new file mode 100644 index 000000000..209717034 --- /dev/null +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/api"; +import { addLineNumbers } from "@/features/chat/utils"; +import { createLogger } from "@sourcebot/shared"; +import { ToolDefinition } from "./types"; +import { FindSymbolFile } from "./findSymbolReferences"; +import description from "./findSymbolDefinitions.txt"; + +const logger = createLogger('tool-findSymbolDefinitions'); + +const findSymbolDefinitionsShape = { + symbol: z.string().describe("The symbol to find definitions of"), + language: z.string().describe("The programming language of the symbol"), + repository: z.string().describe("The repository to scope the search to").optional(), +}; + +export type FindSymbolDefinitionsMetadata = { + files: FindSymbolFile[]; +}; + +export const findSymbolDefinitionsDefinition: ToolDefinition< + 'findSymbolDefinitions', + typeof findSymbolDefinitionsShape, + FindSymbolDefinitionsMetadata +> = { + name: 'findSymbolDefinitions', + description, + inputSchema: z.object(findSymbolDefinitionsShape), + execute: async ({ symbol, language, repository }) => { + logger.debug('findSymbolDefinitions', { symbol, language, repository }); + const revision = "HEAD"; + + const response = await findSearchBasedSymbolDefinitions({ + symbolName: symbol, + language, + revisionName: revision, + repoName: repository, + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const metadata: FindSymbolDefinitionsMetadata = { + files: response.files.map((file) => ({ + fileName: file.fileName, + repository: file.repository, + language: file.language, + matches: file.matches.map(({ lineContent, range }) => { + return addLineNumbers(lineContent, range.start.lineNumber); + }), + revision, + })), + }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/chat/tools/findSymbolDefinitions.txt b/packages/web/src/features/tools/findSymbolDefinitions.txt similarity index 100% rename from packages/web/src/features/chat/tools/findSymbolDefinitions.txt rename to packages/web/src/features/tools/findSymbolDefinitions.txt diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts new file mode 100644 index 000000000..4ef4ea14e --- /dev/null +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { findSearchBasedSymbolReferences } from "@/features/codeNav/api"; +import { addLineNumbers } from "@/features/chat/utils"; +import { createLogger } from "@sourcebot/shared"; +import { ToolDefinition } from "./types"; +import description from "./findSymbolReferences.txt"; + +const logger = createLogger('tool-findSymbolReferences'); + +const findSymbolReferencesShape = { + symbol: z.string().describe("The symbol to find references to"), + language: z.string().describe("The programming language of the symbol"), + repository: z.string().describe("The repository to scope the search to").optional(), +}; + +export type FindSymbolFile = { + fileName: string; + repository: string; + language: string; + matches: string[]; + revision: string; +}; + +export type FindSymbolReferencesMetadata = { + files: FindSymbolFile[]; +}; + +export const findSymbolReferencesDefinition: ToolDefinition< + 'findSymbolReferences', + typeof findSymbolReferencesShape, + FindSymbolReferencesMetadata +> = { + name: 'findSymbolReferences', + description, + inputSchema: z.object(findSymbolReferencesShape), + execute: async ({ symbol, language, repository }) => { + logger.debug('findSymbolReferences', { symbol, language, repository }); + const revision = "HEAD"; + + const response = await findSearchBasedSymbolReferences({ + symbolName: symbol, + language, + revisionName: revision, + repoName: repository, + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const metadata: FindSymbolReferencesMetadata = { + files: response.files.map((file) => ({ + fileName: file.fileName, + repository: file.repository, + language: file.language, + matches: file.matches.map(({ lineContent, range }) => { + return addLineNumbers(lineContent, range.start.lineNumber); + }), + revision, + })), + }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/chat/tools/findSymbolReferences.txt b/packages/web/src/features/tools/findSymbolReferences.txt similarity index 100% rename from packages/web/src/features/chat/tools/findSymbolReferences.txt rename to packages/web/src/features/tools/findSymbolReferences.txt diff --git a/packages/web/src/features/tools/index.ts b/packages/web/src/features/tools/index.ts new file mode 100644 index 000000000..ff75e3d7e --- /dev/null +++ b/packages/web/src/features/tools/index.ts @@ -0,0 +1,7 @@ +export * from './readFile'; +export * from './listCommits'; +export * from './listRepos'; +export * from './searchCode'; +export * from './findSymbolReferences'; +export * from './findSymbolDefinitions'; +export * from './adapters'; \ No newline at end of file diff --git a/packages/web/src/features/tools/registry.ts b/packages/web/src/features/tools/registry.ts deleted file mode 100644 index 1bef7b139..000000000 --- a/packages/web/src/features/tools/registry.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { InferUITool, ToolUIPart } from "ai"; -import { weatherDefinition } from "./weather"; -import { readFileDefinition } from "./readFile"; -import { listCommitsDefinition } from "./listCommits"; -import { listReposDefinition } from "./listRepos"; -import { toVercelAITool } from "./adapters"; - -export const toolRegistry = { - [weatherDefinition.name]: weatherDefinition, - [readFileDefinition.name]: readFileDefinition, - [listCommitsDefinition.name]: listCommitsDefinition, - [listReposDefinition.name]: listReposDefinition, -} as const; - -// Vercel AI tool wrappers, keyed by tool name — pass directly to streamText. -export const vercelAITools = { - [weatherDefinition.name]: toVercelAITool(weatherDefinition), - [readFileDefinition.name]: toVercelAITool(readFileDefinition), - [listCommitsDefinition.name]: toVercelAITool(listCommitsDefinition), - [listReposDefinition.name]: toVercelAITool(listReposDefinition), -} as const; - -// Derive SBChatMessageToolTypes from the registry so that adding a tool here -// automatically updates the message type. Import this into chat/types.ts. -export type ToolTypes = { - [K in keyof typeof vercelAITools]: InferUITool; -}; - -export type ReadFileToolUIPart = ToolUIPart<{ readFile: ToolTypes['readFile'] }>; -export type ListCommitsToolUIPart = ToolUIPart<{ listCommits: ToolTypes['listCommits'] }>; -export type ListReposToolUIPart = ToolUIPart<{ listRepos: ToolTypes['listRepos'] }>; diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts new file mode 100644 index 000000000..cf1d134d9 --- /dev/null +++ b/packages/web/src/features/tools/searchCode.ts @@ -0,0 +1,132 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { search } from "@/features/search"; +import { addLineNumbers } from "@/features/chat/utils"; +import { createLogger } from "@sourcebot/shared"; +import escapeStringRegexp from "escape-string-regexp"; +import { ToolDefinition } from "./types"; +import description from "./searchCode.txt"; + +const logger = createLogger('tool-searchCode'); + +const DEFAULT_SEARCH_LIMIT = 100; + +const searchCodeShape = { + query: z + .string() + .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) + .transform((val) => { + const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + return `"${escaped}"`; + }), + useRegex: z + .boolean() + .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) + .optional(), + filterByRepos: z + .array(z.string()) + .describe(`Scope the search to the provided repositories.`) + .optional(), + filterByLanguages: z + .array(z.string()) + .describe(`Scope the search to the provided languages.`) + .optional(), + filterByFilepaths: z + .array(z.string()) + .describe(`Scope the search to the provided filepaths. Each filepath is a regular expression matched against the full file path.`) + .optional(), + caseSensitive: z + .boolean() + .describe(`Whether the search should be case sensitive (default: false).`) + .optional(), + ref: z + .string() + .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) + .optional(), + limit: z + .number() + .default(DEFAULT_SEARCH_LIMIT) + .describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) + .optional(), +}; + +export type SearchCodeFile = { + fileName: string; + repository: string; + language: string; + matches: string[]; + revision: string; +}; + +export type SearchCodeMetadata = { + files: SearchCodeFile[]; + query: string; +}; + +export const searchCodeDefinition: ToolDefinition<'searchCode', typeof searchCodeShape, SearchCodeMetadata> = { + name: 'searchCode', + description, + inputSchema: z.object(searchCodeShape), + execute: async ({ + query, + useRegex = false, + filterByRepos: repos = [], + filterByLanguages: languages = [], + filterByFilepaths: filepaths = [], + caseSensitive = false, + ref, + limit = DEFAULT_SEARCH_LIMIT, + }) => { + logger.debug('searchCode', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); + + if (repos.length > 0) { + query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; + } + + if (languages.length > 0) { + query += ` (lang:${languages.join(' or lang:')})`; + } + + if (filepaths.length > 0) { + query += ` (file:${filepaths.join(' or file:')})`; + } + + if (ref) { + query += ` (rev:${ref})`; + } + + const response = await search({ + queryType: 'string', + query, + options: { + matches: limit, + contextLines: 3, + isCaseSensitivityEnabled: caseSensitive, + isRegexEnabled: useRegex, + } + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const metadata: SearchCodeMetadata = { + files: response.files.map((file) => ({ + fileName: file.fileName.text, + repository: file.repository, + language: file.language, + matches: file.chunks.map(({ content, contentStart }) => { + return addLineNumbers(content, contentStart.lineNumber); + }), + // @todo: make revision configurable. + revision: 'HEAD', + })), + query, + }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/chat/tools/searchCode.txt b/packages/web/src/features/tools/searchCode.txt similarity index 100% rename from packages/web/src/features/chat/tools/searchCode.txt rename to packages/web/src/features/tools/searchCode.txt diff --git a/packages/web/src/features/tools/weather.ts b/packages/web/src/features/tools/weather.ts deleted file mode 100644 index b6d45baf8..000000000 --- a/packages/web/src/features/tools/weather.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from "zod"; -import { ToolDefinition } from "./types"; -import description from "./weather.txt"; - -// ----------------------------------------------------------------------- -// Weather tool -// ----------------------------------------------------------------------- - -const weatherShape = { - city: z.string().describe("The name of the city to get the weather for."), -}; - -export type WeatherMetadata = { - city: string; - temperatureC: number; - condition: string; -}; - -export const weatherDefinition: ToolDefinition<"weather", typeof weatherShape, WeatherMetadata> = { - name: "weather", - description, - inputSchema: z.object(weatherShape), - execute: async ({ city }) => { - // Dummy response - const metadata: WeatherMetadata = { - city, - temperatureC: 22, - condition: "Partly cloudy", - }; - return { - output: `The weather in ${city} is ${metadata.condition} with a temperature of ${metadata.temperatureC}°C.`, - metadata, - }; - }, -}; diff --git a/packages/web/src/features/tools/weather.txt b/packages/web/src/features/tools/weather.txt deleted file mode 100644 index 08e2b69ef..000000000 --- a/packages/web/src/features/tools/weather.txt +++ /dev/null @@ -1 +0,0 @@ -Get the current weather for a given city. From 6ab9bc72846065a1563b4db20b74f66ec8707a13 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 20:11:33 -0700 Subject: [PATCH 08/43] wip --- packages/web/src/features/chat/agent.ts | 9 +- .../components/chatThread/detailsCard.tsx | 20 +- .../findSymbolDefinitionsToolComponent.tsx | 7 +- .../findSymbolReferencesToolComponent.tsx | 7 +- .../tools/listCommitsToolComponent.tsx | 5 + .../tools/listReposToolComponent.tsx | 11 +- .../tools/listTreeToolComponent.tsx | 74 +++ .../tools/readFileToolComponent.tsx | 37 +- .../tools/searchCodeToolComponent.tsx | 7 +- .../components/chatThread/tools/shared.tsx | 19 +- packages/web/src/features/chat/tools.ts | 17 +- packages/web/src/features/mcp/server.ts | 486 +----------------- packages/web/src/features/mcp/types.ts | 8 +- packages/web/src/features/mcp/utils.ts | 2 +- packages/web/src/features/tools/adapters.ts | 11 +- .../features/tools/findSymbolDefinitions.ts | 14 +- .../features/tools/findSymbolReferences.ts | 16 +- packages/web/src/features/tools/index.ts | 1 + .../web/src/features/tools/listCommits.ts | 56 +- packages/web/src/features/tools/listRepos.ts | 49 +- packages/web/src/features/tools/listTree.ts | 136 +++++ packages/web/src/features/tools/listTree.txt | 9 + packages/web/src/features/tools/readFile.ts | 16 +- packages/web/src/features/tools/searchCode.ts | 13 +- .../web/src/features/tools/searchCode.txt | 10 +- 25 files changed, 433 insertions(+), 607 deletions(-) create mode 100644 packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx create mode 100644 packages/web/src/features/tools/listTree.ts create mode 100644 packages/web/src/features/tools/listTree.txt diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 6fcf34385..fb4f0b81f 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -224,7 +224,7 @@ const createAgentStream = async ({ onWriteSource({ type: 'file', language: output.metadata.language, - repo: output.metadata.repository, + repo: output.metadata.repo, path: output.metadata.path, revision: output.metadata.revision, name: output.metadata.path.split('/').pop() ?? output.metadata.path, @@ -234,7 +234,7 @@ const createAgentStream = async ({ onWriteSource({ type: 'file', language: file.language, - repo: file.repository, + repo: file.repo, path: file.fileName, revision: file.revision, name: file.fileName.split('/').pop() ?? file.fileName, @@ -245,7 +245,7 @@ const createAgentStream = async ({ onWriteSource({ type: 'file', language: file.language, - repo: file.repository, + repo: file.repo, path: file.fileName, revision: file.revision, name: file.fileName.split('/').pop() ?? file.fileName, @@ -308,7 +308,8 @@ const createPrompt = ({ The user has explicitly selected the following repositories for analysis: ${repos.map(repo => `- ${repo}`).join('\n')} - When calling the searchCode tool, always pass these repositories as \`filterByRepos\` to scope results to the selected repositories. + When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`), use these repository names directly. + When calling the \`search_code\` tool, pass these repositories as \`filterByRepos\` to scope results to the selected repositories. ` : ''} diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 5673a590b..bf967e151 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -16,6 +16,7 @@ import { ReadFileToolComponent } from './tools/readFileToolComponent'; import { SearchCodeToolComponent } from './tools/searchCodeToolComponent'; import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; +import { ListTreeToolComponent } from './tools/listTreeToolComponent'; import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; import { SearchScopeIcon } from '../searchScopeIcon'; import isEqual from "fast-deep-equal/react"; @@ -166,48 +167,55 @@ const DetailsCardComponent = ({ className="text-sm" /> ) - case 'tool-readFile': + case 'tool-read_file': return ( ) - case 'tool-searchCode': + case 'tool-search_code': return ( ) - case 'tool-findSymbolDefinitions': + case 'tool-find_symbol_definitions': return ( ) - case 'tool-findSymbolReferences': + case 'tool-find_symbol_references': return ( ) - case 'tool-listRepos': + case 'tool-list_repos': return ( ) - case 'tool-listCommits': + case 'tool-list_commits': return ( ) + case 'tool-list_tree': + return ( + + ) case 'data-source': case 'dynamic-tool': case 'file': diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 828a710c4..28a64b777 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -25,6 +25,10 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD } }, [part]); + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + return (
{part.state === 'output-available' && isExpanded && ( <> @@ -52,7 +57,7 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD ) })} diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx index 532fde80a..27745d5e6 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx @@ -25,6 +25,10 @@ export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolRe } }, [part]); + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + return (
{part.state === 'output-available' && isExpanded && ( <> @@ -52,7 +57,7 @@ export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolRe ) })} diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index 52d595e98..d0109e8a2 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -23,6 +23,10 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart } }, [part]); + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx index efa118e9d..36188602f 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx @@ -23,6 +23,10 @@ export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart }) } }, [part]); + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + return (
{part.state === 'output-available' && isExpanded && ( <> @@ -46,12 +51,12 @@ export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart }) ) : (
- Found {part.output.metadata.repos.length} repositories: + Found {part.output.metadata.repos.length} of {part.output.metadata.totalCount} repositories:
- {part.output.metadata.repos.map((repoName, index) => ( + {part.output.metadata.repos.map((repo, index) => (
- {repoName} + {repo.name}
))}
diff --git a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx new file mode 100644 index 000000000..1482c43d9 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ListTreeToolUIPart } from "@/features/chat/tools"; +import { isServiceError } from "@/lib/utils"; +import { useMemo, useState } from "react"; +import { ToolHeader, TreeList } from "./shared"; +import { CodeSnippet } from "@/app/components/codeSnippet"; +import { Separator } from "@/components/ui/separator"; +import { FileIcon, FolderIcon } from "lucide-react"; + +export const ListTreeToolComponent = ({ part }: { part: ListTreeToolUIPart }) => { + const [isExpanded, setIsExpanded] = useState(false); + + const label = useMemo(() => { + switch (part.state) { + case 'input-streaming': + return 'Listing directory tree...'; + case 'output-error': + return '"List tree" tool call failed'; + case 'input-available': + case 'output-available': + return 'Listed directory tree'; + } + }, [part]); + + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + + return ( +
+ + {part.state === 'output-available' && isExpanded && ( + <> + {isServiceError(part.output) ? ( + + Failed with the following error: {part.output.message} + + ) : ( + <> + {part.output.metadata.entries.length === 0 ? ( + No entries found + ) : ( + +
+ {part.output.metadata.repo} - {part.output.metadata.path || '/'} ({part.output.metadata.totalReturned} entries{part.output.metadata.truncated ? ', truncated' : ''}) +
+ {part.output.metadata.entries.map((entry, index) => ( +
+ {entry.type === 'tree' + ? + : + } + {entry.name} +
+ ))} +
+ )} + + )} + + + )} +
+ ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 6ff35a3af..1c71e9a7d 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -6,17 +6,14 @@ import { ReadFileToolUIPart } from "@/features/chat/tools"; import { isServiceError } from "@/lib/utils"; import { EyeIcon } from "lucide-react"; import { useMemo, useState } from "react"; -import { CopyIconButton } from "@/app/[domain]/components/copyIconButton"; import { FileListItem, ToolHeader, TreeList } from "./shared"; export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => { const [isExpanded, setIsExpanded] = useState(false); - const onCopy = () => { - if (part.state !== 'output-available' || isServiceError(part.output)) return false; - navigator.clipboard.writeText(part.output.output); - return true; - }; + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText((part.output as { output: string }).output); return true; } + : undefined; const label = useMemo(() => { switch (part.state) { @@ -39,23 +36,15 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => return (
-
- - {part.state === 'output-available' && !isServiceError(part.output) && ( - - )} -
+ {part.state === 'output-available' && isExpanded && ( <> @@ -64,7 +53,7 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => ) : ( )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx index 7ff9aaca8..6b175f50c 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx @@ -31,6 +31,10 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart } } }, [part, displayQuery]); + const onCopy = part.state === 'output-available' && !isServiceError(part.output) + ? () => { navigator.clipboard.writeText(part.output.output); return true; } + : undefined; + return (
{part.state === 'output-available' && isExpanded && ( <> @@ -58,7 +63,7 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart } ) })} diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx index 92c2bf3fa..ffc541124 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx @@ -1,6 +1,7 @@ 'use client'; import { VscodeFileIcon } from '@/app/components/vscodeFileIcon'; +import { CopyIconButton } from '@/app/[domain]/components/copyIconButton'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; @@ -82,15 +83,16 @@ interface ToolHeaderProps { label: React.ReactNode; Icon: React.ElementType; onExpand: (isExpanded: boolean) => void; + onCopy?: () => boolean; className?: string; } -export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, className }: ToolHeaderProps) => { +export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, onCopy, className }: ToolHeaderProps) => { return (
)} {label} + {onCopy && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
e.stopPropagation()}> + +
+ )} {!isLoading && ( -
+
{isExpanded ? ( ) : ( diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts index 6feb1ea51..0cf9f4a59 100644 --- a/packages/web/src/features/chat/tools.ts +++ b/packages/web/src/features/chat/tools.ts @@ -1,11 +1,12 @@ import { - toVercelAITool, + toVercelAITool, readFileDefinition, listCommitsDefinition, listReposDefinition, searchCodeDefinition, findSymbolReferencesDefinition, findSymbolDefinitionsDefinition, + listTreeDefinition, } from "@/features/tools"; import { ToolUIPart } from "ai"; import { SBChatMessageToolTypes } from "./types"; @@ -17,11 +18,13 @@ export const tools = { [searchCodeDefinition.name]: toVercelAITool(searchCodeDefinition), [findSymbolReferencesDefinition.name]: toVercelAITool(findSymbolReferencesDefinition), [findSymbolDefinitionsDefinition.name]: toVercelAITool(findSymbolDefinitionsDefinition), + [listTreeDefinition.name]: toVercelAITool(listTreeDefinition), } as const; -export type ReadFileToolUIPart = ToolUIPart<{ readFile: SBChatMessageToolTypes['readFile'] }>; -export type ListCommitsToolUIPart = ToolUIPart<{ listCommits: SBChatMessageToolTypes['listCommits'] }>; -export type ListReposToolUIPart = ToolUIPart<{ listRepos: SBChatMessageToolTypes['listRepos'] }>; -export type SearchCodeToolUIPart = ToolUIPart<{ searchCode: SBChatMessageToolTypes['searchCode'] }>; -export type FindSymbolReferencesToolUIPart = ToolUIPart<{ findSymbolReferences: SBChatMessageToolTypes['findSymbolReferences'] }>; -export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ findSymbolDefinitions: SBChatMessageToolTypes['findSymbolDefinitions'] }>; +export type ReadFileToolUIPart = ToolUIPart<{ read_file: SBChatMessageToolTypes['read_file'] }>; +export type ListCommitsToolUIPart = ToolUIPart<{ list_commits: SBChatMessageToolTypes['list_commits'] }>; +export type ListReposToolUIPart = ToolUIPart<{ list_repos: SBChatMessageToolTypes['list_repos'] }>; +export type SearchCodeToolUIPart = ToolUIPart<{ search_code: SBChatMessageToolTypes['search_code'] }>; +export type FindSymbolReferencesToolUIPart = ToolUIPart<{ find_symbol_references: SBChatMessageToolTypes['find_symbol_references'] }>; +export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ find_symbol_definitions: SBChatMessageToolTypes['find_symbol_definitions'] }>; +export type ListTreeToolUIPart = ToolUIPart<{ list_tree: SBChatMessageToolTypes['list_tree'] }>; diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index 8db766164..476d64318 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -1,485 +1,34 @@ -import { listRepos } from '@/app/api/(server)/repos/listReposApi'; -import { getConfiguredLanguageModelsInfo } from "../chat/utils.server"; -import { askCodebase } from '@/features/mcp/askCodebase'; import { languageModelInfoSchema, } from '@/features/chat/types'; -import { getFileSource, getTree, listCommits } from '@/features/git'; -import { search } from '@/features/search/searchApi'; +import { askCodebase } from '@/features/mcp/askCodebase'; import { isServiceError } from '@/lib/utils'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { ChatVisibility } from '@sourcebot/db'; import { SOURCEBOT_VERSION } from '@sourcebot/shared'; import _dedent from 'dedent'; -import escapeStringRegexp from 'escape-string-regexp'; import { z } from 'zod'; -import { - ListTreeEntry, - TextContent, -} from './types'; -import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from './utils'; +import { getConfiguredLanguageModelsInfo } from "../chat/utils.server"; +import { listCommitsDefinition, listReposDefinition, listTreeDefinition, readFileDefinition, registerMcpTool, searchCodeDefinition } from '../tools'; const dedent = _dedent.withOptions({ alignValues: true }); -const DEFAULT_MINIMUM_TOKENS = 10000; -const DEFAULT_MATCHES = 10000; -const DEFAULT_CONTEXT_LINES = 5; - -const DEFAULT_TREE_DEPTH = 1; -const MAX_TREE_DEPTH = 10; -const DEFAULT_MAX_TREE_ENTRIES = 1000; -const MAX_MAX_TREE_ENTRIES = 10000; - -const TOOL_DESCRIPTIONS = { - search_code: dedent` - Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by - searching for exact symbols, functions, variables, or specific code patterns. - - To determine if a repository is indexed, use the \`list_repos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be - scoped to specific repositories, languages, and branches. - - When referencing code outputted by this tool, always include the file's external URL as a link. This makes it easier for the user to view the file, even if they don't have it locally checked out. - `, - list_commits: dedent`Get a list of commits for a given repository.`, - list_repos: dedent`Lists repositories in the organization with optional filtering and pagination.`, - read_file: dedent`Reads the source code for a given file.`, - list_tree: dedent` - Lists files and directories from a repository path. This can be used as a repo tree tool or directory listing tool. - Returns a flat list of entries with path metadata and depth relative to the requested path. - `, - list_language_models: dedent`Lists the available language models configured on the Sourcebot instance. Use this to discover which models can be specified when calling ask_codebase.`, - ask_codebase: dedent` - DO NOT USE THIS TOOL UNLESS EXPLICITLY ASKED TO. THE PROMPT MUST SPECIFICALLY ASK TO USE THE ask_codebase TOOL. - - Ask a natural language question about the codebase. This tool uses an AI agent to autonomously search code, read files, and find symbol references/definitions to answer your question. - - This is a blocking operation that may take 60+ seconds to research the codebase, so only invoke it if the user has explicitly asked you to by specifying the ask_codebase tool call in the prompt. - - The agent will: - - Analyze your question and determine what context it needs - - Search the codebase using multiple strategies (code search, symbol lookup, file reading) - - Synthesize findings into a comprehensive answer with code references - - Returns a detailed answer in markdown format with code references, plus a link to view the full research session (including all tool calls and reasoning) in the Sourcebot web UI. - - When using this in shared environments (e.g., Slack), you can set the visibility parameter to 'PUBLIC' to ensure everyone can access the chat link. - `, -}; - export function createMcpServer(): McpServer { const server = new McpServer({ name: 'sourcebot-mcp-server', version: SOURCEBOT_VERSION, }); - server.registerTool( - "search_code", - { - description: TOOL_DESCRIPTIONS.search_code, - inputSchema: { - query: z - .string() - .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) - .transform((val) => { - const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; - }), - useRegex: z - .boolean() - .describe(`Whether to use regular expression matching. When false, substring matching is used. (default: false)`) - .optional(), - filterByRepos: z - .array(z.string()) - .describe(`Scope the search to the provided repositories.`) - .optional(), - filterByLanguages: z - .array(z.string()) - .describe(`Scope the search to the provided languages.`) - .optional(), - filterByFilepaths: z - .array(z.string()) - .describe(`Scope the search to the provided filepaths. Each filepath is a regular expression matched against the full file path.`) - .optional(), - caseSensitive: z - .boolean() - .describe(`Whether the search should be case sensitive (default: false).`) - .optional(), - includeCodeSnippets: z - .boolean() - .describe(`Whether to include code snippets in the response. If false, only the file's URL, repository, and language will be returned. (default: false)`) - .optional(), - ref: z - .string() - .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch.`) - .optional(), - maxTokens: z - .number() - .describe(`The maximum number of tokens to return (default: ${DEFAULT_MINIMUM_TOKENS}).`) - .transform((val) => (val < DEFAULT_MINIMUM_TOKENS ? DEFAULT_MINIMUM_TOKENS : val)) - .optional(), - }, - }, - async ({ - query, - filterByRepos: repos = [], - filterByLanguages: languages = [], - filterByFilepaths: filepaths = [], - maxTokens = DEFAULT_MINIMUM_TOKENS, - includeCodeSnippets = false, - caseSensitive = false, - ref, - useRegex = false, - }: { - query: string; - useRegex?: boolean; - filterByRepos?: string[]; - filterByLanguages?: string[]; - filterByFilepaths?: string[]; - caseSensitive?: boolean; - includeCodeSnippets?: boolean; - ref?: string; - maxTokens?: number; - }) => { - if (repos.length > 0) { - query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; - } - if (languages.length > 0) { - query += ` (lang:${languages.join(' or lang:')})`; - } - if (filepaths.length > 0) { - query += ` (file:${filepaths.join(' or file:')})`; - } - if (ref) { - query += ` ( rev:${ref} )`; - } - - const response = await search({ - queryType: 'string', - query, - options: { - matches: DEFAULT_MATCHES, - contextLines: DEFAULT_CONTEXT_LINES, - isRegexEnabled: useRegex, - isCaseSensitivityEnabled: caseSensitive, - }, - source: 'mcp', - }); - - if (isServiceError(response)) { - return { - content: [{ type: "text", text: `Search failed: ${response.message}` }], - }; - } - - if (response.files.length === 0) { - return { - content: [{ type: "text", text: `No results found for the query: ${query}` }], - }; - } - - const content: TextContent[] = []; - let totalTokens = 0; - let isResponseTruncated = false; - - for (const file of response.files) { - const numMatches = file.chunks.reduce((acc, chunk) => acc + chunk.matchRanges.length, 0); - let text = dedent` - file: ${file.webUrl} - num_matches: ${numMatches} - repo: ${file.repository} - language: ${file.language} - `; - - if (includeCodeSnippets) { - const snippets = file.chunks.map(chunk => `\`\`\`\n${chunk.content}\n\`\`\``).join('\n'); - text += `\n\n${snippets}`; - } - - const tokens = text.length / 4; - - if ((totalTokens + tokens) > maxTokens) { - const remainingTokens = maxTokens - totalTokens; - if (remainingTokens > 100) { - const maxLength = Math.floor(remainingTokens * 4); - content.push({ - type: "text", - text: text.substring(0, maxLength) + "\n\n...[content truncated due to token limit]", - }); - totalTokens += remainingTokens; - } - isResponseTruncated = true; - break; - } - - totalTokens += tokens; - content.push({ type: "text", text }); - } - - if (isResponseTruncated) { - content.push({ - type: "text", - text: `The response was truncated because the number of tokens exceeded the maximum limit of ${maxTokens}.`, - }); - } - - return { content }; - } - ); - - server.registerTool( - "list_commits", - { - description: TOOL_DESCRIPTIONS.list_commits, - inputSchema: z.object({ - repo: z.string().describe("The name of the repository to list commits for."), - query: z.string().describe("Search query to filter commits by message content (case-insensitive).").optional(), - since: z.string().describe("Show commits more recent than this date. Supports ISO 8601 or relative formats (e.g., '30 days ago').").optional(), - until: z.string().describe("Show commits older than this date. Supports ISO 8601 or relative formats (e.g., 'yesterday').").optional(), - author: z.string().describe("Filter commits by author name or email (case-insensitive).").optional(), - ref: z.string().describe("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch.").optional(), - page: z.number().int().positive().describe("Page number for pagination (min 1). Default: 1").optional().default(1), - perPage: z.number().int().positive().max(100).describe("Results per page for pagination (min 1, max 100). Default: 50").optional().default(50), - }), - }, - async ({ repo, query, since, until, author, ref, page, perPage }) => { - const skip = (page - 1) * perPage; - const result = await listCommits({ - repo, - query, - since, - until, - author, - ref, - maxCount: perPage, - skip, - }); - - if (isServiceError(result)) { - return { - content: [{ type: "text", text: `Failed to list commits: ${result.message}` }], - }; - } - - return { content: [{ type: "text", text: JSON.stringify(result) }] }; - } - ); - - server.registerTool( - "list_repos", - { - description: TOOL_DESCRIPTIONS.list_repos, - inputSchema: z.object({ - query: z.string().describe("Filter repositories by name (case-insensitive)").optional(), - page: z.number().int().positive().describe("Page number for pagination (min 1). Default: 1").optional().default(1), - perPage: z.number().int().positive().max(100).describe("Results per page for pagination (min 1, max 100). Default: 30").optional().default(30), - sort: z.enum(['name', 'pushed']).describe("Sort repositories by 'name' or 'pushed' (most recent commit). Default: 'name'").optional().default('name'), - direction: z.enum(['asc', 'desc']).describe("Sort direction: 'asc' or 'desc'. Default: 'asc'").optional().default('asc'), - }) - }, - async ({ query, page, perPage, sort, direction }) => { - const result = await listRepos({ query, page, perPage, sort, direction, source: 'mcp' }); - - if (isServiceError(result)) { - return { - content: [{ type: "text", text: `Failed to list repositories: ${result.message}` }], - }; - } - - return { - content: [{ - type: "text", - text: JSON.stringify({ - repos: result.data.map((repo) => ({ - name: repo.repoName, - url: repo.webUrl, - pushedAt: repo.pushedAt, - defaultBranch: repo.defaultBranch, - isFork: repo.isFork, - isArchived: repo.isArchived, - })), - totalCount: result.totalCount, - }), - }], - }; - } - ); - - server.registerTool( - "read_file", - { - description: TOOL_DESCRIPTIONS.read_file, - inputSchema: { - repo: z.string().describe("The repository name."), - path: z.string().describe("The path to the file."), - ref: z.string().optional().describe("Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch of the repository."), - }, - }, - async ({ repo, path, ref }) => { - const response = await getFileSource({ repo, path, ref }, { source: 'mcp' }); - - if (isServiceError(response)) { - return { - content: [{ type: "text", text: `Failed to read file: ${response.message}` }], - }; - } - - return { - content: [{ - type: "text", - text: JSON.stringify({ - source: response.source, - language: response.language, - path: response.path, - url: response.webUrl, - }), - }], - }; - } - ); - - server.registerTool( - "list_tree", - { - description: TOOL_DESCRIPTIONS.list_tree, - inputSchema: { - repo: z.string().describe("The name of the repository to list files from."), - path: z.string().describe("Directory path (relative to repo root). If omitted, the repo root is used.").optional().default(''), - ref: z.string().describe("Commit SHA, branch or tag name to list files from. If not provided, uses the default branch.").optional().default('HEAD'), - depth: z.number().int().positive().max(MAX_TREE_DEPTH).describe(`How many directory levels to traverse below \`path\` (min 1, max ${MAX_TREE_DEPTH}, default ${DEFAULT_TREE_DEPTH}).`).optional().default(DEFAULT_TREE_DEPTH), - includeFiles: z.boolean().describe("Whether to include files in the output (default: true).").optional().default(true), - includeDirectories: z.boolean().describe("Whether to include directories in the output (default: true).").optional().default(true), - maxEntries: z.number().int().positive().max(MAX_MAX_TREE_ENTRIES).describe(`Maximum number of entries to return (min 1, max ${MAX_MAX_TREE_ENTRIES}, default ${DEFAULT_MAX_TREE_ENTRIES}).`).optional().default(DEFAULT_MAX_TREE_ENTRIES), - }, - }, - async ({ - repo, - path = '', - ref = 'HEAD', - depth = DEFAULT_TREE_DEPTH, - includeFiles = true, - includeDirectories = true, - maxEntries = DEFAULT_MAX_TREE_ENTRIES, - }: { - repo: string; - path?: string; - ref?: string; - depth?: number; - includeFiles?: boolean; - includeDirectories?: boolean; - maxEntries?: number; - }) => { - const normalizedPath = normalizeTreePath(path); - const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH); - const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES); - - if (!includeFiles && !includeDirectories) { - return { - content: [{ - type: "text", - text: JSON.stringify({ - repo, ref, path: normalizedPath, - entries: [] as ListTreeEntry[], - totalReturned: 0, - truncated: false, - }), - }], - }; - } - - const queue: Array<{ path: string; depth: number }> = [{ path: normalizedPath, depth: 0 }]; - const queuedPaths = new Set([normalizedPath]); - const seenEntries = new Set(); - const entries: ListTreeEntry[] = []; - let truncated = false; - let treeError: string | null = null; - - while (queue.length > 0 && !truncated) { - const currentDepth = queue[0]!.depth; - const currentLevelPaths: string[] = []; - - while (queue.length > 0 && queue[0]!.depth === currentDepth) { - currentLevelPaths.push(queue.shift()!.path); - } - - const treeResult = await getTree({ - repoName: repo, - revisionName: ref, - paths: currentLevelPaths.filter(Boolean), - }, { source: 'mcp' }); - - if (isServiceError(treeResult)) { - treeError = treeResult.message; - break; - } - - const treeNodeIndex = buildTreeNodeIndex(treeResult.tree); - - for (const currentPath of currentLevelPaths) { - const currentNode = currentPath === '' ? treeResult.tree : treeNodeIndex.get(currentPath); - if (!currentNode || currentNode.type !== 'tree') continue; - - for (const child of currentNode.children) { - if (child.type !== 'tree' && child.type !== 'blob') continue; - - const childPath = joinTreePath(currentPath, child.name); - const childDepth = currentDepth + 1; - - if (child.type === 'tree' && childDepth < normalizedDepth && !queuedPaths.has(childPath)) { - queue.push({ path: childPath, depth: childDepth }); - queuedPaths.add(childPath); - } - - if ((child.type === 'blob' && !includeFiles) || (child.type === 'tree' && !includeDirectories)) { - continue; - } - - const key = `${child.type}:${childPath}`; - if (seenEntries.has(key)) continue; - seenEntries.add(key); - - if (entries.length >= normalizedMaxEntries) { - truncated = true; - break; - } - - entries.push({ - type: child.type as 'tree' | 'blob', - path: childPath, - name: child.name, - parentPath: currentPath, - depth: childDepth, - }); - } - - if (truncated) break; - } - } - - if (treeError) { - return { - content: [{ type: "text", text: `Failed to list tree: ${treeError}` }], - }; - } - - const sortedEntries = sortTreeEntries(entries); - return { - content: [{ - type: "text", - text: JSON.stringify({ - repo, ref, path: normalizedPath, - entries: sortedEntries, - totalReturned: sortedEntries.length, - truncated, - }), - }], - }; - } - ); + registerMcpTool(server, searchCodeDefinition); + registerMcpTool(server, listCommitsDefinition); + registerMcpTool(server, listReposDefinition); + registerMcpTool(server, readFileDefinition); + registerMcpTool(server, listTreeDefinition); server.registerTool( "list_language_models", { - description: TOOL_DESCRIPTIONS.list_language_models, + description: dedent`Lists the available language models configured on the Sourcebot instance. Use this to discover which models can be specified when calling ask_codebase.`, }, async () => { const models = await getConfiguredLanguageModelsInfo(); @@ -490,7 +39,22 @@ export function createMcpServer(): McpServer { server.registerTool( "ask_codebase", { - description: TOOL_DESCRIPTIONS.ask_codebase, + description: dedent` + DO NOT USE THIS TOOL UNLESS EXPLICITLY ASKED TO. THE PROMPT MUST SPECIFICALLY ASK TO USE THE ask_codebase TOOL. + + Ask a natural language question about the codebase. This tool uses an AI agent to autonomously search code, read files, and find symbol references/definitions to answer your question. + + This is a blocking operation that may take 60+ seconds to research the codebase, so only invoke it if the user has explicitly asked you to by specifying the ask_codebase tool call in the prompt. + + The agent will: + - Analyze your question and determine what context it needs + - Search the codebase using multiple strategies (code search, symbol lookup, file reading) + - Synthesize findings into a comprehensive answer with code references + + Returns a detailed answer in markdown format with code references, plus a link to view the full research session (including all tool calls and reasoning) in the Sourcebot web UI. + + When using this in shared environments (e.g., Slack), you can set the visibility parameter to 'PUBLIC' to ensure everyone can access the chat link. + `, inputSchema: z.object({ query: z.string().describe("The query to ask about the codebase."), repos: z.array(z.string()).optional().describe("The repositories accessible to the agent. If not provided, all repositories are accessible."), diff --git a/packages/web/src/features/mcp/types.ts b/packages/web/src/features/mcp/types.ts index af60fd648..b3ff5d903 100644 --- a/packages/web/src/features/mcp/types.ts +++ b/packages/web/src/features/mcp/types.ts @@ -1,13 +1,7 @@ export type TextContent = { type: "text", text: string }; -export type ListTreeEntry = { - type: 'tree' | 'blob'; - path: string; - name: string; - parentPath: string; - depth: number; -}; +export type { ListTreeEntry } from "@/features/tools/listTree"; export type ListTreeApiNode = { type: 'tree' | 'blob'; diff --git a/packages/web/src/features/mcp/utils.ts b/packages/web/src/features/mcp/utils.ts index 96ef5d568..b6de4c71a 100644 --- a/packages/web/src/features/mcp/utils.ts +++ b/packages/web/src/features/mcp/utils.ts @@ -1,6 +1,6 @@ import { FileTreeNode } from "../git"; import { ServiceError } from "@/lib/serviceError"; -import { ListTreeEntry } from "./types"; +import { ListTreeEntry } from "@/features/tools/listTree"; export const isServiceError = (data: unknown): data is ServiceError => { return typeof data === 'object' && diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index f7f574f1e..3c0389b68 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -28,9 +28,14 @@ export function registerMcpTool { - const parsed = def.inputSchema.parse(input); - const result = await def.execute(parsed); - return { content: [{ type: "text" as const, text: result.output }] }; + try { + const parsed = def.inputSchema.parse(input); + const result = await def.execute(parsed); + return { content: [{ type: "text" as const, text: result.output }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { content: [{ type: "text" as const, text: `Tool "${def.name}" failed: ${message}` }], isError: true }; + } }, ); } diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 209717034..62fc4537a 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -12,7 +12,7 @@ const logger = createLogger('tool-findSymbolDefinitions'); const findSymbolDefinitionsShape = { symbol: z.string().describe("The symbol to find definitions of"), language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), + repo: z.string().describe("The repository to scope the search to").optional(), }; export type FindSymbolDefinitionsMetadata = { @@ -20,22 +20,22 @@ export type FindSymbolDefinitionsMetadata = { }; export const findSymbolDefinitionsDefinition: ToolDefinition< - 'findSymbolDefinitions', + 'find_symbol_definitions', typeof findSymbolDefinitionsShape, FindSymbolDefinitionsMetadata > = { - name: 'findSymbolDefinitions', + name: 'find_symbol_definitions', description, inputSchema: z.object(findSymbolDefinitionsShape), - execute: async ({ symbol, language, repository }) => { - logger.debug('findSymbolDefinitions', { symbol, language, repository }); + execute: async ({ symbol, language, repo }) => { + logger.debug('findSymbolDefinitions', { symbol, language, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolDefinitions({ symbolName: symbol, language, revisionName: revision, - repoName: repository, + repoName: repo, }); if (isServiceError(response)) { @@ -45,7 +45,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< const metadata: FindSymbolDefinitionsMetadata = { files: response.files.map((file) => ({ fileName: file.fileName, - repository: file.repository, + repo: file.repository, language: file.language, matches: file.matches.map(({ lineContent, range }) => { return addLineNumbers(lineContent, range.start.lineNumber); diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index 4ef4ea14e..b3f5f98a3 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -11,12 +11,12 @@ const logger = createLogger('tool-findSymbolReferences'); const findSymbolReferencesShape = { symbol: z.string().describe("The symbol to find references to"), language: z.string().describe("The programming language of the symbol"), - repository: z.string().describe("The repository to scope the search to").optional(), + repo: z.string().describe("The repository to scope the search to").optional(), }; export type FindSymbolFile = { fileName: string; - repository: string; + repo: string; language: string; matches: string[]; revision: string; @@ -27,22 +27,22 @@ export type FindSymbolReferencesMetadata = { }; export const findSymbolReferencesDefinition: ToolDefinition< - 'findSymbolReferences', + 'find_symbol_references', typeof findSymbolReferencesShape, FindSymbolReferencesMetadata > = { - name: 'findSymbolReferences', + name: 'find_symbol_references', description, inputSchema: z.object(findSymbolReferencesShape), - execute: async ({ symbol, language, repository }) => { - logger.debug('findSymbolReferences', { symbol, language, repository }); + execute: async ({ symbol, language, repo }) => { + logger.debug('findSymbolReferences', { symbol, language, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolReferences({ symbolName: symbol, language, revisionName: revision, - repoName: repository, + repoName: repo, }); if (isServiceError(response)) { @@ -52,7 +52,7 @@ export const findSymbolReferencesDefinition: ToolDefinition< const metadata: FindSymbolReferencesMetadata = { files: response.files.map((file) => ({ fileName: file.fileName, - repository: file.repository, + repo: file.repository, language: file.language, matches: file.matches.map(({ lineContent, range }) => { return addLineNumbers(lineContent, range.start.lineNumber); diff --git a/packages/web/src/features/tools/index.ts b/packages/web/src/features/tools/index.ts index ff75e3d7e..b2bac07f1 100644 --- a/packages/web/src/features/tools/index.ts +++ b/packages/web/src/features/tools/index.ts @@ -4,4 +4,5 @@ export * from './listRepos'; export * from './searchCode'; export * from './findSymbolReferences'; export * from './findSymbolDefinitions'; +export * from './listTree'; export * from './adapters'; \ No newline at end of file diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index 1f32cf599..076e3000b 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; -import { listCommits } from "@/features/git"; +import { listCommits, SearchCommitsResult } from "@/features/git"; import { createLogger } from "@sourcebot/shared"; import { ToolDefinition } from "./types"; import description from "./listCommits.txt"; @@ -8,62 +8,46 @@ import description from "./listCommits.txt"; const logger = createLogger('tool-listCommits'); const listCommitsShape = { - repository: z.string().describe("The repository to list commits from"), + repo: z.string().describe("The repository to list commits from"), query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), since: z.string().describe("Start date for commit range (e.g., '30 days ago', '2024-01-01', 'last week')").optional(), until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), - maxCount: z.number().describe("Maximum number of commits to return (default: 50)").optional(), + ref: z.string().describe("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch.").optional(), + page: z.number().int().positive().describe("Page number for pagination (min 1). Default: 1").optional().default(1), + perPage: z.number().int().positive().max(100).describe("Results per page for pagination (min 1, max 100). Default: 50").optional().default(50), }; -export type Commit = { - hash: string; - date: string; - message: string; - author: string; - refs: string; -}; - -export type ListCommitsMetadata = { - commits: Commit[]; - totalCount: number; -}; +export type ListCommitsMetadata = SearchCommitsResult; -export const listCommitsDefinition: ToolDefinition<"listCommits", typeof listCommitsShape, ListCommitsMetadata> = { - name: "listCommits", +export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCommitsShape, ListCommitsMetadata> = { + name: "list_commits", description, inputSchema: z.object(listCommitsShape), - execute: async ({ repository, query, since, until, author, maxCount }) => { - logger.debug('listCommits', { repository, query, since, until, author, maxCount }); + execute: async (params) => { + logger.debug('list_commits', params); + + const { repo, query, since, until, author, ref, page, perPage } = params; + const skip = (page - 1) * perPage; + const response = await listCommits({ - repo: repository, + repo, query, since, until, author, - maxCount, + ref, + maxCount: perPage, + skip, }); if (isServiceError(response)) { throw new Error(response.message); } - const commits: Commit[] = response.commits.map((commit) => ({ - hash: commit.hash, - date: commit.date, - message: commit.message, - author: `${commit.author_name} <${commit.author_email}>`, - refs: commit.refs, - })); - - const metadata: ListCommitsMetadata = { - commits, - totalCount: response.totalCount, - }; - return { - output: JSON.stringify(metadata), - metadata, + output: JSON.stringify(response), + metadata: response, }; }, }; diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts index 93cf5dd47..ab489969b 100644 --- a/packages/web/src/features/tools/listRepos.ts +++ b/packages/web/src/features/tools/listRepos.ts @@ -5,40 +5,53 @@ import { ToolDefinition } from "./types"; import description from './listRepos.txt'; const listReposShape = { - page: z.coerce.number().int().positive().default(1).describe("Page number for pagination"), - perPage: z.coerce.number().int().positive().max(100).default(30).describe("Number of repositories per page (max 100)"), - sort: z.enum(['name', 'pushed']).default('name').describe("Sort repositories by name or last pushed date"), - direction: z.enum(['asc', 'desc']).default('asc').describe("Sort direction"), - query: z.string().optional().describe("Filter repositories by name"), + query: z.string().describe("Filter repositories by name (case-insensitive)").optional(), + page: z.number().int().positive().describe("Page number for pagination (min 1). Default: 1").optional().default(1), + perPage: z.number().int().positive().max(100).describe("Results per page for pagination (min 1, max 100). Default: 30").optional().default(30), + sort: z.enum(['name', 'pushed']).describe("Sort repositories by 'name' or 'pushed' (most recent commit). Default: 'name'").optional().default('name'), + direction: z.enum(['asc', 'desc']).describe("Sort direction: 'asc' or 'desc'. Default: 'asc'").optional().default('asc'), }; -type ListReposMetadata = { - repos: string[]; +export type ListRepo = { + name: string; + url: string | null; + pushedAt: string | null; + defaultBranch: string | null; + isFork: boolean; + isArchived: boolean; +}; + +export type ListReposMetadata = { + repos: ListRepo[]; + totalCount: number; }; export const listReposDefinition: ToolDefinition< - 'listRepos', + 'list_repos', typeof listReposShape, ListReposMetadata > = { - name: 'listRepos', + name: 'list_repos', description, inputSchema: z.object(listReposShape), execute: async ({ page, perPage, sort, direction, query }) => { - const reposResponse = await listRepos({ - page, - perPage, - sort, - direction, - query, - }); + const reposResponse = await listRepos({ page, perPage, sort, direction, query }); if (isServiceError(reposResponse)) { throw new Error(reposResponse.message); } - const repos = reposResponse.data.map((repo) => repo.repoName); - const metadata: ListReposMetadata = { repos }; + const metadata: ListReposMetadata = { + repos: reposResponse.data.map((repo) => ({ + name: repo.repoName, + url: repo.webUrl ?? null, + pushedAt: repo.pushedAt?.toISOString() ?? null, + defaultBranch: repo.defaultBranch ?? null, + isFork: repo.isFork, + isArchived: repo.isArchived, + })), + totalCount: reposResponse.totalCount, + }; return { output: JSON.stringify(metadata), diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts new file mode 100644 index 000000000..2dc1b13bc --- /dev/null +++ b/packages/web/src/features/tools/listTree.ts @@ -0,0 +1,136 @@ +import { z } from "zod"; +import { isServiceError } from "@/lib/utils"; +import { getTree } from "@/features/git"; +import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from "@/features/mcp/utils"; +import { ToolDefinition } from "./types"; +import description from "./listTree.txt"; + +const DEFAULT_TREE_DEPTH = 1; +const MAX_TREE_DEPTH = 10; +const DEFAULT_MAX_TREE_ENTRIES = 1000; +const MAX_MAX_TREE_ENTRIES = 10000; + +const listTreeShape = { + repo: z.string().describe("The name of the repository to list files from."), + path: z.string().describe("Directory path (relative to repo root). If omitted, the repo root is used.").optional().default(''), + ref: z.string().describe("Commit SHA, branch or tag name to list files from. If not provided, uses the default branch.").optional().default('HEAD'), + depth: z.number().int().positive().max(MAX_TREE_DEPTH).describe(`How many directory levels to traverse below \`path\` (min 1, max ${MAX_TREE_DEPTH}, default ${DEFAULT_TREE_DEPTH}).`).optional().default(DEFAULT_TREE_DEPTH), + includeFiles: z.boolean().describe("Whether to include files in the output (default: true).").optional().default(true), + includeDirectories: z.boolean().describe("Whether to include directories in the output (default: true).").optional().default(true), + maxEntries: z.number().int().positive().max(MAX_MAX_TREE_ENTRIES).describe(`Maximum number of entries to return (min 1, max ${MAX_MAX_TREE_ENTRIES}, default ${DEFAULT_MAX_TREE_ENTRIES}).`).optional().default(DEFAULT_MAX_TREE_ENTRIES), +}; + +export type ListTreeEntry = { + type: 'tree' | 'blob'; + path: string; + name: string; + parentPath: string; + depth: number; +}; + +export type ListTreeMetadata = { + repo: string; + ref: string; + path: string; + entries: ListTreeEntry[]; + totalReturned: number; + truncated: boolean; +}; + +export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShape, ListTreeMetadata> = { + name: 'list_tree', + description, + inputSchema: z.object(listTreeShape), + execute: async ({ repo, path = '', ref = 'HEAD', depth = DEFAULT_TREE_DEPTH, includeFiles = true, includeDirectories = true, maxEntries = DEFAULT_MAX_TREE_ENTRIES }) => { + const normalizedPath = normalizeTreePath(path); + const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH); + const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES); + + if (!includeFiles && !includeDirectories) { + const metadata: ListTreeMetadata = { + repo, ref, path: normalizedPath, + entries: [], + totalReturned: 0, + truncated: false, + }; + return { output: JSON.stringify(metadata), metadata }; + } + + const queue: Array<{ path: string; depth: number }> = [{ path: normalizedPath, depth: 0 }]; + const queuedPaths = new Set([normalizedPath]); + const seenEntries = new Set(); + const entries: ListTreeEntry[] = []; + let truncated = false; + + while (queue.length > 0 && !truncated) { + const currentDepth = queue[0]!.depth; + const currentLevelPaths: string[] = []; + + while (queue.length > 0 && queue[0]!.depth === currentDepth) { + currentLevelPaths.push(queue.shift()!.path); + } + + const treeResult = await getTree({ + repoName: repo, + revisionName: ref, + paths: currentLevelPaths.filter(Boolean), + }); + + if (isServiceError(treeResult)) { + throw new Error(treeResult.message); + } + + const treeNodeIndex = buildTreeNodeIndex(treeResult.tree); + + for (const currentPath of currentLevelPaths) { + const currentNode = currentPath === '' ? treeResult.tree : treeNodeIndex.get(currentPath); + if (!currentNode || currentNode.type !== 'tree') continue; + + for (const child of currentNode.children) { + if (child.type !== 'tree' && child.type !== 'blob') continue; + + const childPath = joinTreePath(currentPath, child.name); + const childDepth = currentDepth + 1; + + if (child.type === 'tree' && childDepth < normalizedDepth && !queuedPaths.has(childPath)) { + queue.push({ path: childPath, depth: childDepth }); + queuedPaths.add(childPath); + } + + if ((child.type === 'blob' && !includeFiles) || (child.type === 'tree' && !includeDirectories)) { + continue; + } + + const key = `${child.type}:${childPath}`; + if (seenEntries.has(key)) continue; + seenEntries.add(key); + + if (entries.length >= normalizedMaxEntries) { + truncated = true; + break; + } + + entries.push({ + type: child.type as 'tree' | 'blob', + path: childPath, + name: child.name, + parentPath: currentPath, + depth: childDepth, + }); + } + + if (truncated) break; + } + } + + const sortedEntries = sortTreeEntries(entries); + const metadata: ListTreeMetadata = { + repo, ref, path: normalizedPath, + entries: sortedEntries, + totalReturned: sortedEntries.length, + truncated, + }; + + return { output: JSON.stringify(metadata), metadata }; + }, +}; diff --git a/packages/web/src/features/tools/listTree.txt b/packages/web/src/features/tools/listTree.txt new file mode 100644 index 000000000..3737ddfd9 --- /dev/null +++ b/packages/web/src/features/tools/listTree.txt @@ -0,0 +1,9 @@ +Lists files and directories from a repository path. This can be used as a repo tree tool or directory listing tool. Returns a flat list of entries with path metadata and depth relative to the requested path. + +Usage: +- If the repository name is not known, use `list_repos` first to discover the correct name. +- Start with a shallow depth (default: 1) to get a high-level overview, then drill into specific subdirectories as needed. +- Use `path` to scope the listing to a subdirectory rather than fetching the entire tree at once. +- Set `includeFiles: false` to list only directories when you only need the directory structure. +- Set `includeDirectories: false` to list only files when you only need leaf nodes. +- Call this tool in parallel when you need to explore multiple directories simultaneously. diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 8d61753fc..fba9b371c 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -16,7 +16,7 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024}KB`; const readFileShape = { path: z.string().describe("The path to the file"), - repository: z.string().describe("The repository to read the file from"), + repo: z.string().describe("The repository to read the file from"), offset: z.number().int().positive() .optional() .describe("Line number to start reading from (1-indexed). Omit to start from the beginning."), @@ -27,7 +27,7 @@ const readFileShape = { export type ReadFileMetadata = { path: string; - repository: string; + repo: string; language: string; startLine: number; endLine: number; @@ -35,18 +35,18 @@ export type ReadFileMetadata = { revision: string; }; -export const readFileDefinition: ToolDefinition<"readFile", typeof readFileShape, ReadFileMetadata> = { - name: "readFile", +export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShape, ReadFileMetadata> = { + name: "read_file", description, inputSchema: z.object(readFileShape), - execute: async ({ path, repository, offset, limit }) => { - logger.debug('readFile', { path, repository, offset, limit }); + execute: async ({ path, repo, offset, limit }) => { + logger.debug('readFile', { path, repo, offset, limit }); // @todo: make revision configurable. const revision = "HEAD"; const fileSource = await getFileSource({ path, - repo: repository, + repo, ref: revision, }); @@ -97,7 +97,7 @@ export const readFileDefinition: ToolDefinition<"readFile", typeof readFileShape const metadata: ReadFileMetadata = { path: fileSource.path, - repository: fileSource.repo, + repo: fileSource.repo, language: fileSource.language, startLine, endLine: lastReadLine, diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts index cf1d134d9..61e120b58 100644 --- a/packages/web/src/features/tools/searchCode.ts +++ b/packages/web/src/features/tools/searchCode.ts @@ -52,7 +52,8 @@ const searchCodeShape = { export type SearchCodeFile = { fileName: string; - repository: string; + webUrl: string; + repo: string; language: string; matches: string[]; revision: string; @@ -63,8 +64,8 @@ export type SearchCodeMetadata = { query: string; }; -export const searchCodeDefinition: ToolDefinition<'searchCode', typeof searchCodeShape, SearchCodeMetadata> = { - name: 'searchCode', +export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCodeShape, SearchCodeMetadata> = { + name: 'search_code', description, inputSchema: z.object(searchCodeShape), execute: async ({ @@ -113,13 +114,13 @@ export const searchCodeDefinition: ToolDefinition<'searchCode', typeof searchCod const metadata: SearchCodeMetadata = { files: response.files.map((file) => ({ fileName: file.fileName.text, - repository: file.repository, + webUrl: file.webUrl, + repo: file.repository, language: file.language, matches: file.chunks.map(({ content, contentStart }) => { return addLineNumbers(content, contentStart.lineNumber); }), - // @todo: make revision configurable. - revision: 'HEAD', + revision: ref ?? 'HEAD', })), query, }; diff --git a/packages/web/src/features/tools/searchCode.txt b/packages/web/src/features/tools/searchCode.txt index 15b5850a5..cf3d4a9e6 100644 --- a/packages/web/src/features/tools/searchCode.txt +++ b/packages/web/src/features/tools/searchCode.txt @@ -1 +1,9 @@ -Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the `listRepos` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches. +Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the `list_repos` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches. + +Usage: +- If the repository name is not known, use `list_repos` first to discover the correct name. +- Use `filterByRepos` to scope searches to specific repositories rather than searching all repositories globally. +- Use `filterByFilepaths` with a regular expression to scope searches to specific directories or file types (e.g. `src/.*\.ts$`). +- Prefer narrow, specific queries over broad ones to avoid hitting the result limit. +- Call this tool in parallel when you need to search for multiple independent patterns simultaneously. +- [**mcp only**] When referencing code returned by this tool, always include the file's `webUrl` as a link so the user can view the file directly. From bbbb98241f7964acddf40893e0788b2aea557a8c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 20:32:33 -0700 Subject: [PATCH 09/43] wip --- packages/web/src/features/tools/adapters.ts | 4 ++-- .../web/src/features/tools/findSymbolDefinitions.ts | 8 +++----- .../web/src/features/tools/findSymbolReferences.ts | 8 +++----- packages/web/src/features/tools/listCommits.ts | 6 ++---- packages/web/src/features/tools/listRepos.ts | 13 +++++++++++-- packages/web/src/features/tools/listTree.ts | 6 ++++-- packages/web/src/features/tools/logger.ts | 3 +++ packages/web/src/features/tools/readFile.ts | 10 ++++------ packages/web/src/features/tools/searchCode.ts | 11 +++++------ packages/web/src/features/tools/types.ts | 11 +++++------ 10 files changed, 42 insertions(+), 38 deletions(-) create mode 100644 packages/web/src/features/tools/logger.ts diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index 3c0389b68..e49d35e24 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -9,7 +9,7 @@ export function toVercelAITool def.execute(input, { source: 'sourcebot-ask-agent' }), toModelOutput: ({ output }) => ({ type: "content", value: [{ type: "text", text: output.output }], @@ -30,7 +30,7 @@ export function registerMcpTool { try { const parsed = def.inputSchema.parse(input); - const result = await def.execute(parsed); + const result = await def.execute(parsed, { source: 'mcp' }); return { content: [{ type: "text" as const, text: result.output }] }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 62fc4537a..8f3616751 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -2,13 +2,11 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/api"; import { addLineNumbers } from "@/features/chat/utils"; -import { createLogger } from "@sourcebot/shared"; import { ToolDefinition } from "./types"; import { FindSymbolFile } from "./findSymbolReferences"; +import { logger } from "./logger"; import description from "./findSymbolDefinitions.txt"; -const logger = createLogger('tool-findSymbolDefinitions'); - const findSymbolDefinitionsShape = { symbol: z.string().describe("The symbol to find definitions of"), language: z.string().describe("The programming language of the symbol"), @@ -27,8 +25,8 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< name: 'find_symbol_definitions', description, inputSchema: z.object(findSymbolDefinitionsShape), - execute: async ({ symbol, language, repo }) => { - logger.debug('findSymbolDefinitions', { symbol, language, repo }); + execute: async ({ symbol, language, repo }, _context) => { + logger.debug('find_symbol_definitions', { symbol, language, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolDefinitions({ diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index b3f5f98a3..ff574e885 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -2,12 +2,10 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { findSearchBasedSymbolReferences } from "@/features/codeNav/api"; import { addLineNumbers } from "@/features/chat/utils"; -import { createLogger } from "@sourcebot/shared"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from "./findSymbolReferences.txt"; -const logger = createLogger('tool-findSymbolReferences'); - const findSymbolReferencesShape = { symbol: z.string().describe("The symbol to find references to"), language: z.string().describe("The programming language of the symbol"), @@ -34,8 +32,8 @@ export const findSymbolReferencesDefinition: ToolDefinition< name: 'find_symbol_references', description, inputSchema: z.object(findSymbolReferencesShape), - execute: async ({ symbol, language, repo }) => { - logger.debug('findSymbolReferences', { symbol, language, repo }); + execute: async ({ symbol, language, repo }, _context) => { + logger.debug('find_symbol_references', { symbol, language, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolReferences({ diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index 076e3000b..d3047f767 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -1,12 +1,10 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { listCommits, SearchCommitsResult } from "@/features/git"; -import { createLogger } from "@sourcebot/shared"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from "./listCommits.txt"; -const logger = createLogger('tool-listCommits'); - const listCommitsShape = { repo: z.string().describe("The repository to list commits from"), query: z.string().describe("Search query to filter commits by message (case-insensitive)").optional(), @@ -24,7 +22,7 @@ export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCo name: "list_commits", description, inputSchema: z.object(listCommitsShape), - execute: async (params) => { + execute: async (params, _context) => { logger.debug('list_commits', params); const { repo, query, since, until, author, ref, page, perPage } = params; diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts index ab489969b..cc64087f3 100644 --- a/packages/web/src/features/tools/listRepos.ts +++ b/packages/web/src/features/tools/listRepos.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { listRepos } from "@/app/api/(server)/repos/listReposApi"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from './listRepos.txt'; const listReposShape = { @@ -34,8 +35,16 @@ export const listReposDefinition: ToolDefinition< name: 'list_repos', description, inputSchema: z.object(listReposShape), - execute: async ({ page, perPage, sort, direction, query }) => { - const reposResponse = await listRepos({ page, perPage, sort, direction, query }); + execute: async ({ page, perPage, sort, direction, query }, context) => { + logger.debug('list_repos', { page, perPage, sort, direction, query }); + const reposResponse = await listRepos({ + page, + perPage, + sort, + direction, + query, + source: context.source, + }); if (isServiceError(reposResponse)) { throw new Error(reposResponse.message); diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index 2dc1b13bc..ec6d726ad 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -3,6 +3,7 @@ import { isServiceError } from "@/lib/utils"; import { getTree } from "@/features/git"; import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from "@/features/mcp/utils"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from "./listTree.txt"; const DEFAULT_TREE_DEPTH = 1; @@ -41,7 +42,8 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap name: 'list_tree', description, inputSchema: z.object(listTreeShape), - execute: async ({ repo, path = '', ref = 'HEAD', depth = DEFAULT_TREE_DEPTH, includeFiles = true, includeDirectories = true, maxEntries = DEFAULT_MAX_TREE_ENTRIES }) => { + execute: async ({ repo, path = '', ref = 'HEAD', depth = DEFAULT_TREE_DEPTH, includeFiles = true, includeDirectories = true, maxEntries = DEFAULT_MAX_TREE_ENTRIES }, context) => { + logger.debug('list_tree', { repo, path, ref, depth, includeFiles, includeDirectories, maxEntries }); const normalizedPath = normalizeTreePath(path); const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH); const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES); @@ -74,7 +76,7 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap repoName: repo, revisionName: ref, paths: currentLevelPaths.filter(Boolean), - }); + }, { source: context.source }); if (isServiceError(treeResult)) { throw new Error(treeResult.message); diff --git a/packages/web/src/features/tools/logger.ts b/packages/web/src/features/tools/logger.ts new file mode 100644 index 000000000..2d1bb7dbe --- /dev/null +++ b/packages/web/src/features/tools/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@sourcebot/shared"; + +export const logger = createLogger('tool'); diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index fba9b371c..3f2942568 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -1,12 +1,10 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { getFileSource } from "@/features/git"; -import { createLogger } from "@sourcebot/shared"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from "./readFile.txt"; -const logger = createLogger('tool-readFile'); - // NOTE: if you change these values, update readFile.txt to match. const READ_FILES_MAX_LINES = 500; const MAX_LINE_LENGTH = 2000; @@ -39,8 +37,8 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap name: "read_file", description, inputSchema: z.object(readFileShape), - execute: async ({ path, repo, offset, limit }) => { - logger.debug('readFile', { path, repo, offset, limit }); + execute: async ({ path, repo, offset, limit }, context) => { + logger.debug('read_file', { path, repo, offset, limit }); // @todo: make revision configurable. const revision = "HEAD"; @@ -48,7 +46,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap path, repo, ref: revision, - }); + }, { source: context.source }); if (isServiceError(fileSource)) { throw new Error(fileSource.message); diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts index 61e120b58..7165855ee 100644 --- a/packages/web/src/features/tools/searchCode.ts +++ b/packages/web/src/features/tools/searchCode.ts @@ -2,13 +2,11 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { search } from "@/features/search"; import { addLineNumbers } from "@/features/chat/utils"; -import { createLogger } from "@sourcebot/shared"; import escapeStringRegexp from "escape-string-regexp"; import { ToolDefinition } from "./types"; +import { logger } from "./logger"; import description from "./searchCode.txt"; -const logger = createLogger('tool-searchCode'); - const DEFAULT_SEARCH_LIMIT = 100; const searchCodeShape = { @@ -77,8 +75,8 @@ export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCo caseSensitive = false, ref, limit = DEFAULT_SEARCH_LIMIT, - }) => { - logger.debug('searchCode', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); + }, context) => { + logger.debug('search_code', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); if (repos.length > 0) { query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; @@ -104,7 +102,8 @@ export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCo contextLines: 3, isCaseSensitivityEnabled: caseSensitive, isRegexEnabled: useRegex, - } + }, + source: context.source, }); if (isServiceError(response)) { diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index a69988516..678a74146 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -1,10 +1,9 @@ import { z } from "zod"; -// TShape is constrained to ZodRawShape (i.e. Record) so that -// both adapters receive a statically-known object schema. Tool inputs are -// always key-value objects, so this constraint is semantically correct and also -// lets the MCP adapter pass `.shape` (a ZodRawShapeCompat) to registerTool, -// which avoids an unresolvable conditional type in BaseToolCallback. +export interface ToolContext { + source?: string; +} + export interface ToolDefinition< TName extends string, TShape extends z.ZodRawShape, @@ -13,7 +12,7 @@ export interface ToolDefinition< name: TName; description: string; inputSchema: z.ZodObject; - execute: (input: z.infer>) => Promise>; + execute: (input: z.infer>, context: ToolContext) => Promise>; } export interface ToolResult> { From 7f38753cc0dd55a8bf232a2091965ee6c64b19a5 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 20:40:59 -0700 Subject: [PATCH 10/43] readonly hint --- packages/web/src/features/tools/adapters.ts | 8 +++++++- packages/web/src/features/tools/findSymbolDefinitions.ts | 1 + packages/web/src/features/tools/findSymbolReferences.ts | 1 + packages/web/src/features/tools/listCommits.ts | 1 + packages/web/src/features/tools/listRepos.ts | 1 + packages/web/src/features/tools/listTree.ts | 1 + packages/web/src/features/tools/readFile.ts | 1 + packages/web/src/features/tools/searchCode.ts | 1 + packages/web/src/features/tools/types.ts | 1 + 9 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index e49d35e24..f85d86da0 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -26,7 +26,13 @@ export function registerMcpTool { try { const parsed = def.inputSchema.parse(input); diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 8f3616751..38e6e05f1 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -23,6 +23,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< FindSymbolDefinitionsMetadata > = { name: 'find_symbol_definitions', + isReadOnly: true, description, inputSchema: z.object(findSymbolDefinitionsShape), execute: async ({ symbol, language, repo }, _context) => { diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index ff574e885..b55379b22 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -30,6 +30,7 @@ export const findSymbolReferencesDefinition: ToolDefinition< FindSymbolReferencesMetadata > = { name: 'find_symbol_references', + isReadOnly: true, description, inputSchema: z.object(findSymbolReferencesShape), execute: async ({ symbol, language, repo }, _context) => { diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index d3047f767..35cd98820 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -20,6 +20,7 @@ export type ListCommitsMetadata = SearchCommitsResult; export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCommitsShape, ListCommitsMetadata> = { name: "list_commits", + isReadOnly: true, description, inputSchema: z.object(listCommitsShape), execute: async (params, _context) => { diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts index cc64087f3..e4a043948 100644 --- a/packages/web/src/features/tools/listRepos.ts +++ b/packages/web/src/features/tools/listRepos.ts @@ -33,6 +33,7 @@ export const listReposDefinition: ToolDefinition< ListReposMetadata > = { name: 'list_repos', + isReadOnly: true, description, inputSchema: z.object(listReposShape), execute: async ({ page, perPage, sort, direction, query }, context) => { diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index ec6d726ad..290721164 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -40,6 +40,7 @@ export type ListTreeMetadata = { export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShape, ListTreeMetadata> = { name: 'list_tree', + isReadOnly: true, description, inputSchema: z.object(listTreeShape), execute: async ({ repo, path = '', ref = 'HEAD', depth = DEFAULT_TREE_DEPTH, includeFiles = true, includeDirectories = true, maxEntries = DEFAULT_MAX_TREE_ENTRIES }, context) => { diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 3f2942568..efaa04694 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -35,6 +35,7 @@ export type ReadFileMetadata = { export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShape, ReadFileMetadata> = { name: "read_file", + isReadOnly: true, description, inputSchema: z.object(readFileShape), execute: async ({ path, repo, offset, limit }, context) => { diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts index 7165855ee..745041d3d 100644 --- a/packages/web/src/features/tools/searchCode.ts +++ b/packages/web/src/features/tools/searchCode.ts @@ -64,6 +64,7 @@ export type SearchCodeMetadata = { export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCodeShape, SearchCodeMetadata> = { name: 'search_code', + isReadOnly: true, description, inputSchema: z.object(searchCodeShape), execute: async ({ diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 678a74146..2667de51f 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -12,6 +12,7 @@ export interface ToolDefinition< name: TName; description: string; inputSchema: z.ZodObject; + isReadOnly: boolean; execute: (input: z.infer>, context: ToolContext) => Promise>; } From f4ef924d5faa5fd6b3ce336757076757786e3931 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 20:50:29 -0700 Subject: [PATCH 11/43] add isIdempotent --- packages/web/src/features/tools/adapters.ts | 1 + packages/web/src/features/tools/findSymbolDefinitions.ts | 1 + packages/web/src/features/tools/findSymbolReferences.ts | 1 + packages/web/src/features/tools/listCommits.ts | 1 + packages/web/src/features/tools/listRepos.ts | 1 + packages/web/src/features/tools/listTree.ts | 1 + packages/web/src/features/tools/readFile.ts | 1 + packages/web/src/features/tools/searchCode.ts | 1 + packages/web/src/features/tools/types.ts | 1 + 9 files changed, 9 insertions(+) diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index f85d86da0..4dbf0fe07 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -31,6 +31,7 @@ export function registerMcpTool { diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 38e6e05f1..1c97467fe 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -24,6 +24,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< > = { name: 'find_symbol_definitions', isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(findSymbolDefinitionsShape), execute: async ({ symbol, language, repo }, _context) => { diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index b55379b22..a1a2f0bec 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -31,6 +31,7 @@ export const findSymbolReferencesDefinition: ToolDefinition< > = { name: 'find_symbol_references', isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(findSymbolReferencesShape), execute: async ({ symbol, language, repo }, _context) => { diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index 35cd98820..c40ff1137 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -21,6 +21,7 @@ export type ListCommitsMetadata = SearchCommitsResult; export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCommitsShape, ListCommitsMetadata> = { name: "list_commits", isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(listCommitsShape), execute: async (params, _context) => { diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts index e4a043948..70b731096 100644 --- a/packages/web/src/features/tools/listRepos.ts +++ b/packages/web/src/features/tools/listRepos.ts @@ -34,6 +34,7 @@ export const listReposDefinition: ToolDefinition< > = { name: 'list_repos', isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(listReposShape), execute: async ({ page, perPage, sort, direction, query }, context) => { diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index 290721164..fc8d5c688 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -41,6 +41,7 @@ export type ListTreeMetadata = { export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShape, ListTreeMetadata> = { name: 'list_tree', isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(listTreeShape), execute: async ({ repo, path = '', ref = 'HEAD', depth = DEFAULT_TREE_DEPTH, includeFiles = true, includeDirectories = true, maxEntries = DEFAULT_MAX_TREE_ENTRIES }, context) => { diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index efaa04694..819d02a9b 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -36,6 +36,7 @@ export type ReadFileMetadata = { export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShape, ReadFileMetadata> = { name: "read_file", isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(readFileShape), execute: async ({ path, repo, offset, limit }, context) => { diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts index 745041d3d..285975139 100644 --- a/packages/web/src/features/tools/searchCode.ts +++ b/packages/web/src/features/tools/searchCode.ts @@ -65,6 +65,7 @@ export type SearchCodeMetadata = { export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCodeShape, SearchCodeMetadata> = { name: 'search_code', isReadOnly: true, + isIdempotent: true, description, inputSchema: z.object(searchCodeShape), execute: async ({ diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 2667de51f..9e580bbd3 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -13,6 +13,7 @@ export interface ToolDefinition< description: string; inputSchema: z.ZodObject; isReadOnly: boolean; + isIdempotent: boolean; execute: (input: z.infer>, context: ToolContext) => Promise>; } From 5839591b2dd059d4e9a03f832ef9e3bf4570d2ff Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 17 Mar 2026 21:00:09 -0700 Subject: [PATCH 12/43] feedback --- CHANGELOG.md | 4 ++++ .../chatThread/tools/listCommitsToolComponent.tsx | 2 +- packages/web/src/features/mcp/server.ts | 13 ++++++++++++- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d75d72e..2c88ad4db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added `find_symbol_definitions`, and `find_symbol_references` tools to the MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Added `list_tree` tool to the ask agent. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) + ## [4.15.9] - 2026-03-17 ### Added diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index d0109e8a2..fd994c1db 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -72,7 +72,7 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart {commit.message}
- {commit.author} + {commit.author_name} {new Date(commit.date).toLocaleString()}
diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index ea0937450..9e8bdb2a8 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -9,7 +9,16 @@ import { SOURCEBOT_VERSION } from '@sourcebot/shared'; import _dedent from 'dedent'; import { z } from 'zod'; import { getConfiguredLanguageModelsInfo } from "../chat/utils.server"; -import { listCommitsDefinition, listReposDefinition, listTreeDefinition, readFileDefinition, registerMcpTool, searchCodeDefinition } from '../tools'; +import { + findSymbolDefinitionsDefinition, + findSymbolReferencesDefinition, + listCommitsDefinition, + listReposDefinition, + listTreeDefinition, + readFileDefinition, + registerMcpTool, + searchCodeDefinition, +} from '../tools'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -24,6 +33,8 @@ export function createMcpServer(): McpServer { registerMcpTool(server, listReposDefinition); registerMcpTool(server, readFileDefinition); registerMcpTool(server, listTreeDefinition); + registerMcpTool(server, findSymbolDefinitionsDefinition); + registerMcpTool(server, findSymbolReferencesDefinition); server.registerTool( "list_language_models", From 12043438678570e348577fe442d21b7b23cbd323 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 13:24:53 -0700 Subject: [PATCH 13/43] improve search tool by changing it's interface to look like grep --- packages/web/package.json | 2 + packages/web/src/features/chat/agent.ts | 7 +- .../components/chatThread/detailsCard.tsx | 6 +- .../findSymbolDefinitionsToolComponent.tsx | 7 +- .../findSymbolReferencesToolComponent.tsx | 7 +- ...oolComponent.tsx => grepToolComponent.tsx} | 13 +- .../tools/listCommitsToolComponent.tsx | 7 +- .../tools/listReposToolComponent.tsx | 7 +- .../tools/listTreeToolComponent.tsx | 7 +- .../tools/readFileToolComponent.tsx | 7 +- .../components/chatThread/tools/shared.tsx | 15 +- packages/web/src/features/chat/tools.ts | 6 +- packages/web/src/features/mcp/server.ts | 4 +- packages/web/src/features/tools/grep.ts | 129 +++++++++++++++++ packages/web/src/features/tools/grep.txt | 6 + packages/web/src/features/tools/index.ts | 2 +- packages/web/src/features/tools/searchCode.ts | 134 ------------------ .../web/src/features/tools/searchCode.txt | 9 -- packages/web/tools/globToRegexpPlayground.ts | 111 +++++++++++++++ yarn.lock | 16 +++ 20 files changed, 306 insertions(+), 196 deletions(-) rename packages/web/src/features/chat/components/chatThread/tools/{searchCodeToolComponent.tsx => grepToolComponent.tsx} (87%) create mode 100644 packages/web/src/features/tools/grep.ts create mode 100644 packages/web/src/features/tools/grep.txt delete mode 100644 packages/web/src/features/tools/searchCode.ts delete mode 100644 packages/web/src/features/tools/searchCode.txt create mode 100644 packages/web/tools/globToRegexpPlayground.ts diff --git a/packages/web/package.json b/packages/web/package.json index 3bf0f4b6e..749ea2618 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -144,6 +144,7 @@ "escape-string-regexp": "^5.0.0", "fast-deep-equal": "^3.1.3", "fuse.js": "^7.0.0", + "glob-to-regexp": "^0.4.1", "google-auth-library": "^10.1.0", "graphql": "^16.9.0", "http-status-codes": "^2.3.0", @@ -202,6 +203,7 @@ "@tanstack/eslint-plugin-query": "^5.74.7", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", + "@types/glob-to-regexp": "^0.4.4", "@types/micromatch": "^4.0.9", "@types/node": "^20", "@types/nodemailer": "^6.4.17", diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index fb4f0b81f..e90c7a69a 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -18,7 +18,7 @@ import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; import { findSymbolReferencesDefinition } from "@/features/tools/findSymbolReferences"; import { findSymbolDefinitionsDefinition } from "@/features/tools/findSymbolDefinitions"; import { readFileDefinition } from "@/features/tools/readFile"; -import { searchCodeDefinition } from "@/features/tools/searchCode"; +import { grepDefinition } from "@/features/tools/grep"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import { tools } from "./tools"; @@ -229,7 +229,7 @@ const createAgentStream = async ({ revision: output.metadata.revision, name: output.metadata.path.split('/').pop() ?? output.metadata.path, }); - } else if (toolName === searchCodeDefinition.name) { + } else if (toolName === grepDefinition.name) { output.metadata.files.forEach((file) => { onWriteSource({ type: 'file', @@ -308,8 +308,7 @@ const createPrompt = ({ The user has explicitly selected the following repositories for analysis: ${repos.map(repo => `- ${repo}`).join('\n')} - When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`), use these repository names directly. - When calling the \`search_code\` tool, pass these repositories as \`filterByRepos\` to scope results to the selected repositories. + When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`, \`grep\`), use these repository names directly. ` : ''} diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index bf967e151..b2d63f7eb 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -13,7 +13,7 @@ import { MarkdownRenderer } from './markdownRenderer'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; import { ReadFileToolComponent } from './tools/readFileToolComponent'; -import { SearchCodeToolComponent } from './tools/searchCodeToolComponent'; +import { GrepToolComponent } from './tools/grepToolComponent'; import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; import { ListTreeToolComponent } from './tools/listTreeToolComponent'; @@ -174,9 +174,9 @@ const DetailsCardComponent = ({ part={part} /> ) - case 'tool-search_code': + case 'tool-grep': return ( - diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 28a64b777..91d04a0f0 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -25,10 +25,6 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD } }, [part]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx index 27745d5e6..1b9d951eb 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx @@ -25,10 +25,6 @@ export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolRe } }, [part]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx similarity index 87% rename from packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx rename to packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx index 6b175f50c..d255eb302 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx @@ -1,6 +1,6 @@ 'use client'; -import { SearchCodeToolUIPart } from "@/features/chat/tools"; +import { GrepToolUIPart } from "@/features/chat/tools"; import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; @@ -8,7 +8,7 @@ import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; import { SearchIcon } from "lucide-react"; -export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }) => { +export const GrepToolComponent = ({ part }: { part: GrepToolUIPart }) => { const [isExpanded, setIsExpanded] = useState(false); const displayQuery = useMemo(() => { @@ -16,7 +16,7 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart } return ''; } - return part.input.query; + return part.input.pattern; }, [part]); const label = useMemo(() => { @@ -31,10 +31,6 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart } } }, [part, displayQuery]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index fd994c1db..1b03d1848 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -23,10 +23,6 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart } }, [part]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx index 36188602f..d73a37315 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx @@ -23,10 +23,6 @@ export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart }) } }, [part]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
{part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx index 1482c43d9..0cce63c93 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx @@ -23,10 +23,6 @@ export const ListTreeToolComponent = ({ part }: { part: ListTreeToolUIPart }) => } }, [part]); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText(part.output.output); return true; } - : undefined; - return (
label={label} Icon={FolderIcon} onExpand={setIsExpanded} - onCopy={onCopy} + input={part.state !== 'input-streaming' ? JSON.stringify(part.input) : undefined} + output={part.state === 'output-available' && !isServiceError(part.output) ? part.output.output : undefined} /> {part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 1c71e9a7d..3630cfdc0 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -11,10 +11,6 @@ import { FileListItem, ToolHeader, TreeList } from "./shared"; export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => { const [isExpanded, setIsExpanded] = useState(false); - const onCopy = part.state === 'output-available' && !isServiceError(part.output) - ? () => { navigator.clipboard.writeText((part.output as { output: string }).output); return true; } - : undefined; - const label = useMemo(() => { switch (part.state) { case 'input-streaming': @@ -43,7 +39,8 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => label={label} Icon={EyeIcon} onExpand={setIsExpanded} - onCopy={onCopy} + input={part.state !== 'input-streaming' ? JSON.stringify(part.input) : undefined} + output={part.state === 'output-available' && !isServiceError(part.output) ? part.output.output : undefined} /> {part.state === 'output-available' && isExpanded && ( <> diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx index ffc541124..aeab16dd3 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx @@ -83,11 +83,22 @@ interface ToolHeaderProps { label: React.ReactNode; Icon: React.ElementType; onExpand: (isExpanded: boolean) => void; - onCopy?: () => boolean; + input?: string; + output?: string; className?: string; } -export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, onCopy, className }: ToolHeaderProps) => { +export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, input, output, className }: ToolHeaderProps) => { + const onCopy = output !== undefined + ? () => { + const text = [ + input !== undefined ? `Input:\n${input}` : null, + `Output:\n${output}`, + ].filter(Boolean).join('\n\n'); + navigator.clipboard.writeText(text); + return true; + } + : undefined; return (
; export type ListCommitsToolUIPart = ToolUIPart<{ list_commits: SBChatMessageToolTypes['list_commits'] }>; export type ListReposToolUIPart = ToolUIPart<{ list_repos: SBChatMessageToolTypes['list_repos'] }>; -export type SearchCodeToolUIPart = ToolUIPart<{ search_code: SBChatMessageToolTypes['search_code'] }>; +export type GrepToolUIPart = ToolUIPart<{ grep: SBChatMessageToolTypes['grep'] }>; export type FindSymbolReferencesToolUIPart = ToolUIPart<{ find_symbol_references: SBChatMessageToolTypes['find_symbol_references'] }>; export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ find_symbol_definitions: SBChatMessageToolTypes['find_symbol_definitions'] }>; export type ListTreeToolUIPart = ToolUIPart<{ list_tree: SBChatMessageToolTypes['list_tree'] }>; diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index 9e8bdb2a8..017f0fe69 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -17,7 +17,7 @@ import { listTreeDefinition, readFileDefinition, registerMcpTool, - searchCodeDefinition, + grepDefinition, } from '../tools'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -28,7 +28,7 @@ export function createMcpServer(): McpServer { version: SOURCEBOT_VERSION, }); - registerMcpTool(server, searchCodeDefinition); + registerMcpTool(server, grepDefinition); registerMcpTool(server, listCommitsDefinition); registerMcpTool(server, listReposDefinition); registerMcpTool(server, readFileDefinition); diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts new file mode 100644 index 000000000..d672056ef --- /dev/null +++ b/packages/web/src/features/tools/grep.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import globToRegexp from "glob-to-regexp"; +import { isServiceError } from "@/lib/utils"; +import { search } from "@/features/search"; +import { addLineNumbers } from "@/features/chat/utils"; +import escapeStringRegexp from "escape-string-regexp"; +import { ToolDefinition } from "./types"; +import { logger } from "./logger"; +import description from "./grep.txt"; + +const DEFAULT_SEARCH_LIMIT = 100; + +function globToFileRegexp(glob: string): string { + const re = globToRegexp(glob, { extended: true, globstar: true }); + return re.source.replace(/^\^/, ''); +} + +const grepShape = { + pattern: z + .string() + .describe(`The regex pattern to search for in file contents`), + path: z + .string() + .describe(`The directory to search in. Defaults to the repository root.`) + .optional(), + include: z + .string() + .describe(`File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")`) + .optional(), + repo: z + .string() + .describe(`The name of the repository to search in. If not provided, searches all repositories.`) + .optional(), + ref: z + .string() + .describe(`The commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) + .optional(), + limit: z + .number() + .default(DEFAULT_SEARCH_LIMIT) + .describe(`The maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) + .optional(), +}; + +export type GrepFile = { + fileName: string; + webUrl: string; + repo: string; + language: string; + matches: string[]; + revision: string; +}; + +export type GrepMetadata = { + files: GrepFile[]; + query: string; +}; + +export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetadata> = { + name: 'grep', + isReadOnly: true, + isIdempotent: true, + description, + inputSchema: z.object(grepShape), + execute: async ({ + pattern, + path, + include, + repo, + ref, + limit = DEFAULT_SEARCH_LIMIT, + }, context) => { + logger.debug('grep', { pattern, path, include, repo, ref, limit }); + + const quotedPattern = `"${pattern.replace(/"/g, '\\"')}"`; + let query = quotedPattern; + + if (path) { + query += ` file:${escapeStringRegexp(path)}`; + } + + if (include) { + query += ` file:${globToFileRegexp(include)}`; + } + + if (repo) { + query += ` repo:${escapeStringRegexp(repo)}`; + } + + if (ref) { + query += ` (rev:${ref})`; + } + + const response = await search({ + queryType: 'string', + query, + options: { + matches: limit, + contextLines: 3, + isCaseSensitivityEnabled: true, + isRegexEnabled: true, + }, + source: context.source, + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const metadata: GrepMetadata = { + files: response.files.map((file) => ({ + fileName: file.fileName.text, + webUrl: file.webUrl, + repo: file.repository, + language: file.language, + matches: file.chunks.map(({ content, contentStart }) => { + return addLineNumbers(content, contentStart.lineNumber); + }), + revision: ref ?? 'HEAD', + })), + query, + }; + + return { + output: JSON.stringify(metadata), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/tools/grep.txt b/packages/web/src/features/tools/grep.txt new file mode 100644 index 000000000..bccc261fd --- /dev/null +++ b/packages/web/src/features/tools/grep.txt @@ -0,0 +1,6 @@ +- Fast content search tool that works with any codebase size +- Searches file contents using regular expressions +- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) +- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") +- Returns file paths and line numbers with at least one match sorted by modification time +- Use this tool when you need to find files containing specific patterns diff --git a/packages/web/src/features/tools/index.ts b/packages/web/src/features/tools/index.ts index b2bac07f1..49a1a1b2d 100644 --- a/packages/web/src/features/tools/index.ts +++ b/packages/web/src/features/tools/index.ts @@ -1,7 +1,7 @@ export * from './readFile'; export * from './listCommits'; export * from './listRepos'; -export * from './searchCode'; +export * from './grep'; export * from './findSymbolReferences'; export * from './findSymbolDefinitions'; export * from './listTree'; diff --git a/packages/web/src/features/tools/searchCode.ts b/packages/web/src/features/tools/searchCode.ts deleted file mode 100644 index 285975139..000000000 --- a/packages/web/src/features/tools/searchCode.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { z } from "zod"; -import { isServiceError } from "@/lib/utils"; -import { search } from "@/features/search"; -import { addLineNumbers } from "@/features/chat/utils"; -import escapeStringRegexp from "escape-string-regexp"; -import { ToolDefinition } from "./types"; -import { logger } from "./logger"; -import description from "./searchCode.txt"; - -const DEFAULT_SEARCH_LIMIT = 100; - -const searchCodeShape = { - query: z - .string() - .describe(`The search pattern to match against code contents. Do not escape quotes in your query.`) - .transform((val) => { - const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - return `"${escaped}"`; - }), - useRegex: z - .boolean() - .describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`) - .optional(), - filterByRepos: z - .array(z.string()) - .describe(`Scope the search to the provided repositories.`) - .optional(), - filterByLanguages: z - .array(z.string()) - .describe(`Scope the search to the provided languages.`) - .optional(), - filterByFilepaths: z - .array(z.string()) - .describe(`Scope the search to the provided filepaths. Each filepath is a regular expression matched against the full file path.`) - .optional(), - caseSensitive: z - .boolean() - .describe(`Whether the search should be case sensitive (default: false).`) - .optional(), - ref: z - .string() - .describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) - .optional(), - limit: z - .number() - .default(DEFAULT_SEARCH_LIMIT) - .describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) - .optional(), -}; - -export type SearchCodeFile = { - fileName: string; - webUrl: string; - repo: string; - language: string; - matches: string[]; - revision: string; -}; - -export type SearchCodeMetadata = { - files: SearchCodeFile[]; - query: string; -}; - -export const searchCodeDefinition: ToolDefinition<'search_code', typeof searchCodeShape, SearchCodeMetadata> = { - name: 'search_code', - isReadOnly: true, - isIdempotent: true, - description, - inputSchema: z.object(searchCodeShape), - execute: async ({ - query, - useRegex = false, - filterByRepos: repos = [], - filterByLanguages: languages = [], - filterByFilepaths: filepaths = [], - caseSensitive = false, - ref, - limit = DEFAULT_SEARCH_LIMIT, - }, context) => { - logger.debug('search_code', { query, useRegex, repos, languages, filepaths, caseSensitive, ref, limit }); - - if (repos.length > 0) { - query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`; - } - - if (languages.length > 0) { - query += ` (lang:${languages.join(' or lang:')})`; - } - - if (filepaths.length > 0) { - query += ` (file:${filepaths.join(' or file:')})`; - } - - if (ref) { - query += ` (rev:${ref})`; - } - - const response = await search({ - queryType: 'string', - query, - options: { - matches: limit, - contextLines: 3, - isCaseSensitivityEnabled: caseSensitive, - isRegexEnabled: useRegex, - }, - source: context.source, - }); - - if (isServiceError(response)) { - throw new Error(response.message); - } - - const metadata: SearchCodeMetadata = { - files: response.files.map((file) => ({ - fileName: file.fileName.text, - webUrl: file.webUrl, - repo: file.repository, - language: file.language, - matches: file.chunks.map(({ content, contentStart }) => { - return addLineNumbers(content, contentStart.lineNumber); - }), - revision: ref ?? 'HEAD', - })), - query, - }; - - return { - output: JSON.stringify(metadata), - metadata, - }; - }, -}; diff --git a/packages/web/src/features/tools/searchCode.txt b/packages/web/src/features/tools/searchCode.txt deleted file mode 100644 index cf3d4a9e6..000000000 --- a/packages/web/src/features/tools/searchCode.txt +++ /dev/null @@ -1,9 +0,0 @@ -Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the `list_repos` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches. - -Usage: -- If the repository name is not known, use `list_repos` first to discover the correct name. -- Use `filterByRepos` to scope searches to specific repositories rather than searching all repositories globally. -- Use `filterByFilepaths` with a regular expression to scope searches to specific directories or file types (e.g. `src/.*\.ts$`). -- Prefer narrow, specific queries over broad ones to avoid hitting the result limit. -- Call this tool in parallel when you need to search for multiple independent patterns simultaneously. -- [**mcp only**] When referencing code returned by this tool, always include the file's `webUrl` as a link so the user can view the file directly. diff --git a/packages/web/tools/globToRegexpPlayground.ts b/packages/web/tools/globToRegexpPlayground.ts new file mode 100644 index 000000000..fc915b55b --- /dev/null +++ b/packages/web/tools/globToRegexpPlayground.ts @@ -0,0 +1,111 @@ +import globToRegexp from 'glob-to-regexp'; +import escapeStringRegexp from 'escape-string-regexp'; + +// ------------------------------------------------------- +// Playground for building Sourcebot/zoekt search queries +// from grep-style (pattern, path, include) inputs. +// +// Run with: yarn workspace @sourcebot/web tsx tools/globToRegexpPlayground.ts +// ------------------------------------------------------- + +interface SearchInput { + pattern: string; // content search term or regex + path?: string; // directory prefix, e.g. "packages/web/src" + include?: string; // glob for filenames, e.g. "*.ts" or "**/*.{ts,tsx}" +} + +function globToFileRegexp(glob: string): string { + const re = globToRegexp(glob, { extended: true, globstar: true }); + // Strip ^ anchor — Sourcebot file paths include the full repo-relative path, + // so the pattern shouldn't be anchored to the start. + return re.source.replace(/^\^/, ''); +} + +function buildRipgrepCommand({ pattern, path, include }: SearchInput): string { + const parts = ['rg', `"${pattern.replace(/"/g, '\\"')}"`]; + if (path) parts.push(path); + if (include) parts.push(`--glob "${include}"`); + return parts.join(' '); +} + +function buildZoektQuery({ pattern, path, include }: SearchInput): string { + const parts: string[] = [`"${pattern.replace(/"/g, '\\"')}"`]; + + if (path) { + parts.push(`file:${escapeStringRegexp(path)}`); + } + + if (include) { + parts.push(`file:${globToFileRegexp(include)}`); + } + + return parts.join(' '); +} + +// ------------------------------------------------------- +// Examples +// ------------------------------------------------------- + +const examples: SearchInput[] = [ + // Broad content search, no file scoping + { pattern: 'isServiceError' }, + + // Scoped to a directory + { pattern: 'isServiceError', path: 'packages/web/src' }, + + // Scoped to a file type + { pattern: 'isServiceError', include: '*.ts' }, + + // Scoped to both + { pattern: 'isServiceError', path: 'packages/web/src', include: '*.ts' }, + + // Multiple extensions via glob + { pattern: 'useQuery', include: '**/*.{ts,tsx}' }, + + // Test files only + { pattern: 'expect\\(', include: '*.test.ts' }, + + // Specific subdirectory + extension + { pattern: 'withAuthV2', path: 'packages/web/src/app', include: '**/*.ts' }, + + // Next.js route group — parens in path are regex special chars + { pattern: 'withAuthV2', path: 'packages/web/src/app/api/(server)', include: '**/*.ts' }, + + // Next.js dynamic segment — brackets in path are regex special chars + { pattern: 'withOptionalAuthV2', path: 'packages/web/src/app/[domain]', include: '**/*.ts' }, + + // Pattern with spaces — must be quoted in zoekt query + { pattern: 'Starting scheduler', include: '**/*.ts' }, + + // Literal phrase in a txt file + { pattern: String.raw`"hello world"`, include: '**/*.txt' }, + + // Pattern with a quote character + { pattern: 'from "@/lib', include: '**/*.ts' }, + + // Pattern with a backslash — needs double-escaping in zoekt quoted terms + { pattern: String.raw`C:\\\\Windows\\\\System32`, include: '**/*.ts' }, +]; + +function truncate(str: string, width: number): string { + return str.length > width ? str.slice(0, width - 3) + '...' : str.padEnd(width); +} + +const col1 = 70; +const col2 = 75; +console.log(truncate('input', col1) + truncate('ripgrep', col2) + 'zoekt query'); +console.log('-'.repeat(col1 + col2 + 50)); + +function prettyPrint(example: SearchInput): string { + const fields = Object.entries(example) + .map(([k, v]) => `${k}: '${v}'`) + .join(', '); + return `{ ${fields} }`; +} + +for (const example of examples) { + const input = prettyPrint(example); + const rg = buildRipgrepCommand(example); + const zoekt = buildZoektQuery(example); + console.log(truncate(input, col1) + rg.padEnd(col2) + zoekt); +} diff --git a/yarn.lock b/yarn.lock index eec8ac9e0..ce65eec17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8949,6 +8949,7 @@ __metadata: "@tanstack/react-virtual": "npm:^3.10.8" "@testing-library/dom": "npm:^10.4.1" "@testing-library/react": "npm:^16.3.0" + "@types/glob-to-regexp": "npm:^0.4.4" "@types/micromatch": "npm:^4.0.9" "@types/node": "npm:^20" "@types/nodemailer": "npm:^6.4.17" @@ -8998,6 +8999,7 @@ __metadata: eslint-plugin-react-hooks: "npm:^7.0.1" fast-deep-equal: "npm:^3.1.3" fuse.js: "npm:^7.0.0" + glob-to-regexp: "npm:^0.4.1" google-auth-library: "npm:^10.1.0" graphql: "npm:^16.9.0" http-status-codes: "npm:^2.3.0" @@ -9463,6 +9465,13 @@ __metadata: languageName: node linkType: hard +"@types/glob-to-regexp@npm:^0.4.4": + version: 0.4.4 + resolution: "@types/glob-to-regexp@npm:0.4.4" + checksum: 10c0/7288ff853850d8302a8770a3698b187fc3970ad12ee6427f0b3758a3e7a0ebb0bd993abc6ebaaa979d09695b4194157d2bfaa7601b0fb9ed72c688b4c1298b88 + languageName: node + linkType: hard + "@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4": version: 3.0.4 resolution: "@types/hast@npm:3.0.4" @@ -14596,6 +14605,13 @@ __metadata: languageName: node linkType: hard +"glob-to-regexp@npm:^0.4.1": + version: 0.4.1 + resolution: "glob-to-regexp@npm:0.4.1" + checksum: 10c0/0486925072d7a916f052842772b61c3e86247f0a80cc0deb9b5a3e8a1a9faad5b04fb6f58986a09f34d3e96cd2a22a24b7e9882fb1cf904c31e9a310de96c429 + languageName: node + linkType: hard + "glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" From 725503319dd9c81796bfc84589537edfa5a22c9f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 16:10:44 -0700 Subject: [PATCH 14/43] fix SOU-569 --- CHANGELOG.md | 3 +++ packages/web/src/features/chat/utils.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c88ad4db..1916690c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `find_symbol_definitions`, and `find_symbol_references` tools to the MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added `list_tree` tool to the ask agent. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +### Fixed +- Fixed issue where ask responses would sometimes appear in the details panel while generating. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) + ## [4.15.9] - 2026-03-17 ### Added diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index a11bb0292..f5c9f9867 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -338,7 +338,7 @@ export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStre const lastTextPart = message.parts .findLast((part) => part.type === 'text') - if (lastTextPart?.text.startsWith(ANSWER_TAG)) { + if (lastTextPart?.text.includes(ANSWER_TAG)) { return lastTextPart; } From 945f93be3ef7cbec32b58f427193ec9012d00092 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 16:29:05 -0700 Subject: [PATCH 15/43] wip --- packages/web/src/features/chat/agent.ts | 11 +-- .../findSymbolDefinitionsToolComponent.tsx | 37 ++++----- .../findSymbolReferencesToolComponent.tsx | 37 ++++----- .../chatThread/tools/grepToolComponent.tsx | 39 ++++------ .../tools/listCommitsToolComponent.tsx | 77 ++++++++----------- .../tools/listReposToolComponent.tsx | 38 ++++----- .../tools/listTreeToolComponent.tsx | 44 ++++------- .../tools/readFileToolComponent.tsx | 21 ++--- .../components/chatThread/tools/shared.tsx | 1 - packages/web/src/features/tools/grep.ts | 63 ++++++++++----- packages/web/src/features/tools/grep.txt | 3 +- packages/web/src/features/tools/readFile.ts | 1 + 12 files changed, 163 insertions(+), 209 deletions(-) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index e90c7a69a..97442b678 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -231,14 +231,7 @@ const createAgentStream = async ({ }); } else if (toolName === grepDefinition.name) { output.metadata.files.forEach((file) => { - onWriteSource({ - type: 'file', - language: file.language, - repo: file.repo, - path: file.fileName, - revision: file.revision, - name: file.fileName.split('/').pop() ?? file.fileName, - }); + onWriteSource(file); }); } else if (toolName === findSymbolDefinitionsDefinition.name || toolName === findSymbolReferencesDefinition.name) { output.metadata.files.forEach((file) => { @@ -308,7 +301,7 @@ const createPrompt = ({ The user has explicitly selected the following repositories for analysis: ${repos.map(repo => `- ${repo}`).join('\n')} - When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`, \`grep\`), use these repository names directly. + When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`, \`grep\`), use these repository names exactly as listed above, including the full host prefix (e.g. \`github.com/org/repo\`). ` : ''} diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 91d04a0f0..00b2e48fe 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -1,7 +1,6 @@ 'use client'; import { FindSymbolDefinitionsToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; import { CodeSnippet } from "@/app/components/codeSnippet"; @@ -29,38 +28,30 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( + {part.output.metadata.files.length === 0 ? ( + No matches found + ) : ( - Failed with the following error: {part.output.message} + {part.output.metadata.files.map((file) => { + return ( + + ) + })} - ) : ( - <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx index 1b9d951eb..c5d4985a0 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx @@ -1,7 +1,6 @@ 'use client'; import { FindSymbolReferencesToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; import { CodeSnippet } from "@/app/components/codeSnippet"; @@ -29,38 +28,30 @@ export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolRe
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( + {part.output.metadata.files.length === 0 ? ( + No matches found + ) : ( - Failed with the following error: {part.output.message} + {part.output.metadata.files.map((file) => { + return ( + + ) + })} - ) : ( - <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx index d255eb302..27d94a518 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx @@ -1,7 +1,6 @@ 'use client'; import { GrepToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; import { CodeSnippet } from "@/app/components/codeSnippet"; @@ -35,38 +34,30 @@ export const GrepToolComponent = ({ part }: { part: GrepToolUIPart }) => {
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( + {part.output.metadata.files.length === 0 ? ( + No matches found + ) : ( - Failed with the following error: {part.output.message} + {part.output.metadata.files.map((file) => { + return ( + + ) + })} - ) : ( - <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index 1b03d1848..568840f40 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -1,7 +1,6 @@ 'use client'; import { ListCommitsToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { ToolHeader, TreeList } from "./shared"; import { CodeSnippet } from "@/app/components/codeSnippet"; @@ -27,59 +26,51 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( - - Failed with the following error: {part.output.message} - + {part.output.metadata.commits.length === 0 ? ( + No commits found ) : ( - <> - {part.output.metadata.commits.length === 0 ? ( - No commits found - ) : ( - -
- Found {part.output.metadata.commits.length} of {part.output.metadata.totalCount} total commits: -
- {part.output.metadata.commits.map((commit) => ( -
-
- -
-
- - {commit.hash.substring(0, 7)} - - {commit.refs && ( - - {commit.refs} - - )} -
-
- {commit.message} -
-
- {commit.author_name} - - {new Date(commit.date).toLocaleString()} -
-
+ +
+ Found {part.output.metadata.commits.length} of {part.output.metadata.totalCount} total commits: +
+ {part.output.metadata.commits.map((commit) => ( +
+
+ +
+
+ + {commit.hash.substring(0, 7)} + + {commit.refs && ( + + {commit.refs} + + )} +
+
+ {commit.message} +
+
+ {commit.author_name} + + {new Date(commit.date).toLocaleString()}
- ))} - - )} - +
+
+ ))} +
)} diff --git a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx index d73a37315..d09bb6925 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx @@ -1,10 +1,8 @@ 'use client'; import { ListReposToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { ToolHeader, TreeList } from "./shared"; -import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; import { FolderOpenIcon } from "lucide-react"; @@ -27,38 +25,30 @@ export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart })
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( + {part.output.metadata.repos.length === 0 ? ( + No repositories found + ) : ( - Failed with the following error: {part.output.message} +
+ Found {part.output.metadata.repos.length} of {part.output.metadata.totalCount} repositories: +
+ {part.output.metadata.repos.map((repo, index) => ( +
+ + {repo.name} +
+ ))}
- ) : ( - <> - {part.output.metadata.repos.length === 0 ? ( - No repositories found - ) : ( - -
- Found {part.output.metadata.repos.length} of {part.output.metadata.totalCount} repositories: -
- {part.output.metadata.repos.map((repo, index) => ( -
- - {repo.name} -
- ))} -
- )} - )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx index 0cce63c93..323ad8e93 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx @@ -1,10 +1,8 @@ 'use client'; import { ListTreeToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { useMemo, useState } from "react"; import { ToolHeader, TreeList } from "./shared"; -import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; import { FileIcon, FolderIcon } from "lucide-react"; @@ -27,41 +25,33 @@ export const ListTreeToolComponent = ({ part }: { part: ListTreeToolUIPart }) =>
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( + {part.output.metadata.entries.length === 0 ? ( + No entries found + ) : ( - Failed with the following error: {part.output.message} +
+ {part.output.metadata.repo} - {part.output.metadata.path || '/'} ({part.output.metadata.totalReturned} entries{part.output.metadata.truncated ? ', truncated' : ''}) +
+ {part.output.metadata.entries.map((entry, index) => ( +
+ {entry.type === 'tree' + ? + : + } + {entry.name} +
+ ))}
- ) : ( - <> - {part.output.metadata.entries.length === 0 ? ( - No entries found - ) : ( - -
- {part.output.metadata.repo} - {part.output.metadata.path || '/'} ({part.output.metadata.totalReturned} entries{part.output.metadata.truncated ? ', truncated' : ''}) -
- {part.output.metadata.entries.map((entry, index) => ( -
- {entry.type === 'tree' - ? - : - } - {entry.name} -
- ))} -
- )} - )} diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 3630cfdc0..36042f408 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -1,9 +1,7 @@ 'use client'; -import { CodeSnippet } from "@/app/components/codeSnippet"; import { Separator } from "@/components/ui/separator"; import { ReadFileToolUIPart } from "@/features/chat/tools"; -import { isServiceError } from "@/lib/utils"; import { EyeIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { FileListItem, ToolHeader, TreeList } from "./shared"; @@ -20,9 +18,6 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => case 'output-error': return 'Tool call failed'; case 'output-available': - if (isServiceError(part.output)) { - return 'Failed to read file'; - } if (part.output.metadata.isTruncated || part.output.metadata.startLine > 1) { return `Read ${part.output.metadata.path} (lines ${part.output.metadata.startLine}–${part.output.metadata.endLine})`; } @@ -34,25 +29,21 @@ export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) =>
{part.state === 'output-available' && isExpanded && ( <> - {isServiceError(part.output) ? ( - Failed with the following error: {part.output.message} - ) : ( - - )} + diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx index aeab16dd3..77c559897 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx @@ -135,7 +135,6 @@ export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpa {label} {onCopy && ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
e.stopPropagation()}> ({ - fileName: file.fileName.text, - webUrl: file.webUrl, - repo: file.repository, + type: 'file', + path: file.fileName.text, + name: file.fileName.text.split('/').pop() ?? file.fileName.text, language: file.language, - matches: file.chunks.map(({ content, contentStart }) => { - return addLineNumbers(content, contentStart.lineNumber); - }), + repo: file.repository, revision: ref ?? 'HEAD', })), query, }; + const totalFiles = response.files.length; + const actualMatches = response.stats.actualMatchCount; + + if (totalFiles === 0) { + return { + output: 'No files found', + metadata, + }; + } + + const outputLines: string[] = [ + `Found ${actualMatches} match${actualMatches !== 1 ? 'es' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`, + ]; + + for (const file of response.files) { + outputLines.push(''); + outputLines.push(`[${file.repository}] ${file.fileName.text}:`); + for (const chunk of file.chunks) { + chunk.content.split('\n').forEach((content, i) => { + if (!content.trim()) return; + const lineNum = chunk.contentStart.lineNumber + i; + const line = content.length > MAX_LINE_LENGTH + ? content.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : content; + outputLines.push(` ${lineNum}: ${line}`); + }); + } + } + + if (!response.isSearchExhaustive) { + outputLines.push(''); + outputLines.push(`(Results truncated. Consider using a more specific path or pattern, specifying a repo, or increasing the limit.)`); + } + return { - output: JSON.stringify(metadata), + output: outputLines.join('\n'), metadata, }; }, diff --git a/packages/web/src/features/tools/grep.txt b/packages/web/src/features/tools/grep.txt index bccc261fd..dccdac441 100644 --- a/packages/web/src/features/tools/grep.txt +++ b/packages/web/src/features/tools/grep.txt @@ -2,5 +2,6 @@ - Searches file contents using regular expressions - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.) - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}") -- Returns file paths and line numbers with at least one match sorted by modification time +- Returns file paths and line numbers with at least one match - Use this tool when you need to find files containing specific patterns +- When using the `repo` param, if the repository name is not known, use `list_repos` first to discover the correct name. diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 819d02a9b..9cf064dd4 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -80,6 +80,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap let output = [ `${fileSource.repo}`, `${fileSource.path}`, + `${fileSource.webUrl}`, '\n' ].join('\n'); From dbd69a1dc68bf4c17a7c01ead1532b6261bf6afb Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 16:40:32 -0700 Subject: [PATCH 16/43] small improvement --- CHANGELOG.md | 1 + .../components/chatThread/detailsCard.tsx | 32 +++++++++++++++---- packages/web/src/lib/utils.ts | 6 ++-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1916690c7..d5ba983fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added `find_symbol_definitions`, and `find_symbol_references` tools to the MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added `list_tree` tool to the ask agent. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Added input & output token breakdown in ask details card. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) ### Fixed - Fixed issue where ask responses would sometimes appear in the details panel while generating. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index b2d63f7eb..1061acbeb 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -5,7 +5,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn } from '@/lib/utils'; +import { cn, getShortenedNumberDisplayString } from '@/lib/utils'; import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, List, ScanSearchIcon, Zap } from 'lucide-react'; import { memo, useCallback } from 'react'; import useCaptureEvent from '@/hooks/useCaptureEvent'; @@ -106,15 +106,35 @@ const DetailsCardComponent = ({
)} {metadata?.totalTokens && ( -
- - {metadata?.totalTokens} tokens -
+ + +
+ + {getShortenedNumberDisplayString(metadata.totalTokens, 0)} tokens +
+
+ +
+
+ Input + {metadata.totalInputTokens?.toLocaleString() ?? '—'} +
+
+ Output + {metadata.totalOutputTokens?.toLocaleString() ?? '—'} +
+
+ Total + {metadata.totalTokens.toLocaleString()} +
+
+
+
)} {metadata?.totalResponseTimeMs && (
- {metadata?.totalResponseTimeMs / 1000} seconds + {Math.round(metadata.totalResponseTimeMs / 1000)} seconds
)}
diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index dd7f783e5..d61832326 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -507,13 +507,13 @@ export const getFormattedDate = (date: Date) => { /** * Converts a number to a string */ -export const getShortenedNumberDisplayString = (number: number) => { +export const getShortenedNumberDisplayString = (number: number, fractionDigits: number = 1) => { if (number < 1000) { return number.toString(); } else if (number < 1000000) { - return `${(number / 1000).toFixed(1)}k`; + return `${(number / 1000).toFixed(fractionDigits)}k`; } else { - return `${(number / 1000000).toFixed(1)}m`; + return `${(number / 1000000).toFixed(fractionDigits)}m`; } } From 2e67a0de0dfd20479451a1563f057aeb9bdcd697 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 18:55:56 -0700 Subject: [PATCH 17/43] improve listTree output --- packages/web/src/features/chat/agent.ts | 23 ++++++++++-- packages/web/src/features/chat/types.ts | 1 - packages/web/src/features/chat/utils.ts | 1 - packages/web/src/features/tools/grep.ts | 14 +++++--- packages/web/src/features/tools/listTree.ts | 40 +++++++++++++++++++-- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 97442b678..7207ecd63 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -22,6 +22,7 @@ import { grepDefinition } from "@/features/tools/grep"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import { tools } from "./tools"; +import { listTreeDefinition } from "../tools"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -223,7 +224,6 @@ const createAgentStream = async ({ if (toolName === readFileDefinition.name) { onWriteSource({ type: 'file', - language: output.metadata.language, repo: output.metadata.repo, path: output.metadata.path, revision: output.metadata.revision, @@ -231,19 +231,36 @@ const createAgentStream = async ({ }); } else if (toolName === grepDefinition.name) { output.metadata.files.forEach((file) => { - onWriteSource(file); + onWriteSource({ + type: 'file', + repo: file.repo, + path: file.path, + revision: file.revision, + name: file.path.split('/').pop() ?? file.path, + }); }); } else if (toolName === findSymbolDefinitionsDefinition.name || toolName === findSymbolReferencesDefinition.name) { output.metadata.files.forEach((file) => { onWriteSource({ type: 'file', - language: file.language, repo: file.repo, path: file.fileName, revision: file.revision, name: file.fileName.split('/').pop() ?? file.fileName, }); }); + } else if (toolName === listTreeDefinition.name) { + output.metadata.entries + .filter((entry) => entry.type === 'blob') + .forEach((entry) => { + onWriteSource({ + type: 'file', + repo: output.metadata.repo, + path: entry.path, + revision: output.metadata.ref, + name: entry.name, + }); + }); } }); }, diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 5565a65be..a9923f58b 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -11,7 +11,6 @@ const fileSourceSchema = z.object({ repo: z.string(), path: z.string(), name: z.string(), - language: z.string(), revision: z.string(), }); export type FileSource = z.infer; diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index f5c9f9867..4204b9c39 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -187,7 +187,6 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedS path: mention.path, repo: mention.repo, name: mention.name, - language: mention.language, revision: mention.revision, } return fileSource; diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index 223cb912f..e9288a05b 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -6,7 +6,6 @@ import escapeStringRegexp from "escape-string-regexp"; import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./grep.txt"; -import { FileSource } from "../chat/types"; const DEFAULT_SEARCH_LIMIT = 100; const MAX_LINE_LENGTH = 2000; @@ -44,8 +43,15 @@ const grepShape = { .optional(), }; +export type GrepFile = { + path: string; + name: string; + repo: string; + revision: string; +}; + export type GrepMetadata = { - files: FileSource[]; + files: GrepFile[]; query: string; }; @@ -102,13 +108,11 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada const metadata: GrepMetadata = { files: response.files.map((file) => ({ - type: 'file', path: file.fileName.text, name: file.fileName.text.split('/').pop() ?? file.fileName.text, - language: file.language, repo: file.repository, revision: ref ?? 'HEAD', - })), + } satisfies GrepFile)), query, }; diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index fc8d5c688..7e774f516 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -52,12 +52,14 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap if (!includeFiles && !includeDirectories) { const metadata: ListTreeMetadata = { - repo, ref, path: normalizedPath, + repo, + ref, + path: normalizedPath, entries: [], totalReturned: 0, truncated: false, }; - return { output: JSON.stringify(metadata), metadata }; + return { output: 'No entries found', metadata }; } const queue: Array<{ path: string; depth: number }> = [{ path: normalizedPath, depth: 0 }]; @@ -135,6 +137,38 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap truncated, }; - return { output: JSON.stringify(metadata), metadata }; + const outputLines = [normalizedPath || '/']; + + const childrenByPath = new Map(); + for (const entry of sortedEntries) { + const siblings = childrenByPath.get(entry.parentPath) ?? []; + siblings.push(entry); + childrenByPath.set(entry.parentPath, siblings); + } + + function renderEntries(parentPath: string) { + const children = childrenByPath.get(parentPath) ?? []; + for (const entry of children) { + const indent = ' '.repeat(entry.depth); + const label = entry.type === 'tree' ? `${entry.name}/` : entry.name; + outputLines.push(`${indent}${label}`); + if (entry.type === 'tree') { + renderEntries(entry.path); + } + } + } + + renderEntries(normalizedPath); + + if (sortedEntries.length === 0) { + outputLines.push(' (no entries found)'); + } + + if (truncated) { + outputLines.push(''); + outputLines.push(`(truncated — showing first ${normalizedMaxEntries} entries)`); + } + + return { output: outputLines.join('\n'), metadata }; }, }; From 290d32b5a363ec941e578bcca705d6994477a1fe Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 22:13:02 -0700 Subject: [PATCH 18/43] remove everything before and including answer tag --- packages/web/src/features/chat/utils.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index 4204b9c39..a1c9fd9f4 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -338,7 +338,12 @@ export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStre .findLast((part) => part.type === 'text') if (lastTextPart?.text.includes(ANSWER_TAG)) { - return lastTextPart; + const answerIndex = lastTextPart.text.indexOf(ANSWER_TAG); + const answer = lastTextPart.text.substring(answerIndex + ANSWER_TAG.length); + return { + ...lastTextPart, + text: answer + }; } // If the agent did not include the answer tag, then fallback to using the last text part. From f46b5636a232a068194bec335a52ffcf99b5ae77 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 18 Mar 2026 22:40:47 -0700 Subject: [PATCH 19/43] fix answer part detection --- .../chat/components/chatThread/chatThreadListItem.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index 9be161fde..b9c9a65b1 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -14,6 +14,7 @@ import { DetailsCard } from './detailsCard'; import { MarkdownRenderer, REFERENCE_PAYLOAD_ATTRIBUTE } from './markdownRenderer'; import { ReferencedSourcesListView } from './referencedSourcesListView'; import isEqual from "fast-deep-equal/react"; +import { ANSWER_TAG } from '../../constants'; interface ChatThreadListItemProps { userMessage: SBChatMessage; @@ -94,11 +95,11 @@ const ChatThreadListItemComponent = forwardRef step // First, filter out any parts that are not text .filter((part) => { - if (part.type !== 'text') { - return true; + if (part.type === 'text') { + return !part.text.includes(ANSWER_TAG); } - return part.text !== answerPart?.text; + return true; }) .filter((part) => { // Only include text, reasoning, and tool parts @@ -111,7 +112,7 @@ const ChatThreadListItemComponent = forwardRef step.length > 0); - }, [answerPart, assistantMessage?.parts]); + }, [assistantMessage?.parts]); // "thinking" is when the agent is generating output that is not the answer. const isThinking = useMemo(() => { From 768864bce95f336f494670283a5ec927e577c3e3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 19 Mar 2026 16:38:03 -0700 Subject: [PATCH 20/43] plumb repo selection to grep tool --- docs/docs/features/mcp-server.mdx | 27 ++++++++++----------- packages/web/src/features/chat/agent.ts | 4 +-- packages/web/src/features/chat/tools.ts | 19 ++++++++------- packages/web/src/features/mcp/server.ts | 19 +++++++++------ packages/web/src/features/tools/adapters.ts | 8 +++--- packages/web/src/features/tools/grep.ts | 2 ++ packages/web/src/features/tools/index.ts | 3 ++- packages/web/src/features/tools/types.ts | 1 + 8 files changed, 47 insertions(+), 36 deletions(-) diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx index 248e4f2d3..b1ce9c275 100644 --- a/docs/docs/features/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -304,22 +304,19 @@ Pass the key as an `Authorization: Bearer ` header when connecting to the M ## Available Tools -### `search_code` +### `grep` -Searches for code that matches the provided search query as a substring by default, or as a regular expression if `useRegex` is true. +Searches for code matching a regular expression pattern across repositories, similar to `grep`/`ripgrep`. Always case-sensitive. Results are grouped by file and include line numbers. Parameters: | Name | Required | Description | -|:----------------------|:---------|:---------------------------------------------------------------------------------------------------------------------| -| `query` | yes | The search pattern to match against code contents. Do not escape quotes in your query. | -| `useRegex` | no | Whether to use regular expression matching. When false, substring matching is used (default: false). | -| `filterByRepos` | no | Scope the search to specific repositories. | -| `filterByLanguages` | no | Scope the search to specific languages. | -| `filterByFilepaths` | no | Scope the search to specific filepaths. | -| `caseSensitive` | no | Whether the search should be case sensitive (default: false). | -| `includeCodeSnippets` | no | Whether to include code snippets in the response (default: false). | +|:----------|:---------|:--------------------------------------------------------------------------------------------------------------| +| `pattern` | yes | The regex pattern to search for in file contents. | +| `path` | no | Directory path to scope the search to. Defaults to the repository root. | +| `include` | no | File glob pattern to include in the search (e.g. `*.ts`, `*.{ts,tsx}`). | +| `repo` | no | Repository name to search in. If not provided, searches all repositories. Use the full name including host (e.g. `github.com/org/repo`). | | `ref` | no | Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch. | -| `maxTokens` | no | The maximum number of tokens to return (default: 10000). | +| `limit` | no | Maximum number of matching files to return (default: 100). | ### `list_repos` @@ -336,18 +333,20 @@ Parameters: ### `read_file` -Reads the source code for a given file. +Reads the source code for a given file, with optional line range control for large files. Parameters: | Name | Required | Description | -|:-------|:---------|:-------------------------------------------------------------------------------------------------------| +|:---------|:---------|:-------------------------------------------------------------------------------------------------------| | `repo` | yes | The repository name. | | `path` | yes | The path to the file. | | `ref` | no | Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch. | +| `offset` | no | Line number to start reading from (1-indexed). Omit to start from the beginning. | +| `limit` | no | Maximum number of lines to read (max: 500). Omit to read up to 500 lines. | ### `list_tree` -Lists files and directories from a repository path. Can be used as a directory listing tool (`depth: 1`) or a repo-tree tool (`depth > 1`). +Lists files and directories from a repository path. Directories are shown before files at each level. Parameters: | Name | Required | Description | diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 7207ecd63..4ca2563ba 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -21,7 +21,7 @@ import { readFileDefinition } from "@/features/tools/readFile"; import { grepDefinition } from "@/features/tools/grep"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; -import { tools } from "./tools"; +import { createTools } from "./tools"; import { listTreeDefinition } from "../tools"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -203,7 +203,7 @@ const createAgentStream = async ({ providerOptions, messages: inputMessages, system: systemPrompt, - tools, + tools: createTools({ source: 'sourcebot-ask-agent', selectedRepos }), temperature: env.SOURCEBOT_CHAT_MODEL_TEMPERATURE, stopWhen: [ stepCountIsGTE(env.SOURCEBOT_CHAT_MAX_STEP_COUNT), diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts index dfd031e7d..d3673a7b8 100644 --- a/packages/web/src/features/chat/tools.ts +++ b/packages/web/src/features/chat/tools.ts @@ -8,18 +8,19 @@ import { findSymbolDefinitionsDefinition, listTreeDefinition, } from "@/features/tools"; +import { ToolContext } from "@/features/tools/types"; import { ToolUIPart } from "ai"; import { SBChatMessageToolTypes } from "./types"; -export const tools = { - [readFileDefinition.name]: toVercelAITool(readFileDefinition), - [listCommitsDefinition.name]: toVercelAITool(listCommitsDefinition), - [listReposDefinition.name]: toVercelAITool(listReposDefinition), - [grepDefinition.name]: toVercelAITool(grepDefinition), - [findSymbolReferencesDefinition.name]: toVercelAITool(findSymbolReferencesDefinition), - [findSymbolDefinitionsDefinition.name]: toVercelAITool(findSymbolDefinitionsDefinition), - [listTreeDefinition.name]: toVercelAITool(listTreeDefinition), -} as const; +export const createTools = (context: ToolContext) => ({ + [readFileDefinition.name]: toVercelAITool(readFileDefinition, context), + [listCommitsDefinition.name]: toVercelAITool(listCommitsDefinition, context), + [listReposDefinition.name]: toVercelAITool(listReposDefinition, context), + [grepDefinition.name]: toVercelAITool(grepDefinition, context), + [findSymbolReferencesDefinition.name]: toVercelAITool(findSymbolReferencesDefinition, context), + [findSymbolDefinitionsDefinition.name]: toVercelAITool(findSymbolDefinitionsDefinition, context), + [listTreeDefinition.name]: toVercelAITool(listTreeDefinition, context), +}); export type ReadFileToolUIPart = ToolUIPart<{ read_file: SBChatMessageToolTypes['read_file'] }>; export type ListCommitsToolUIPart = ToolUIPart<{ list_commits: SBChatMessageToolTypes['list_commits'] }>; diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index 017f0fe69..eb7e9607f 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -18,6 +18,7 @@ import { readFileDefinition, registerMcpTool, grepDefinition, + ToolContext, } from '../tools'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -28,13 +29,17 @@ export function createMcpServer(): McpServer { version: SOURCEBOT_VERSION, }); - registerMcpTool(server, grepDefinition); - registerMcpTool(server, listCommitsDefinition); - registerMcpTool(server, listReposDefinition); - registerMcpTool(server, readFileDefinition); - registerMcpTool(server, listTreeDefinition); - registerMcpTool(server, findSymbolDefinitionsDefinition); - registerMcpTool(server, findSymbolReferencesDefinition); + const toolContext: ToolContext = { + source: 'sourcebot-mcp-server', + } + + registerMcpTool(server, grepDefinition, toolContext); + registerMcpTool(server, listCommitsDefinition, toolContext); + registerMcpTool(server, listReposDefinition, toolContext); + registerMcpTool(server, readFileDefinition, toolContext); + registerMcpTool(server, listTreeDefinition, toolContext); + registerMcpTool(server, findSymbolDefinitionsDefinition, toolContext); + registerMcpTool(server, findSymbolReferencesDefinition, toolContext); server.registerTool( "list_language_models", diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index 4dbf0fe07..7883b7b1c 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -1,15 +1,16 @@ import { tool } from "ai"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; -import { ToolDefinition } from "./types"; +import { ToolContext, ToolDefinition } from "./types"; export function toVercelAITool( def: ToolDefinition, + context: ToolContext, ) { return tool({ description: def.description, inputSchema: def.inputSchema, - execute: (input) => def.execute(input, { source: 'sourcebot-ask-agent' }), + execute: (input) => def.execute(input, context), toModelOutput: ({ output }) => ({ type: "content", value: [{ type: "text", text: output.output }], @@ -20,6 +21,7 @@ export function toVercelAITool( server: McpServer, def: ToolDefinition, + context: ToolContext, ) { // Widening .shape to z.ZodRawShape (its base constraint) gives TypeScript a // concrete InputArgs so it can fully resolve BaseToolCallback's conditional @@ -37,7 +39,7 @@ export function registerMcpTool { try { const parsed = def.inputSchema.parse(input); - const result = await def.execute(parsed, { source: 'mcp' }); + const result = await def.execute(parsed, context); return { content: [{ type: "text" as const, text: result.output }] }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index e9288a05b..6867dde4c 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -84,6 +84,8 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada if (repo) { query += ` repo:${escapeStringRegexp(repo)}`; + } else if (context.selectedRepos && context.selectedRepos.length > 0) { + query += ` reposet:${context.selectedRepos.join(',')}`; } if (ref) { diff --git a/packages/web/src/features/tools/index.ts b/packages/web/src/features/tools/index.ts index 49a1a1b2d..38fae0da6 100644 --- a/packages/web/src/features/tools/index.ts +++ b/packages/web/src/features/tools/index.ts @@ -5,4 +5,5 @@ export * from './grep'; export * from './findSymbolReferences'; export * from './findSymbolDefinitions'; export * from './listTree'; -export * from './adapters'; \ No newline at end of file +export * from './adapters'; +export * from './types'; \ No newline at end of file diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 9e580bbd3..c553dafc1 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; export interface ToolContext { source?: string; + selectedRepos?: string[]; } export interface ToolDefinition< From addb243ccb24cc1aa57c3ea8b98652d7fd1175d0 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 19 Mar 2026 17:17:29 -0700 Subject: [PATCH 21/43] grep prompt improvement --- packages/web/src/features/chat/agent.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 4ca2563ba..3645bb42e 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -319,6 +319,8 @@ const createPrompt = ({ ${repos.map(repo => `- ${repo}`).join('\n')} When calling tools that accept a \`repo\` parameter (e.g. \`read_file\`, \`list_commits\`, \`list_tree\`, \`grep\`), use these repository names exactly as listed above, including the full host prefix (e.g. \`github.com/org/repo\`). + + When using \`grep\` to search across ALL selected repositories (e.g. "which repos have X?"), omit the \`repo\` parameter entirely — the tool will automatically search across all selected repositories in a single call. Do NOT call \`grep\` once per repository when a single broad search would suffice. ` : ''} From b3f768da3cc4a5d971fa7685e7a7efab557d919d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 21 Mar 2026 01:24:34 -0700 Subject: [PATCH 22/43] further wip --- CHANGELOG.md | 3 + CLAUDE.md | 19 ++ packages/web/package.json | 1 + .../components/chatThread/detailsCard.tsx | 242 ++++++++++-------- .../findSymbolDefinitionsToolComponent.tsx | 2 +- .../findSymbolReferencesToolComponent.tsx | 2 +- .../chatThread/tools/grepToolComponent.tsx | 154 +++++++---- .../tools/listCommitsToolComponent.tsx | 2 +- .../tools/listReposToolComponent.tsx | 2 +- .../tools/listTreeToolComponent.tsx | 82 +++--- .../tools/readFileToolComponent.tsx | 83 +++--- .../components/chatThread/tools/repoBadge.tsx | 29 +++ .../components/chatThread/tools/shared.tsx | 45 +--- .../chatThread/tools/toolLoadingGuard.tsx | 103 ++++++++ packages/web/src/features/chat/types.ts | 4 +- .../web/src/features/git/getFileSourceApi.ts | 18 +- packages/web/src/features/tools/adapters.ts | 1 + .../features/tools/findSymbolDefinitions.ts | 1 + .../features/tools/findSymbolReferences.ts | 1 + packages/web/src/features/tools/grep.ts | 42 ++- .../web/src/features/tools/listCommits.ts | 1 + packages/web/src/features/tools/listRepos.ts | 1 + packages/web/src/features/tools/listTree.ts | 23 +- packages/web/src/features/tools/readFile.ts | 23 +- packages/web/src/features/tools/types.ts | 1 + yarn.lock | 10 + 26 files changed, 570 insertions(+), 325 deletions(-) create mode 100644 packages/web/src/features/chat/components/chatThread/tools/repoBadge.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ba983fd..bba687eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue where ask responses would sometimes appear in the details panel while generating. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +### Changed +- Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) + ## [4.15.9] - 2026-03-17 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 2fff041c8..3755abd4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,25 @@ Exceptions: - Special files like `README.md`, `CHANGELOG.md`, `LICENSE` - Next.js conventions: `page.tsx`, `layout.tsx`, `loading.tsx`, etc. +## Code Style + +Always use curly braces for `if` statements, with the body on a new line — even for single-line bodies: + +```ts +// Correct +if (!value) { + return; +} +if (condition) { + doSomething(); +} + +// Incorrect +if (!value) return; +if (!value) { return; } +if (condition) doSomething(); +``` + ## Tailwind CSS Use Tailwind color classes directly instead of CSS variable syntax: diff --git a/packages/web/package.json b/packages/web/package.json index 749ea2618..82a7a0ddb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -190,6 +190,7 @@ "stripe": "^17.6.0", "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", + "use-stick-to-bottom": "^1.1.3", "usehooks-ts": "^3.1.0", "vscode-icons-js": "^11.6.1", "zod": "^3.25.74", diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 1061acbeb..9643e6bfc 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -5,21 +5,24 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn, getShortenedNumberDisplayString } from '@/lib/utils'; -import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, List, ScanSearchIcon, Zap } from 'lucide-react'; -import { memo, useCallback } from 'react'; import useCaptureEvent from '@/hooks/useCaptureEvent'; +import { cn, getShortenedNumberDisplayString } from '@/lib/utils'; +import isEqual from "fast-deep-equal/react"; +import { useStickToBottom } from 'use-stick-to-bottom'; +import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, ScanSearchIcon, Zap } from 'lucide-react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { usePrevious } from '@uidotdev/usehooks'; +import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; +import { SearchScopeIcon } from '../searchScopeIcon'; import { MarkdownRenderer } from './markdownRenderer'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; -import { ReadFileToolComponent } from './tools/readFileToolComponent'; import { GrepToolComponent } from './tools/grepToolComponent'; -import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; +import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListTreeToolComponent } from './tools/listTreeToolComponent'; -import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; -import { SearchScopeIcon } from '../searchScopeIcon'; -import isEqual from "fast-deep-equal/react"; +import { ReadFileToolComponent } from './tools/readFileToolComponent'; +import { ToolLoadingGuard } from './tools/toolLoadingGuard'; interface DetailsCardProps { @@ -32,6 +35,43 @@ interface DetailsCardProps { metadata?: SBChatMessageMetadata; } +const ThinkingStepsScroller = ({ thinkingSteps, isStreaming, isThinking }: { thinkingSteps: SBChatMessagePart[][], isStreaming: boolean, isThinking: boolean }) => { + const { scrollRef, contentRef, scrollToBottom } = useStickToBottom(); + const [shouldStick, setShouldStick] = useState(isThinking); + const prevIsThinking = usePrevious(isThinking); + + useEffect(() => { + if (prevIsThinking && !isThinking) { + scrollToBottom(); + setShouldStick(false); + } else if (!prevIsThinking && isThinking) { + setShouldStick(true); + } + }, [isThinking, prevIsThinking, scrollToBottom]); + + return ( +
+
+ {thinkingSteps.length === 0 ? ( + isStreaming ? ( + + ) : ( +

No thinking steps

+ ) + ) : thinkingSteps.map((step, index) => ( +
+ {step.map((part, index) => ( +
+ +
+ ))} +
+ ))} +
+
+ ); +} + const DetailsCardComponent = ({ chatId, isExpanded, @@ -137,10 +177,6 @@ const DetailsCardComponent = ({ {Math.round(metadata.totalResponseTimeMs / 1000)} seconds
)} -
- - {`${thinkingSteps.length} step${thinkingSteps.length === 1 ? '' : 's'}`} -
)}
@@ -154,104 +190,12 @@ const DetailsCardComponent = ({ - - {thinkingSteps.length === 0 ? ( - isStreaming ? ( - - ) : ( -

No thinking steps

- ) - ) : thinkingSteps.map((step, index) => { - return ( -
-
- - {index + 1} - -
- {step.map((part, index) => { - switch (part.type) { - case 'reasoning': - case 'text': - return ( - - ) - case 'tool-read_file': - return ( - - ) - case 'tool-grep': - return ( - - ) - case 'tool-find_symbol_definitions': - return ( - - ) - case 'tool-find_symbol_references': - return ( - - ) - case 'tool-list_repos': - return ( - - ) - case 'tool-list_commits': - return ( - - ) - case 'tool-list_tree': - return ( - - ) - case 'data-source': - case 'dynamic-tool': - case 'file': - case 'source-document': - case 'source-url': - case 'step-start': - return null; - default: - // Guarantees this switch-case to be exhaustive - part satisfies never; - return null; - } - })} -
- ) - })} + +
@@ -259,4 +203,80 @@ const DetailsCardComponent = ({ ) } -export const DetailsCard = memo(DetailsCardComponent, isEqual); \ No newline at end of file +export const DetailsCard = memo(DetailsCardComponent, isEqual); + + +export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { + switch (part.type) { + case 'reasoning': + case 'text': + return ( + + ) + case 'tool-read_file': + return ( + + {(output) => } + + ) + case 'tool-grep': + return ( + + {(output) => } + + ) + case 'tool-find_symbol_definitions': + return ( + + ) + case 'tool-find_symbol_references': + return ( + + ) + case 'tool-list_repos': + return ( + + ) + case 'tool-list_commits': + return ( + + ) + case 'tool-list_tree': + return ( + + {(output) => } + + ) + case 'data-source': + case 'dynamic-tool': + case 'file': + case 'source-document': + case 'source-url': + case 'step-start': + return null; + default: + // Guarantees this switch-case to be exhaustive + part satisfies never; + return null; + } +} \ No newline at end of file diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 00b2e48fe..3e3b99c20 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -25,7 +25,7 @@ export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolD }, [part]); return ( -
+
+
{ - const [isExpanded, setIsExpanded] = useState(false); +export const GrepToolComponent = (output: ToolResult) => { + const stats = useMemo(() => { + const { matchCount, repoCount } = output.metadata; + const matchLabel = `${matchCount} ${matchCount === 1 ? 'match' : 'matches'}`; + const repoLabel = `${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}`; + return `${matchLabel} · ${repoLabel}`; + }, [output]); - const displayQuery = useMemo(() => { - if (part.state !== 'input-available' && part.state !== 'output-available') { - return ''; + const filesByRepo = useMemo(() => { + const groups = new Map(); + for (const file of output.metadata.files) { + if (!groups.has(file.repo)) { + groups.set(file.repo, []); + } + groups.get(file.repo)!.push(file); } + return groups; + }, [output.metadata.files]); - return part.input.pattern; - }, [part]); + const singleRepo = output.metadata.repoCount === 1 + ? output.metadata.repoInfoMap[output.metadata.files[0]?.repo] + : undefined; - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Searching...'; - case 'output-error': - return '"Search code" tool call failed'; - case 'input-available': - case 'output-available': - return Searched for {displayQuery}; - } - }, [part, displayQuery]); + return ( + +
+
+ + + Searched + {output.metadata.pattern} + {singleRepo && <>in} + + +
+ {stats} + +
+ {output.metadata.files.length > 0 && ( + +
+ {Array.from(filesByRepo.entries()).map(([repo, files]) => ( +
+ + {files.map((file) => ( + + ))} +
+ ))} +
+
+ )} +
+ ); +} + +const RepoHeader = ({ repo, repoName }: { repo: GrepRepoInfo | undefined; repoName: string }) => { + const displayName = repo?.displayName ?? repoName.split('/').slice(1).join('/'); + const icon = repo ? getCodeHostIcon(repo.codeHostType) : null; return ( -
- - {part.state === 'output-available' && isExpanded && ( +
+ {icon && ( + {repo!.codeHostType} + )} + {displayName} +
+ ); +} + +const FileRow = ({ file }: { file: GrepFile }) => { + const dir = file.path.includes('/') + ? file.path.split('/').slice(0, -1).join('/') + : ''; + + const href = getBrowsePath({ + repoName: file.repo, + revisionName: file.revision, + path: file.path, + pathType: 'blob', + domain: SINGLE_TENANT_ORG_DOMAIN, + }); + + return ( + + + {file.name} + {dir && ( <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - + · + {dir} )} -
- ) -} \ No newline at end of file + + ); +} diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index 568840f40..2b4d64df7 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -23,7 +23,7 @@ export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart }, [part]); return ( -
+
+
{ - const [isExpanded, setIsExpanded] = useState(false); - - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Listing directory tree...'; - case 'output-error': - return '"List tree" tool call failed'; - case 'input-available': - case 'output-available': - return 'Listed directory tree'; - } - }, [part]); +import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { FolderIcon } from "lucide-react"; +import Link from "next/link"; +export const ListTreeToolComponent = ({ metadata }: ToolResult) => { return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - {part.output.metadata.entries.length === 0 ? ( - No entries found - ) : ( - -
- {part.output.metadata.repo} - {part.output.metadata.path || '/'} ({part.output.metadata.totalReturned} entries{part.output.metadata.truncated ? ', truncated' : ''}) -
- {part.output.metadata.entries.map((entry, index) => ( -
- {entry.type === 'tree' - ? - : - } - {entry.name} -
- ))} -
- )} - - - )} +
+ Listed + e.stopPropagation()} + className="inline-flex items-center gap-1 text-xs bg-muted hover:bg-accent px-1.5 py-0.5 rounded truncate text-foreground font-medium transition-colors" + > + + {metadata.path || '/'} + + in + + + {metadata.totalReturned} {metadata.totalReturned === 1 ? 'entry' : 'entries'}{metadata.truncated ? ' (truncated)' : ''} + +
); }; diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 36042f408..84b9558fe 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -1,53 +1,48 @@ 'use client'; +import { ReadFileMetadata, ToolResult } from "@/features/tools"; +import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; +import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; import { Separator } from "@/components/ui/separator"; -import { ReadFileToolUIPart } from "@/features/chat/tools"; -import { EyeIcon } from "lucide-react"; -import { useMemo, useState } from "react"; -import { FileListItem, ToolHeader, TreeList } from "./shared"; +import Link from "next/link"; +import { RepoBadge } from "./repoBadge"; -export const ReadFileToolComponent = ({ part }: { part: ReadFileToolUIPart }) => { - const [isExpanded, setIsExpanded] = useState(false); +export const ReadFileToolComponent = ({ metadata }: ToolResult) => { + const fileName = metadata.path.split('/').pop() ?? metadata.path; + const href = getBrowsePath({ + repoName: metadata.repo, + revisionName: metadata.revision, + path: metadata.path, + pathType: 'blob', + domain: SINGLE_TENANT_ORG_DOMAIN, + highlightRange: (metadata.isTruncated || metadata.startLine > 1) ? { + start: { lineNumber: metadata.startLine }, + end: { lineNumber: metadata.endLine }, + } : undefined, + }); - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Reading...'; - case 'input-available': - return `Reading ${part.input.path}...`; - case 'output-error': - return 'Tool call failed'; - case 'output-available': - if (part.output.metadata.isTruncated || part.output.metadata.startLine > 1) { - return `Read ${part.output.metadata.path} (lines ${part.output.metadata.startLine}–${part.output.metadata.endLine})`; - } - return `Read ${part.output.metadata.path}`; - } - }, [part]); + const linesRead = metadata.endLine - metadata.startLine + 1; return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - - - - - - )} +
+ Read + e.stopPropagation()} + > + + {fileName} + {(metadata.isTruncated || metadata.startLine > 1) && ( + L{metadata.startLine}-{metadata.endLine} + )} + + in + + + {linesRead} {linesRead === 1 ? 'line' : 'lines'} +
- ) + ); } diff --git a/packages/web/src/features/chat/components/chatThread/tools/repoBadge.tsx b/packages/web/src/features/chat/components/chatThread/tools/repoBadge.tsx new file mode 100644 index 000000000..68b69bfef --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/repoBadge.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { getCodeHostIcon } from "@/lib/utils"; +import { CodeHostType } from "@sourcebot/db"; +import Image from "next/image"; +import Link from "next/link"; + +export const RepoBadge = ({ repo }: { repo: { name: string; displayName: string; codeHostType: CodeHostType } }) => { + const icon = getCodeHostIcon(repo.codeHostType); + const href = getBrowsePath({ + repoName: repo.name, + path: '', + pathType: 'tree', + domain: SINGLE_TENANT_ORG_DOMAIN, + }); + + return ( + e.stopPropagation()} + className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted hover:bg-accent text-xs font-medium transition-colors text-foreground max-w-[300px] overflow-hidden" + > + {repo.codeHostType} + {repo.displayName.split('/').pop()} + + ); +} diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx index 77c559897..cd064c42b 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx @@ -86,38 +86,17 @@ interface ToolHeaderProps { input?: string; output?: string; className?: string; + rightContent?: React.ReactNode; } -export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, input, output, className }: ToolHeaderProps) => { - const onCopy = output !== undefined - ? () => { - const text = [ - input !== undefined ? `Input:\n${input}` : null, - `Output:\n${output}`, - ].filter(Boolean).join('\n\n'); - navigator.clipboard.writeText(text); - return true; - } - : undefined; +export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, input, output, className, rightContent }: ToolHeaderProps) => { return (
{ - onExpand(!isExpanded) - }} - onKeyDown={(e) => { - if (e.key !== "Enter") { - return; - } - onExpand(!isExpanded); - }} > {isLoading ? ( @@ -125,7 +104,7 @@ export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpa )} {label} - {onCopy && ( -
e.stopPropagation()}> - -
- )} - {!isLoading && ( -
- {isExpanded ? ( - - ) : ( - - )} -
+ {rightContent && ( + {rightContent} )}
) diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx new file mode 100644 index 000000000..cc53b9cf4 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { SBChatMessageToolTypes } from "@/features/chat/types"; +import { CopyIconButton } from "@/app/[domain]/components/copyIconButton"; +import { ToolUIPart } from "ai"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useCallback, useState } from "react"; + +export const ToolLoadingGuard = >({ + part, + loadingText, + children, +}: { + part: T, + loadingText: string, + children: (output: Extract['output']) => React.ReactNode, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + const onToggle = useCallback(() => setIsExpanded(v => !v), []); + + const hasInput = part.state !== 'input-streaming'; + + const requestText = hasInput ? JSON.stringify(part.input, null, 2) : ''; + const responseText = part.state === 'output-available' + ? (part.output as { output: string }).output + : part.state === 'output-error' + ? (part.errorText ?? '') + : undefined; + + const onCopyRequest = useCallback(() => { + navigator.clipboard.writeText(requestText); + return true; + }, [requestText]); + + const onCopyResponse = useCallback(() => { + if (!responseText) { + return false; + } + navigator.clipboard.writeText(responseText); + return true; + }, [responseText]); + + return ( +
+
+
+ {part.state === 'output-error' ? ( + + {part.title!} failed with error: {part.errorText} + + ) : part.state !== 'output-available' ? ( + + {loadingText} + + ) : ( + children(part.output as Extract['output']) + )} +
+ {hasInput && } +
+ {hasInput && isExpanded && ( +
+ +
+                            {requestText}
+                        
+
+ {responseText !== undefined && ( + <> +
+ +
+                                    {responseText}
+                                
+
+ + )} +
+ )} +
+ ); +} + +const ExpandButton = ({ isExpanded, onToggle }: { isExpanded: boolean; onToggle: () => void }) => ( + +); + +const ResultSection = ({ label, onCopy, children }: { label: string; onCopy: () => boolean; children: React.ReactNode }) => ( +
+
+ {label} + +
+
+ {children} +
+
+); diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index a9923f58b..11fa7f360 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -4,7 +4,7 @@ import { HistoryEditor } from "slate-history"; import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; -import { tools } from "./tools"; +import { createTools } from "./tools"; const fileSourceSchema = z.object({ type: z.literal('file'), @@ -77,7 +77,7 @@ export const sbChatMessageMetadataSchema = z.object({ export type SBChatMessageMetadata = z.infer; export type SBChatMessageToolTypes = { - [K in keyof typeof tools]: InferUITool; + [K in keyof ReturnType]: InferUITool[K]>; }; export type SBChatMessageDataParts = { diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts index 03ecbaef2..401461981 100644 --- a/packages/web/src/features/git/getFileSourceApi.ts +++ b/packages/web/src/features/git/getFileSourceApi.ts @@ -6,7 +6,7 @@ import { detectLanguageFromFilename } from '@/lib/languageDetection'; import { ServiceError, notFound, fileNotFound, invalidGitRef, unexpectedError } from '@/lib/serviceError'; import { getCodeHostBrowseFileAtBranchUrl } from '@/lib/utils'; import { withOptionalAuthV2 } from '@/withAuthV2'; -import { getRepoPath } from '@sourcebot/shared'; +import { env, getRepoPath } from '@sourcebot/shared'; import { headers } from 'next/headers'; import simpleGit from 'simple-git'; import type z from 'zod'; @@ -66,13 +66,6 @@ export const getFileSource = async ({ path: filePath, repo: repoName, ref }: Fil } const language = detectLanguageFromFilename(filePath); - const webUrl = getBrowsePath({ - repoName: repo.name, - revisionName: ref, - path: filePath, - pathType: 'blob', - domain: SINGLE_TENANT_ORG_DOMAIN, - }); const externalWebUrl = getCodeHostBrowseFileAtBranchUrl({ webUrl: repo.webUrl, codeHostType: repo.external_codeHostType, @@ -80,6 +73,15 @@ export const getFileSource = async ({ path: filePath, repo: repoName, ref }: Fil filePath, }); + const baseUrl = env.AUTH_URL; + const webUrl = `${baseUrl}${getBrowsePath({ + repoName: repo.name, + revisionName: ref, + path: filePath, + pathType: 'blob', + domain: SINGLE_TENANT_ORG_DOMAIN, + })}`; + return { source: fileContent, language, diff --git a/packages/web/src/features/tools/adapters.ts b/packages/web/src/features/tools/adapters.ts index 7883b7b1c..5b2ac3809 100644 --- a/packages/web/src/features/tools/adapters.ts +++ b/packages/web/src/features/tools/adapters.ts @@ -10,6 +10,7 @@ export function toVercelAITool def.execute(input, context), toModelOutput: ({ output }) => ({ type: "content", diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 1c97467fe..e4c304277 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -23,6 +23,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< FindSymbolDefinitionsMetadata > = { name: 'find_symbol_definitions', + title: 'Find symbol definitions', isReadOnly: true, isIdempotent: true, description, diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index a1a2f0bec..16953b7ab 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -30,6 +30,7 @@ export const findSymbolReferencesDefinition: ToolDefinition< FindSymbolReferencesMetadata > = { name: 'find_symbol_references', + title: 'Find symbol references', isReadOnly: true, isIdempotent: true, description, diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index 6867dde4c..ec2e8198f 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -6,6 +6,7 @@ import escapeStringRegexp from "escape-string-regexp"; import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./grep.txt"; +import { CodeHostType } from "@sourcebot/db"; const DEFAULT_SEARCH_LIMIT = 100; const MAX_LINE_LENGTH = 2000; @@ -50,13 +51,24 @@ export type GrepFile = { revision: string; }; +export type GrepRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; +}; + export type GrepMetadata = { files: GrepFile[]; + pattern: string; query: string; + matchCount: number; + repoCount: number; + repoInfoMap: Record; }; export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetadata> = { name: 'grep', + title: 'Search code', isReadOnly: true, isIdempotent: true, description, @@ -108,14 +120,28 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada throw new Error(response.message); } + const files = response.files.map((file) => ({ + path: file.fileName.text, + name: file.fileName.text.split('/').pop() ?? file.fileName.text, + repo: file.repository, + revision: ref ?? 'HEAD', + } satisfies GrepFile)); + + const repoInfoMap = Object.fromEntries( + response.repositoryInfo.map((info) => [info.name, { + name: info.name, + displayName: info.displayName ?? info.name, + codeHostType: info.codeHostType, + }]) + ); + const metadata: GrepMetadata = { - files: response.files.map((file) => ({ - path: file.fileName.text, - name: file.fileName.text.split('/').pop() ?? file.fileName.text, - repo: file.repository, - revision: ref ?? 'HEAD', - } satisfies GrepFile)), + files, + pattern, query, + matchCount: response.stats.actualMatchCount, + repoCount: new Set(files.map((f) => f.repo)).size, + repoInfoMap, }; const totalFiles = response.files.length; @@ -137,7 +163,9 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada outputLines.push(`[${file.repository}] ${file.fileName.text}:`); for (const chunk of file.chunks) { chunk.content.split('\n').forEach((content, i) => { - if (!content.trim()) return; + if (!content.trim()) { + return; + } const lineNum = chunk.contentStart.lineNumber + i; const line = content.length > MAX_LINE_LENGTH ? content.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index c40ff1137..71edf81bf 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -20,6 +20,7 @@ export type ListCommitsMetadata = SearchCommitsResult; export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCommitsShape, ListCommitsMetadata> = { name: "list_commits", + title: "List commits", isReadOnly: true, isIdempotent: true, description, diff --git a/packages/web/src/features/tools/listRepos.ts b/packages/web/src/features/tools/listRepos.ts index 70b731096..8958fcec2 100644 --- a/packages/web/src/features/tools/listRepos.ts +++ b/packages/web/src/features/tools/listRepos.ts @@ -33,6 +33,7 @@ export const listReposDefinition: ToolDefinition< ListReposMetadata > = { name: 'list_repos', + title: 'List repositories', isReadOnly: true, isIdempotent: true, description, diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index 7e774f516..08ae8f4b1 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -5,6 +5,8 @@ import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./listTree.txt"; +import { CodeHostType } from "@sourcebot/db"; +import { getRepoInfoByName } from "@/actions"; const DEFAULT_TREE_DEPTH = 1; const MAX_TREE_DEPTH = 10; @@ -29,8 +31,15 @@ export type ListTreeEntry = { depth: number; }; +export type ListTreeRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; +}; + export type ListTreeMetadata = { repo: string; + repoInfo: ListTreeRepoInfo; ref: string; path: string; entries: ListTreeEntry[]; @@ -40,6 +49,7 @@ export type ListTreeMetadata = { export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShape, ListTreeMetadata> = { name: 'list_tree', + title: 'List directory tree', isReadOnly: true, isIdempotent: true, description, @@ -50,9 +60,20 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH); const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES); + const repoInfoResult = await getRepoInfoByName(repo); + if (isServiceError(repoInfoResult) || !repoInfoResult) { + throw new Error(`Repository "${repo}" not found.`); + } + const repoInfo: ListTreeRepoInfo = { + name: repoInfoResult.name, + displayName: repoInfoResult.displayName ?? repoInfoResult.name, + codeHostType: repoInfoResult.codeHostType, + }; + if (!includeFiles && !includeDirectories) { const metadata: ListTreeMetadata = { repo, + repoInfo, ref, path: normalizedPath, entries: [], @@ -131,7 +152,7 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap const sortedEntries = sortTreeEntries(entries); const metadata: ListTreeMetadata = { - repo, ref, path: normalizedPath, + repo, repoInfo, ref, path: normalizedPath, entries: sortedEntries, totalReturned: sortedEntries.length, truncated, diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 9cf064dd4..0119d59aa 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -4,6 +4,8 @@ import { getFileSource } from "@/features/git"; import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./readFile.txt"; +import { CodeHostType } from "@sourcebot/db"; +import { getRepoInfoByName } from "@/actions"; // NOTE: if you change these values, update readFile.txt to match. const READ_FILES_MAX_LINES = 500; @@ -23,9 +25,16 @@ const readFileShape = { .describe(`Maximum number of lines to read (max: ${READ_FILES_MAX_LINES}). Omit to read up to ${READ_FILES_MAX_LINES} lines.`), }; +export type ReadFileRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; +}; + export type ReadFileMetadata = { path: string; repo: string; + repoInfo: ReadFileRepoInfo; language: string; startLine: number; endLine: number; @@ -35,6 +44,7 @@ export type ReadFileMetadata = { export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShape, ReadFileMetadata> = { name: "read_file", + title: "Read file", isReadOnly: true, isIdempotent: true, description, @@ -80,7 +90,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap let output = [ `${fileSource.repo}`, `${fileSource.path}`, - `${fileSource.webUrl}`, + `${fileSource.externalWebUrl}`, '\n' ].join('\n'); @@ -96,9 +106,20 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap output += `\n`; + const repoInfoResult = await getRepoInfoByName(fileSource.repo); + if (isServiceError(repoInfoResult) || !repoInfoResult) { + throw new Error(`Repository "${fileSource.repo}" not found.`); + } + const repoInfo: ReadFileRepoInfo = { + name: repoInfoResult.name, + displayName: repoInfoResult.displayName ?? repoInfoResult.name, + codeHostType: repoInfoResult.codeHostType, + }; + const metadata: ReadFileMetadata = { path: fileSource.path, repo: fileSource.repo, + repoInfo, language: fileSource.language, startLine, endLine: lastReadLine, diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index c553dafc1..437f17b01 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -11,6 +11,7 @@ export interface ToolDefinition< TMetadata = Record, > { name: TName; + title: string; description: string; inputSchema: z.ZodObject; isReadOnly: boolean; diff --git a/yarn.lock b/yarn.lock index ce65eec17..28671cdae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9055,6 +9055,7 @@ __metadata: tsx: "npm:^4.19.2" typescript: "npm:^5" typescript-eslint: "npm:^8.56.1" + use-stick-to-bottom: "npm:^1.1.3" usehooks-ts: "npm:^3.1.0" vite-tsconfig-paths: "npm:^5.1.3" vitest: "npm:^2.1.5" @@ -22195,6 +22196,15 @@ __metadata: languageName: node linkType: hard +"use-stick-to-bottom@npm:^1.1.3": + version: 1.1.3 + resolution: "use-stick-to-bottom@npm:1.1.3" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/60408d47b4ddac959a8f170fe9806cdea94fd8a51d3b58cbcce6246ef9babde56d1a5f1a14cb12f474d351430145464992aaac008178924c60191b6c61954bf7 + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.4.0": version: 1.5.0 resolution: "use-sync-external-store@npm:1.5.0" From b4500eae4611f4364a30de8010f0ee44b11cfd1c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 21 Mar 2026 01:38:58 -0700 Subject: [PATCH 23/43] nit --- .../components/chatThread/detailsCard.tsx | 77 ++++++++++--------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 9643e6bfc..653fd61f2 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -35,43 +35,6 @@ interface DetailsCardProps { metadata?: SBChatMessageMetadata; } -const ThinkingStepsScroller = ({ thinkingSteps, isStreaming, isThinking }: { thinkingSteps: SBChatMessagePart[][], isStreaming: boolean, isThinking: boolean }) => { - const { scrollRef, contentRef, scrollToBottom } = useStickToBottom(); - const [shouldStick, setShouldStick] = useState(isThinking); - const prevIsThinking = usePrevious(isThinking); - - useEffect(() => { - if (prevIsThinking && !isThinking) { - scrollToBottom(); - setShouldStick(false); - } else if (!prevIsThinking && isThinking) { - setShouldStick(true); - } - }, [isThinking, prevIsThinking, scrollToBottom]); - - return ( -
-
- {thinkingSteps.length === 0 ? ( - isStreaming ? ( - - ) : ( -

No thinking steps

- ) - ) : thinkingSteps.map((step, index) => ( -
- {step.map((part, index) => ( -
- -
- ))} -
- ))} -
-
- ); -} - const DetailsCardComponent = ({ chatId, isExpanded, @@ -191,7 +154,7 @@ const DetailsCardComponent = ({ - { + const { scrollRef, contentRef, scrollToBottom } = useStickToBottom(); + const [shouldStick, setShouldStick] = useState(isThinking); + const prevIsThinking = usePrevious(isThinking); + + useEffect(() => { + if (prevIsThinking && !isThinking) { + scrollToBottom(); + setShouldStick(false); + } else if (!prevIsThinking && isThinking) { + setShouldStick(true); + } + }, [isThinking, prevIsThinking, scrollToBottom]); + + return ( +
+
+ {thinkingSteps.length === 0 ? ( + isStreaming ? ( + + ) : ( +

No thinking steps

+ ) + ) : thinkingSteps.map((step, index) => ( +
+ {step.map((part, index) => ( +
+ +
+ ))} +
+ ))} +
+
+ ); +} + + export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { switch (part.type) { case 'reasoning': From ea801f138ae81f81b3e997c90238e59a8cfa43eb Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 21 Mar 2026 15:36:54 -0700 Subject: [PATCH 24/43] add path to /api/commits api --- CHANGELOG.md | 1 + .../web/src/app/api/(server)/commits/route.ts | 2 ++ .../web/src/features/git/listCommitsApi.ts | 26 +++++++++++++---- .../web/src/features/tools/listCommits.ts | 29 +++++++++++++++++-- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba687eb5..b1f39ac11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `find_symbol_definitions`, and `find_symbol_references` tools to the MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added `list_tree` tool to the ask agent. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added input & output token breakdown in ask details card. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Added `path` parameter to the `/api/commits` api to allow filtering commits by paths. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) ### Fixed - Fixed issue where ask responses would sometimes appear in the details panel while generating. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) diff --git a/packages/web/src/app/api/(server)/commits/route.ts b/packages/web/src/app/api/(server)/commits/route.ts index 18b4afb93..fb3f9cbe1 100644 --- a/packages/web/src/app/api/(server)/commits/route.ts +++ b/packages/web/src/app/api/(server)/commits/route.ts @@ -13,6 +13,7 @@ const listCommitsQueryParamsSchema = z.object({ until: z.string().optional(), author: z.string().optional(), ref: z.string().optional(), + path: z.string().optional(), page: z.coerce.number().int().positive().default(1), perPage: z.coerce.number().int().positive().max(100).default(50), }); @@ -57,6 +58,7 @@ export const GET = apiHandler(async (request: NextRequest): Promise => ...(searchParams.until ? { until: searchParams.until } : {}), ...(searchParams.author ? { author: searchParams.author } : {}), ...(searchParams.ref ? { ref: searchParams.ref } : {}), + ...(searchParams.path ? { path: searchParams.path } : {}), }, }); if (linkHeader) headers.set('Link', linkHeader); diff --git a/packages/web/src/features/git/listCommitsApi.ts b/packages/web/src/features/git/listCommitsApi.ts index 27baf096e..405dcaf9a 100644 --- a/packages/web/src/features/git/listCommitsApi.ts +++ b/packages/web/src/features/git/listCommitsApi.ts @@ -28,6 +28,7 @@ type ListCommitsRequest = { until?: string; author?: string; ref?: string; + path?: string; maxCount?: number; skip?: number; } @@ -46,6 +47,7 @@ export const listCommits = async ({ until, author, ref = 'HEAD', + path, maxCount = 50, skip = 0, }: ListCommitsRequest): Promise => sew(() => @@ -93,19 +95,31 @@ export const listCommits = async ({ } : {}), }; + // Build args array directly to ensure correct ordering: + // git log [flags] [-- ] + const logArgs: string[] = [`--max-count=${maxCount}`]; + if (skip > 0) { + logArgs.push(`--skip=${skip}`); + } + for (const [key, value] of Object.entries(sharedOptions)) { + logArgs.push(value !== null ? `${key}=${value}` : key); + } + logArgs.push(ref); + if (path) { + logArgs.push('--', path); + } + // First, get the commits - const log = await git.log({ - [ref]: null, - maxCount, - ...(skip > 0 ? { '--skip': skip } : {}), - ...sharedOptions, - }); + const log = await git.log(logArgs); // Then, use rev-list to get the total count of commits const countArgs = ['rev-list', '--count', ref]; for (const [key, value] of Object.entries(sharedOptions)) { countArgs.push(value !== null ? `${key}=${value}` : key); } + if (path) { + countArgs.push('--', path); + } const totalCount = parseInt((await git.raw(countArgs)).trim(), 10); diff --git a/packages/web/src/features/tools/listCommits.ts b/packages/web/src/features/tools/listCommits.ts index 71edf81bf..34d61eeeb 100644 --- a/packages/web/src/features/tools/listCommits.ts +++ b/packages/web/src/features/tools/listCommits.ts @@ -4,6 +4,8 @@ import { listCommits, SearchCommitsResult } from "@/features/git"; import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./listCommits.txt"; +import { CodeHostType } from "@sourcebot/db"; +import { getRepoInfoByName } from "@/actions"; const listCommitsShape = { repo: z.string().describe("The repository to list commits from"), @@ -12,11 +14,21 @@ const listCommitsShape = { until: z.string().describe("End date for commit range (e.g., 'yesterday', '2024-12-31', 'today')").optional(), author: z.string().describe("Filter commits by author name or email (case-insensitive)").optional(), ref: z.string().describe("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch.").optional(), + path: z.string().describe("Filter commits to only those that touched this file or directory path (relative to repo root).").optional(), page: z.number().int().positive().describe("Page number for pagination (min 1). Default: 1").optional().default(1), perPage: z.number().int().positive().max(100).describe("Results per page for pagination (min 1, max 100). Default: 50").optional().default(50), }; -export type ListCommitsMetadata = SearchCommitsResult; +export type ListCommitsRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; +}; + +export type ListCommitsMetadata = SearchCommitsResult & { + repo: string; + repoInfo: ListCommitsRepoInfo; +}; export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCommitsShape, ListCommitsMetadata> = { name: "list_commits", @@ -28,7 +40,7 @@ export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCo execute: async (params, _context) => { logger.debug('list_commits', params); - const { repo, query, since, until, author, ref, page, perPage } = params; + const { repo, query, since, until, author, ref, path, page, perPage } = params; const skip = (page - 1) * perPage; const response = await listCommits({ @@ -38,6 +50,7 @@ export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCo until, author, ref, + path, maxCount: perPage, skip, }); @@ -46,9 +59,19 @@ export const listCommitsDefinition: ToolDefinition<"list_commits", typeof listCo throw new Error(response.message); } + const repoInfoResult = await getRepoInfoByName(repo); + if (isServiceError(repoInfoResult) || !repoInfoResult) { + throw new Error(`Repository "${repo}" not found.`); + } + const repoInfo: ListCommitsRepoInfo = { + name: repoInfoResult.name, + displayName: repoInfoResult.displayName ?? repoInfoResult.name, + codeHostType: repoInfoResult.codeHostType, + }; + return { output: JSON.stringify(response), - metadata: response, + metadata: { ...response, repo, repoInfo }, }; }, }; From 7044321e838fd50688792df2ba332582c5515bc0 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 21 Mar 2026 15:37:59 -0700 Subject: [PATCH 25/43] list commits updated ui --- .../components/chatThread/detailsCard.tsx | 7 +- .../tools/listCommitsToolComponent.tsx | 86 +++---------------- .../chatThread/tools/toolLoadingGuard.tsx | 9 +- 3 files changed, 26 insertions(+), 76 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 653fd61f2..c6299b8aa 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -255,9 +255,12 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { ) case 'tool-list_commits': return ( - + loadingText="Listing commits..." + > + {(output) => } + ) case 'tool-list_tree': return ( diff --git a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx index 2b4d64df7..3e0d2651d 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listCommitsToolComponent.tsx @@ -1,80 +1,20 @@ 'use client'; -import { ListCommitsToolUIPart } from "@/features/chat/tools"; -import { useMemo, useState } from "react"; -import { ToolHeader, TreeList } from "./shared"; -import { CodeSnippet } from "@/app/components/codeSnippet"; +import { ListCommitsMetadata, ToolResult } from "@/features/tools"; +import { RepoBadge } from "./repoBadge"; import { Separator } from "@/components/ui/separator"; -import { GitCommitVerticalIcon } from "lucide-react"; -export const ListCommitsToolComponent = ({ part }: { part: ListCommitsToolUIPart }) => { - const [isExpanded, setIsExpanded] = useState(false); - - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Listing commits...'; - case 'output-error': - return '"List commits" tool call failed'; - case 'input-available': - case 'output-available': - return 'Listed commits'; - } - }, [part]); +export const ListCommitsToolComponent = ({ metadata }: ToolResult) => { + const count = metadata.commits.length; + const label = `${count} ${count === 1 ? 'commit' : 'commits'}`; return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - {part.output.metadata.commits.length === 0 ? ( - No commits found - ) : ( - -
- Found {part.output.metadata.commits.length} of {part.output.metadata.totalCount} total commits: -
- {part.output.metadata.commits.map((commit) => ( -
-
- -
-
- - {commit.hash.substring(0, 7)} - - {commit.refs && ( - - {commit.refs} - - )} -
-
- {commit.message} -
-
- {commit.author_name} - - {new Date(commit.date).toLocaleString()} -
-
-
-
- ))} -
- )} - - - )} +
+ Listed commits in + + + {label} +
- ) -} + ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx index cc53b9cf4..91aa1ff3e 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx @@ -24,7 +24,14 @@ export const ToolLoadingGuard = { + const raw = (part.output as { output: string }).output; + try { + return JSON.stringify(JSON.parse(raw), null, 2); + } catch { + return raw; + } + })() : part.state === 'output-error' ? (part.errorText ?? '') : undefined; From 9a3ea1c34f6fccb41246e42f41599e6b029991cb Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 21 Mar 2026 16:43:06 -0700 Subject: [PATCH 26/43] list repos tool --- .../components/chatThread/detailsCard.tsx | 7 ++- .../tools/listReposToolComponent.tsx | 62 ++++--------------- 2 files changed, 16 insertions(+), 53 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index c6299b8aa..1d39bb35b 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -249,9 +249,12 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { ) case 'tool-list_repos': return ( - + loadingText="Listing repositories..." + > + {(output) => } + ) case 'tool-list_commits': return ( diff --git a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx index ac3c0f772..ba7c8d008 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listReposToolComponent.tsx @@ -1,58 +1,18 @@ 'use client'; -import { ListReposToolUIPart } from "@/features/chat/tools"; -import { useMemo, useState } from "react"; -import { ToolHeader, TreeList } from "./shared"; +import { ListReposMetadata, ToolResult } from "@/features/tools"; import { Separator } from "@/components/ui/separator"; -import { FolderOpenIcon } from "lucide-react"; -export const ListReposToolComponent = ({ part }: { part: ListReposToolUIPart }) => { - const [isExpanded, setIsExpanded] = useState(false); - - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Listing repositories...'; - case 'output-error': - return '"List repositories" tool call failed'; - case 'input-available': - case 'output-available': - return 'Listed repositories'; - } - }, [part]); +export const ListReposToolComponent = ({ metadata }: ToolResult) => { + const count = metadata.repos.length; + const label = `${count}${metadata.totalCount > count ? ` of ${metadata.totalCount}` : ''} ${count === 1 ? 'repo' : 'repos'}`; return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - {part.output.metadata.repos.length === 0 ? ( - No repositories found - ) : ( - -
- Found {part.output.metadata.repos.length} of {part.output.metadata.totalCount} repositories: -
- {part.output.metadata.repos.map((repo, index) => ( -
- - {repo.name} -
- ))} -
- )} - - - )} +
+ Listed repositories + + {label} +
- ) -} \ No newline at end of file + ); +}; From 717b6def174af5720efe29949ac2b34e783eabff Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 11:07:07 -0700 Subject: [PATCH 27/43] fix merge conflicts --- packages/web/src/features/mcp/server.ts | 50 +++++++++++++------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index 5bb311244..390334ca2 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -23,12 +23,15 @@ import { const dedent = _dedent.withOptions({ alignValues: true }); -export function createMcpServer(): McpServer { +export async function createMcpServer(): Promise { const server = new McpServer({ name: 'sourcebot-mcp-server', version: SOURCEBOT_VERSION, }); + const configuredLanguageModels = await getConfiguredLanguageModelsInfo(); + const hasLanguageModels = configuredLanguageModels.length > 0; + const toolContext: ToolContext = { source: 'sourcebot-mcp-server', } @@ -56,10 +59,11 @@ export function createMcpServer(): McpServer { } ); - server.registerTool( - "ask_codebase", - { - description: dedent` + if (hasLanguageModels) { + server.registerTool( + "ask_codebase", + { + description: dedent` DO NOT USE THIS TOOL UNLESS EXPLICITLY ASKED TO. THE PROMPT MUST SPECIFICALLY ASK TO USE THE ask_codebase TOOL. Ask a natural language question about the codebase. This tool uses an AI agent to autonomously search code, read files, and find symbol references/definitions to answer your question. @@ -75,24 +79,24 @@ export function createMcpServer(): McpServer { When using this in shared environments (e.g., Slack), you can set the visibility parameter to 'PUBLIC' to ensure everyone can access the chat link. `, - inputSchema: z.object({ - query: z.string().describe("The query to ask about the codebase."), - repos: z.array(z.string()).optional().describe("The repositories accessible to the agent. If not provided, all repositories are accessible."), - languageModel: languageModelInfoSchema.optional().describe("The language model to use. If not provided, defaults to the first model in the config."), - visibility: z.enum(['PRIVATE', 'PUBLIC']).optional().describe("The visibility of the chat session. Defaults to PRIVATE for authenticated users."), - }), - annotations: { - readOnlyHint: true, - } - }, - async (request) => { - const result = await askCodebase({ - query: request.query, - repos: request.repos, - languageModel: request.languageModel, - visibility: request.visibility as ChatVisibility | undefined, - source: 'mcp', - }); + inputSchema: z.object({ + query: z.string().describe("The query to ask about the codebase."), + repos: z.array(z.string()).optional().describe("The repositories accessible to the agent. If not provided, all repositories are accessible."), + languageModel: languageModelInfoSchema.optional().describe("The language model to use. If not provided, defaults to the first model in the config."), + visibility: z.enum(['PRIVATE', 'PUBLIC']).optional().describe("The visibility of the chat session. Defaults to PRIVATE for authenticated users."), + }), + annotations: { + readOnlyHint: true, + } + }, + async (request) => { + const result = await askCodebase({ + query: request.query, + repos: request.repos, + languageModel: request.languageModel, + visibility: request.visibility as ChatVisibility | undefined, + source: 'mcp', + }); if (isServiceError(result)) { return { From cb17945659e050082b2686491564f10eaf65a90f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 11:54:59 -0700 Subject: [PATCH 28/43] refs / defs improvements --- .../components/chatThread/detailsCard.tsx | 14 +- .../findSymbolDefinitionsToolComponent.tsx | 69 +++------- .../findSymbolReferencesToolComponent.tsx | 69 +++------- .../components/chatThread/tools/shared.tsx | 121 ------------------ packages/web/src/features/codeNav/api.ts | 8 +- packages/web/src/features/codeNav/types.ts | 2 +- .../features/tools/findSymbolDefinitions.ts | 76 ++++++++--- .../features/tools/findSymbolReferences.ts | 73 +++++++++-- 8 files changed, 163 insertions(+), 269 deletions(-) delete mode 100644 packages/web/src/features/chat/components/chatThread/tools/shared.tsx diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 1d39bb35b..f5063daee 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -237,15 +237,21 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { ) case 'tool-find_symbol_definitions': return ( - + loadingText="Resolving definitions..." + > + {(output) => } + ) case 'tool-find_symbol_references': return ( - + loadingText="Resolving references..." + > + {(output) => } + ) case 'tool-list_repos': return ( diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 3e3b99c20..69320d17a 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -1,61 +1,22 @@ 'use client'; -import { FindSymbolDefinitionsToolUIPart } from "@/features/chat/tools"; -import { useMemo, useState } from "react"; -import { FileListItem, ToolHeader, TreeList } from "./shared"; -import { CodeSnippet } from "@/app/components/codeSnippet"; +import { FindSymbolDefinitionsMetadata, ToolResult } from "@/features/tools"; import { Separator } from "@/components/ui/separator"; -import { BookOpenIcon } from "lucide-react"; +import { VscSymbolMisc } from "react-icons/vsc"; +import { RepoBadge } from "./repoBadge"; - -export const FindSymbolDefinitionsToolComponent = ({ part }: { part: FindSymbolDefinitionsToolUIPart }) => { - const [isExpanded, setIsExpanded] = useState(false); - - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Resolving definition...'; - case 'input-available': - return Resolving definition for {part.input.symbol}; - case 'output-error': - return '"Find symbol definitions" tool call failed'; - case 'output-available': - return Resolved definition for {part.input.symbol}; - } - }, [part]); +export const FindSymbolDefinitionsToolComponent = ({ metadata }: ToolResult) => { + const label = `${metadata.matchCount} ${metadata.matchCount === 1 ? 'definition' : 'definitions'}`; return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - - - )} +
+ Resolved + {metadata.symbol} + in + + + {label} +
- ) -} \ No newline at end of file + ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx index 723e7ff16..ba75eb93f 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx @@ -1,61 +1,22 @@ 'use client'; -import { FindSymbolReferencesToolUIPart } from "@/features/chat/tools"; -import { useMemo, useState } from "react"; -import { FileListItem, ToolHeader, TreeList } from "./shared"; -import { CodeSnippet } from "@/app/components/codeSnippet"; +import { FindSymbolReferencesMetadata, ToolResult } from "@/features/tools"; import { Separator } from "@/components/ui/separator"; -import { BookOpenIcon } from "lucide-react"; +import { VscSymbolMisc } from "react-icons/vsc"; +import { RepoBadge } from "./repoBadge"; - -export const FindSymbolReferencesToolComponent = ({ part }: { part: FindSymbolReferencesToolUIPart }) => { - const [isExpanded, setIsExpanded] = useState(false); - - const label = useMemo(() => { - switch (part.state) { - case 'input-streaming': - return 'Resolving references...'; - case 'input-available': - return Resolving references for {part.input.symbol}; - case 'output-error': - return '"Find symbol references" tool call failed'; - case 'output-available': - return Resolved references for {part.input.symbol}; - } - }, [part]); +export const FindSymbolReferencesToolComponent = ({ metadata }: ToolResult) => { + const label = `${metadata.matchCount} ${metadata.matchCount === 1 ? 'reference' : 'references'}`; return ( -
- - {part.state === 'output-available' && isExpanded && ( - <> - {part.output.metadata.files.length === 0 ? ( - No matches found - ) : ( - - {part.output.metadata.files.map((file) => { - return ( - - ) - })} - - )} - - - )} +
+ Resolved + {metadata.symbol} + in + + + {label} +
- ) -} \ No newline at end of file + ); +}; diff --git a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx b/packages/web/src/features/chat/components/chatThread/tools/shared.tsx deleted file mode 100644 index cd064c42b..000000000 --- a/packages/web/src/features/chat/components/chatThread/tools/shared.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client'; - -import { VscodeFileIcon } from '@/app/components/vscodeFileIcon'; -import { CopyIconButton } from '@/app/[domain]/components/copyIconButton'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { cn } from '@/lib/utils'; -import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'; -import Link from 'next/link'; -import React from 'react'; -import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; - - -export const FileListItem = ({ - path, - repoName, -}: { - path: string, - repoName: string, -}) => { - return ( -
- - - {path} - -
- ) -} - -export const TreeList = ({ children }: { children: React.ReactNode }) => { - const childrenArray = React.Children.toArray(children); - - return ( - - {/* vertical line */} -
0 ? `${100 / childrenArray.length * 0.6}%` : '0' - }} - /> - - {childrenArray.map((child, index) => { - const isLast = index === childrenArray.length - 1; - - return ( -
- {!isLast && ( -
- )} - {isLast && ( -
- )} - -
{child}
-
- ) - })} - - ); -}; - -interface ToolHeaderProps { - isLoading: boolean; - isError: boolean; - isExpanded: boolean; - label: React.ReactNode; - Icon: React.ElementType; - onExpand: (isExpanded: boolean) => void; - input?: string; - output?: string; - className?: string; - rightContent?: React.ReactNode; -} - -export const ToolHeader = ({ isLoading, isError, isExpanded, label, Icon, onExpand, input, output, className, rightContent }: ToolHeaderProps) => { - return ( -
- {isLoading ? ( - - ) : ( - - )} - - {label} - - {rightContent && ( - {rightContent} - )} -
- ) -} \ No newline at end of file diff --git a/packages/web/src/features/codeNav/api.ts b/packages/web/src/features/codeNav/api.ts index 2d0e92364..fe7a44e54 100644 --- a/packages/web/src/features/codeNav/api.ts +++ b/packages/web/src/features/codeNav/api.ts @@ -22,8 +22,6 @@ export const findSearchBasedSymbolReferences = async (props: FindRelatedSymbolsR repoName, } = props; - const languageFilter = getExpandedLanguageFilter(language); - const query: QueryIR = { and: { children: [ @@ -41,7 +39,7 @@ export const findSearchBasedSymbolReferences = async (props: FindRelatedSymbolsR exact: true, } }, - languageFilter, + ...(language ? [getExpandedLanguageFilter(language)] : []), ...(repoName ? [{ repo: { regexp: `^${escapeStringRegexp(repoName)}$`, @@ -78,8 +76,6 @@ export const findSearchBasedSymbolDefinitions = async (props: FindRelatedSymbols repoName } = props; - const languageFilter = getExpandedLanguageFilter(language); - const query: QueryIR = { and: { children: [ @@ -101,7 +97,7 @@ export const findSearchBasedSymbolDefinitions = async (props: FindRelatedSymbols exact: true, } }, - languageFilter, + ...(language ? [getExpandedLanguageFilter(language)] : []), ...(repoName ? [{ repo: { regexp: `^${escapeStringRegexp(repoName)}$`, diff --git a/packages/web/src/features/codeNav/types.ts b/packages/web/src/features/codeNav/types.ts index d3b789471..59fd7b22f 100644 --- a/packages/web/src/features/codeNav/types.ts +++ b/packages/web/src/features/codeNav/types.ts @@ -3,7 +3,7 @@ import { rangeSchema, repositoryInfoSchema } from "../search/types"; export const findRelatedSymbolsRequestSchema = z.object({ symbolName: z.string(), - language: z.string(), + language: z.string().optional(), /** * Optional revision name to scope search to. * If not provided, the search will be scoped to HEAD. diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index e4c304277..b52813a1e 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -1,19 +1,27 @@ -import { z } from "zod"; -import { isServiceError } from "@/lib/utils"; +import { getRepoInfoByName } from "@/actions"; import { findSearchBasedSymbolDefinitions } from "@/features/codeNav/api"; -import { addLineNumbers } from "@/features/chat/utils"; -import { ToolDefinition } from "./types"; -import { FindSymbolFile } from "./findSymbolReferences"; -import { logger } from "./logger"; +import { isServiceError } from "@/lib/utils"; +import { z } from "zod"; import description from "./findSymbolDefinitions.txt"; +import { FindSymbolFile, FindSymbolRepoInfo } from "./findSymbolReferences"; +import { logger } from "./logger"; +import { ToolDefinition } from "./types"; + + +const MAX_LINE_LENGTH = 2000; +const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; const findSymbolDefinitionsShape = { symbol: z.string().describe("The symbol to find definitions of"), - language: z.string().describe("The programming language of the symbol"), - repo: z.string().describe("The repository to scope the search to").optional(), + repo: z.string().describe("The repository to scope the search to"), }; + export type FindSymbolDefinitionsMetadata = { + symbol: string; + matchCount: number; + fileCount: number; + repoInfo: FindSymbolRepoInfo; files: FindSymbolFile[]; }; @@ -28,13 +36,12 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< isIdempotent: true, description, inputSchema: z.object(findSymbolDefinitionsShape), - execute: async ({ symbol, language, repo }, _context) => { - logger.debug('find_symbol_definitions', { symbol, language, repo }); + execute: async ({ symbol, repo }, _context) => { + logger.debug('find_symbol_definitions', { symbol, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolDefinitions({ symbolName: symbol, - language, revisionName: revision, repoName: repo, }); @@ -43,20 +50,57 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< throw new Error(response.message); } + const matchCount = response.stats.matchCount; + const fileCount = response.files.length; + + const repoInfoResult = await getRepoInfoByName(repo); + if (isServiceError(repoInfoResult) || !repoInfoResult) { + throw new Error(`Repository "${repo}" not found.`); + } + const repoInfo: FindSymbolRepoInfo = { + name: repoInfoResult.name, + displayName: repoInfoResult.displayName ?? repoInfoResult.name, + codeHostType: repoInfoResult.codeHostType, + }; + const metadata: FindSymbolDefinitionsMetadata = { + symbol, + matchCount, + fileCount, + repoInfo, files: response.files.map((file) => ({ fileName: file.fileName, repo: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), revision, })), }; + if (fileCount === 0) { + return { + output: 'No definitions found', + metadata, + }; + } + + const outputLines: string[] = [ + `Found ${matchCount} ${matchCount === 1 ? 'definition' : 'definitions'} in ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + ]; + + for (const file of response.files) { + outputLines.push(''); + outputLines.push(`[${file.repository}] ${file.fileName}:`); + for (const { lineContent, range } of file.matches) { + const lineNum = range.start.lineNumber; + const trimmed = lineContent.trimEnd(); + const line = trimmed.length > MAX_LINE_LENGTH + ? trimmed.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : trimmed; + outputLines.push(` ${lineNum}: ${line}`); + } + } + return { - output: JSON.stringify(metadata), + output: outputLines.join('\n'), metadata, }; }, diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index 16953b7ab..3013bd34d 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -1,26 +1,37 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { findSearchBasedSymbolReferences } from "@/features/codeNav/api"; -import { addLineNumbers } from "@/features/chat/utils"; import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./findSymbolReferences.txt"; +import { getRepoInfoByName } from "@/actions"; +import { CodeHostType } from "@sourcebot/db"; + +const MAX_LINE_LENGTH = 2000; +const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; const findSymbolReferencesShape = { symbol: z.string().describe("The symbol to find references to"), - language: z.string().describe("The programming language of the symbol"), - repo: z.string().describe("The repository to scope the search to").optional(), + repo: z.string().describe("The repository to scope the search to"), +}; + +export type FindSymbolRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; }; export type FindSymbolFile = { fileName: string; repo: string; - language: string; - matches: string[]; revision: string; }; export type FindSymbolReferencesMetadata = { + symbol: string; + matchCount: number; + fileCount: number; + repoInfo: FindSymbolRepoInfo; files: FindSymbolFile[]; }; @@ -35,13 +46,12 @@ export const findSymbolReferencesDefinition: ToolDefinition< isIdempotent: true, description, inputSchema: z.object(findSymbolReferencesShape), - execute: async ({ symbol, language, repo }, _context) => { - logger.debug('find_symbol_references', { symbol, language, repo }); + execute: async ({ symbol, repo }, _context) => { + logger.debug('find_symbol_references', { symbol, repo }); const revision = "HEAD"; const response = await findSearchBasedSymbolReferences({ symbolName: symbol, - language, revisionName: revision, repoName: repo, }); @@ -50,20 +60,57 @@ export const findSymbolReferencesDefinition: ToolDefinition< throw new Error(response.message); } + const matchCount = response.stats.matchCount; + const fileCount = response.files.length; + + const repoInfoResult = await getRepoInfoByName(repo); + if (isServiceError(repoInfoResult) || !repoInfoResult) { + throw new Error(`Repository "${repo}" not found.`); + } + const repoInfo: FindSymbolRepoInfo = { + name: repoInfoResult.name, + displayName: repoInfoResult.displayName ?? repoInfoResult.name, + codeHostType: repoInfoResult.codeHostType, + }; + const metadata: FindSymbolReferencesMetadata = { + symbol, + matchCount, + fileCount, + repoInfo, files: response.files.map((file) => ({ fileName: file.fileName, repo: file.repository, - language: file.language, - matches: file.matches.map(({ lineContent, range }) => { - return addLineNumbers(lineContent, range.start.lineNumber); - }), revision, })), }; + if (fileCount === 0) { + return { + output: 'No references found', + metadata, + }; + } + + const outputLines: string[] = [ + `Found ${matchCount} ${matchCount === 1 ? 'reference' : 'references'} in ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + ]; + + for (const file of response.files) { + outputLines.push(''); + outputLines.push(`[${file.repository}] ${file.fileName}:`); + for (const { lineContent, range } of file.matches) { + const lineNum = range.start.lineNumber; + const trimmed = lineContent.trimEnd(); + const line = trimmed.length > MAX_LINE_LENGTH + ? trimmed.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : trimmed; + outputLines.push(` ${lineNum}: ${line}`); + } + } + return { - output: JSON.stringify(metadata), + output: outputLines.join('\n'), metadata, }; }, From 3ce592f3f0e24e55775bb0bf17735c6e694b2ebf Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 11:58:58 -0700 Subject: [PATCH 29/43] rename tooloutput --- .../components/chatThread/detailsCard.tsx | 30 +++++++++---------- ...olLoadingGuard.tsx => toolOutputGuard.tsx} | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) rename packages/web/src/features/chat/components/chatThread/tools/{toolLoadingGuard.tsx => toolOutputGuard.tsx} (97%) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index f5063daee..2eb1804f9 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -22,7 +22,7 @@ import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; import { ListReposToolComponent } from './tools/listReposToolComponent'; import { ListTreeToolComponent } from './tools/listTreeToolComponent'; import { ReadFileToolComponent } from './tools/readFileToolComponent'; -import { ToolLoadingGuard } from './tools/toolLoadingGuard'; +import { ToolOutputGuard } from './tools/toolOutputGuard'; interface DetailsCardProps { @@ -219,66 +219,66 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { ) case 'tool-read_file': return ( - {(output) => } - + ) case 'tool-grep': return ( - {(output) => } - + ) case 'tool-find_symbol_definitions': return ( - {(output) => } - + ) case 'tool-find_symbol_references': return ( - {(output) => } - + ) case 'tool-list_repos': return ( - {(output) => } - + ) case 'tool-list_commits': return ( - {(output) => } - + ) case 'tool-list_tree': return ( - {(output) => } - + ) case 'data-source': case 'dynamic-tool': diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx similarity index 97% rename from packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx rename to packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx index 91aa1ff3e..c682cfcad 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolLoadingGuard.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx @@ -7,7 +7,7 @@ import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCallback, useState } from "react"; -export const ToolLoadingGuard = >({ +export const ToolOutputGuard = >({ part, loadingText, children, From 8f66fb87cfbb537c6d93b0868f396d27669b56b2 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 13:14:08 -0700 Subject: [PATCH 30/43] groupByRepo --- .../findSymbolDefinitionsToolComponent.tsx | 2 +- .../findSymbolReferencesToolComponent.tsx | 2 +- .../chatThread/tools/grepToolComponent.tsx | 81 +++++++++++++++---- .../tools/listTreeToolComponent.tsx | 2 +- .../tools/readFileToolComponent.tsx | 4 +- packages/web/src/features/tools/grep.ts | 50 ++++++++++-- packages/web/src/features/tools/grep.txt | 1 + 7 files changed, 115 insertions(+), 27 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx index 69320d17a..ae327d640 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolDefinitionsToolComponent.tsx @@ -11,7 +11,7 @@ export const FindSymbolDefinitionsToolComponent = ({ metadata }: ToolResult Resolved - {metadata.symbol} + {metadata.symbol} in diff --git a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx index ba75eb93f..d423fe976 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/findSymbolReferencesToolComponent.tsx @@ -11,7 +11,7 @@ export const FindSymbolReferencesToolComponent = ({ metadata }: ToolResult Resolved - {metadata.symbol} + {metadata.symbol} in diff --git a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx index 690e37e85..fe655e482 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx @@ -7,7 +7,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; -import { getCodeHostIcon } from "@/lib/utils"; +import { cn, getCodeHostIcon } from "@/lib/utils"; import Image from "next/image"; import { Separator } from "@/components/ui/separator"; import Link from "next/link"; @@ -16,6 +16,9 @@ export const GrepToolComponent = (output: ToolResult) => { const stats = useMemo(() => { const { matchCount, repoCount } = output.metadata; const matchLabel = `${matchCount} ${matchCount === 1 ? 'match' : 'matches'}`; + if (matchCount === 0 || repoCount === 1) { + return matchLabel; + } const repoLabel = `${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}`; return `${matchLabel} · ${repoLabel}`; }, [output]); @@ -40,9 +43,9 @@ export const GrepToolComponent = (output: ToolResult) => {
- + Searched - {output.metadata.pattern} + {output.metadata.pattern} {singleRepo && <>in} @@ -53,14 +56,29 @@ export const GrepToolComponent = (output: ToolResult) => { {output.metadata.files.length > 0 && (
- {Array.from(filesByRepo.entries()).map(([repo, files]) => ( -
- - {files.map((file) => ( - - ))} -
- ))} + {output.metadata.groupByRepo ? ( + Array.from(filesByRepo.keys()).map((repo) => ( + + )) + ) : ( + Array.from(filesByRepo.entries()).map(([repo, files]) => ( +
+ + {files.map((file) => ( + + ))} +
+ )) + )}
)} @@ -68,18 +86,49 @@ export const GrepToolComponent = (output: ToolResult) => { ); } -const RepoHeader = ({ repo, repoName }: { repo: GrepRepoInfo | undefined; repoName: string }) => { +const RepoHeader = ({ repo, repoName, isPrimary }: { repo: GrepRepoInfo | undefined; repoName: string; isPrimary: boolean }) => { const displayName = repo?.displayName ?? repoName.split('/').slice(1).join('/'); const icon = repo ? getCodeHostIcon(repo.codeHostType) : null; - return ( -
+ const href = getBrowsePath({ + repoName: repoName, + path: '', + pathType: 'tree', + domain: SINGLE_TENANT_ORG_DOMAIN, + }); + + const className = cn("top-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-popover border-b border-border", + { + 'sticky text-muted-foreground': !isPrimary, + 'text-foreground cursor-pointer hover:bg-accent transition-colors': isPrimary, + } + ) + + const Content = ( + <> {icon && ( {repo!.codeHostType} )} {displayName} -
- ); + + ) + + if (isPrimary) { + return ( + + {Content} + + ) + } else { + return ( +
+ {Content} +
+ ) + } } const FileRow = ({ file }: { file: GrepFile }) => { diff --git a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx index 28cd2f822..bc49c56ef 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/listTreeToolComponent.tsx @@ -21,7 +21,7 @@ export const ListTreeToolComponent = ({ metadata }: ToolResult domain: SINGLE_TENANT_ORG_DOMAIN, })} onClick={(e) => e.stopPropagation()} - className="inline-flex items-center gap-1 text-xs bg-muted hover:bg-accent px-1.5 py-0.5 rounded truncate text-foreground font-medium transition-colors" + className="inline-flex items-center gap-1 text-xs bg-muted hover:bg-accent px-1.5 py-0.5 rounded truncate text-foreground font-medium transition-colors min-w-0" > {metadata.path || '/'} diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 84b9558fe..44e207683 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -29,11 +29,11 @@ export const ReadFileToolComponent = ({ metadata }: ToolResult Read e.stopPropagation()} > - {fileName} + {fileName} {(metadata.isTruncated || metadata.startLine > 1) && ( L{metadata.startLine}-{metadata.endLine} )} diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index ec2e8198f..1f200d961 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -8,9 +8,11 @@ import { logger } from "./logger"; import description from "./grep.txt"; import { CodeHostType } from "@sourcebot/db"; -const DEFAULT_SEARCH_LIMIT = 100; +const DEFAULT_LIMIT = 100; +const DEFAULT_GROUP_BY_REPO_LIMIT = 10_000; const MAX_LINE_LENGTH = 2000; const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`; +const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific path or pattern, specifying a repo, or increasing the limit.)`; function globToFileRegexp(glob: string): string { const re = globToRegexp(glob, { extended: true, globstar: true }); @@ -39,9 +41,12 @@ const grepShape = { .optional(), limit: z .number() - .default(DEFAULT_SEARCH_LIMIT) - .describe(`The maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`) + .describe(`The maximum number of matches to return (default: ${DEFAULT_LIMIT} when groupByRepo=false, ${DEFAULT_GROUP_BY_REPO_LIMIT} when groupByRepo=true)`) .optional(), + groupByRepo: z + .boolean() + .optional() + .describe(`If true, returns a summary of match counts grouped by repository instead of individual file results.`), }; export type GrepFile = { @@ -64,6 +69,7 @@ export type GrepMetadata = { matchCount: number; repoCount: number; repoInfoMap: Record; + groupByRepo: boolean; }; export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetadata> = { @@ -79,9 +85,13 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada include, repo, ref, - limit = DEFAULT_SEARCH_LIMIT, + limit: _limit, + groupByRepo = false, }, context) => { - logger.debug('grep', { pattern, path, include, repo, ref, limit }); + + const limit = _limit ?? (groupByRepo ? DEFAULT_GROUP_BY_REPO_LIMIT : DEFAULT_LIMIT); + + logger.debug('grep', { pattern, path, include, repo, ref, limit, groupByRepo }); const quotedPattern = `"${pattern.replace(/"/g, '\\"')}"`; let query = quotedPattern; @@ -142,6 +152,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada matchCount: response.stats.actualMatchCount, repoCount: new Set(files.map((f) => f.repo)).size, repoInfoMap, + groupByRepo, }; const totalFiles = response.files.length; @@ -154,6 +165,33 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada }; } + if (groupByRepo) { + const repoCounts = new Map(); + for (const file of response.files) { + const repo = file.repository; + const matchCount = file.chunks.reduce((acc, chunk) => acc + chunk.matchRanges.length, 0); + const existing = repoCounts.get(repo) ?? { matches: 0, files: 0 }; + repoCounts.set(repo, { matches: existing.matches + matchCount, files: existing.files + 1 }); + } + + const outputLines: string[] = [ + `Found matches in ${repoCounts.size} ${repoCounts.size === 1 ? 'repository' : 'repositories'}:`, + ]; + for (const [repoName, { matches, files }] of repoCounts) { + outputLines.push(` ${repoName}: ${matches} ${matches === 1 ? 'match' : 'matches'} in ${files} ${files === 1 ? 'file' : 'files'}`); + } + + if (!response.isSearchExhaustive) { + outputLines.push(''); + outputLines.push(TRUNCATION_MESSAGE); + } + + return { + output: outputLines.join('\n'), + metadata, + }; + } + const outputLines: string[] = [ `Found ${actualMatches} match${actualMatches !== 1 ? 'es' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`, ]; @@ -177,7 +215,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada if (!response.isSearchExhaustive) { outputLines.push(''); - outputLines.push(`(Results truncated. Consider using a more specific path or pattern, specifying a repo, or increasing the limit.)`); + outputLines.push(TRUNCATION_MESSAGE); } return { diff --git a/packages/web/src/features/tools/grep.txt b/packages/web/src/features/tools/grep.txt index dccdac441..c8f06e477 100644 --- a/packages/web/src/features/tools/grep.txt +++ b/packages/web/src/features/tools/grep.txt @@ -5,3 +5,4 @@ - Returns file paths and line numbers with at least one match - Use this tool when you need to find files containing specific patterns - When using the `repo` param, if the repository name is not known, use `list_repos` first to discover the correct name. +- Use `groupByRepo: true` when searching across many repositories and you want to identify which repos are most relevant before drilling in. This returns a per-repository summary (match and file counts) instead of individual file results, and automatically uses a higher match limit for accuracy. From e4f256c185e6c52958658617710689cff53f3e57 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 13:31:29 -0700 Subject: [PATCH 31/43] Add tool count to details card header --- .../chat/components/chatThread/detailsCard.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index 2eb1804f9..c6f99f7b6 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -9,8 +9,8 @@ import useCaptureEvent from '@/hooks/useCaptureEvent'; import { cn, getShortenedNumberDisplayString } from '@/lib/utils'; import isEqual from "fast-deep-equal/react"; import { useStickToBottom } from 'use-stick-to-bottom'; -import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, ScanSearchIcon, Zap } from 'lucide-react'; -import { memo, useCallback, useEffect, useState } from 'react'; +import { Brain, ChevronDown, ChevronRight, Clock, InfoIcon, Loader2, ScanSearchIcon, Wrench, Zap } from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { usePrevious } from '@uidotdev/usehooks'; import { SBChatMessageMetadata, SBChatMessagePart } from '../../types'; import { SearchScopeIcon } from '../searchScopeIcon'; @@ -46,6 +46,8 @@ const DetailsCardComponent = ({ }: DetailsCardProps) => { const captureEvent = useCaptureEvent(); + const toolCallCount = useMemo(() => thinkingSteps.flat().filter(part => part.type.startsWith('tool-')).length, [thinkingSteps]); + const handleExpandedChanged = useCallback((next: boolean) => { captureEvent('wa_chat_details_card_toggled', { chatId, isExpanded: next }); onExpandedChanged(next); @@ -140,6 +142,12 @@ const DetailsCardComponent = ({ {Math.round(metadata.totalResponseTimeMs / 1000)} seconds
)} + {toolCallCount > 0 && ( +
+ + {toolCallCount} tool call{toolCallCount === 1 ? '' : 's'} +
+ )} )}
From 4bab982d30ec9fd41f883b0f5931cdabadc0fa33 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 14:34:41 -0700 Subject: [PATCH 32/43] glob tool --- packages/web/src/features/chat/agent.ts | 12 +- .../components/chatThread/detailsCard.tsx | 10 + .../components/chatThread/tools/fileRow.tsx | 43 ++++ .../chatThread/tools/globToolComponent.tsx | 68 +++++++ .../chatThread/tools/grepToolComponent.tsx | 91 +-------- .../chatThread/tools/repoHeader.tsx | 55 +++++ .../chatThread/tools/toolOutputGuard.tsx | 2 +- packages/web/src/features/chat/tools.ts | 3 + packages/web/src/features/tools/glob.ts | 192 ++++++++++++++++++ packages/web/src/features/tools/glob.txt | 6 + packages/web/src/features/tools/grep.ts | 76 ++++--- packages/web/src/features/tools/index.ts | 1 + 12 files changed, 439 insertions(+), 120 deletions(-) create mode 100644 packages/web/src/features/chat/components/chatThread/tools/fileRow.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/globToolComponent.tsx create mode 100644 packages/web/src/features/chat/components/chatThread/tools/repoHeader.tsx create mode 100644 packages/web/src/features/tools/glob.ts create mode 100644 packages/web/src/features/tools/glob.txt diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 3645bb42e..4d6d1f4c5 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -22,7 +22,7 @@ import { grepDefinition } from "@/features/tools/grep"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import { createTools } from "./tools"; -import { listTreeDefinition } from "../tools"; +import { globDefinition, listTreeDefinition } from "../tools"; const dedent = _dedent.withOptions({ alignValues: true }); @@ -261,6 +261,16 @@ const createAgentStream = async ({ name: entry.name, }); }); + } else if (toolName === globDefinition.name) { + output.metadata.files.forEach((file) => { + onWriteSource({ + type: 'file', + repo: file.repo, + path: file.path, + revision: file.revision, + name: file.path.split('/').pop() ?? file.path, + }); + }); } }); }, diff --git a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx index c6f99f7b6..72ec22b8c 100644 --- a/packages/web/src/features/chat/components/chatThread/detailsCard.tsx +++ b/packages/web/src/features/chat/components/chatThread/detailsCard.tsx @@ -17,6 +17,7 @@ import { SearchScopeIcon } from '../searchScopeIcon'; import { MarkdownRenderer } from './markdownRenderer'; import { FindSymbolDefinitionsToolComponent } from './tools/findSymbolDefinitionsToolComponent'; import { FindSymbolReferencesToolComponent } from './tools/findSymbolReferencesToolComponent'; +import { GlobToolComponent } from './tools/globToolComponent'; import { GrepToolComponent } from './tools/grepToolComponent'; import { ListCommitsToolComponent } from './tools/listCommitsToolComponent'; import { ListReposToolComponent } from './tools/listReposToolComponent'; @@ -243,6 +244,15 @@ export const StepPartRenderer = ({ part }: { part: SBChatMessagePart }) => { {(output) => } ) + case 'tool-glob': + return ( + + {(output) => } + + ) case 'tool-find_symbol_definitions': return ( { + const dir = file.path.includes('/') + ? file.path.split('/').slice(0, -1).join('/') + : ''; + + const href = getBrowsePath({ + repoName: file.repo, + revisionName: file.revision, + path: file.path, + pathType: 'blob', + domain: SINGLE_TENANT_ORG_DOMAIN, + }); + + return ( + + + {file.name} + {dir && ( + <> + · + {dir} + + )} + + ); +} diff --git a/packages/web/src/features/chat/components/chatThread/tools/globToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/globToolComponent.tsx new file mode 100644 index 000000000..e73250402 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/globToolComponent.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { GlobFile, GlobMetadata, ToolResult } from "@/features/tools"; +import { useMemo } from "react"; +import { RepoBadge } from "./repoBadge"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { Separator } from "@/components/ui/separator"; +import { RepoHeader } from "./repoHeader"; +import { FileRow } from "./fileRow"; + +export const GlobToolComponent = (output: ToolResult) => { + const stats = useMemo(() => { + const { fileCount, repoCount } = output.metadata; + const fileLabel = `${fileCount} ${fileCount === 1 ? 'file' : 'files'}`; + if (fileCount === 0 || repoCount <= 1) { + return fileLabel; + } + const repoLabel = `${repoCount} ${repoCount === 1 ? 'repo' : 'repos'}`; + return `${fileLabel} · ${repoLabel}`; + }, [output]); + + const filesByRepo = useMemo(() => { + const groups = new Map(); + for (const file of output.metadata.files) { + if (!groups.has(file.repo)) { + groups.set(file.repo, []); + } + groups.get(file.repo)!.push(file); + } + return groups; + }, [output.metadata.files]); + + return ( + +
+
+ + + Searched files + {output.metadata.pattern} + {output.metadata.inputRepo && <>in} + + +
+ {stats} + +
+ {output.metadata.files.length > 0 && ( + +
+ {Array.from(filesByRepo.entries()).map(([repo, files]) => ( +
+ + {files.map((file) => ( + + ))} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx index fe655e482..a8e2852f2 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/grepToolComponent.tsx @@ -1,16 +1,12 @@ 'use client'; -import { GrepFile, GrepMetadata, GrepRepoInfo, ToolResult } from "@/features/tools"; +import { GrepFile, GrepMetadata, ToolResult } from "@/features/tools"; import { useMemo } from "react"; import { RepoBadge } from "./repoBadge"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; -import { VscodeFileIcon } from "@/app/components/vscodeFileIcon"; -import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; -import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; -import { cn, getCodeHostIcon } from "@/lib/utils"; -import Image from "next/image"; import { Separator } from "@/components/ui/separator"; -import Link from "next/link"; +import { RepoHeader } from "./repoHeader"; +import { FileRow } from "./fileRow"; export const GrepToolComponent = (output: ToolResult) => { const stats = useMemo(() => { @@ -34,10 +30,6 @@ export const GrepToolComponent = (output: ToolResult) => { return groups; }, [output.metadata.files]); - const singleRepo = output.metadata.repoCount === 1 - ? output.metadata.repoInfoMap[output.metadata.files[0]?.repo] - : undefined; - return (
@@ -46,7 +38,7 @@ export const GrepToolComponent = (output: ToolResult) => { Searched {output.metadata.pattern} - {singleRepo && <>in} + {output.metadata.inputRepo && <>in}
@@ -85,78 +77,3 @@ export const GrepToolComponent = (output: ToolResult) => {
); } - -const RepoHeader = ({ repo, repoName, isPrimary }: { repo: GrepRepoInfo | undefined; repoName: string; isPrimary: boolean }) => { - const displayName = repo?.displayName ?? repoName.split('/').slice(1).join('/'); - const icon = repo ? getCodeHostIcon(repo.codeHostType) : null; - - const href = getBrowsePath({ - repoName: repoName, - path: '', - pathType: 'tree', - domain: SINGLE_TENANT_ORG_DOMAIN, - }); - - const className = cn("top-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-popover border-b border-border", - { - 'sticky text-muted-foreground': !isPrimary, - 'text-foreground cursor-pointer hover:bg-accent transition-colors': isPrimary, - } - ) - - const Content = ( - <> - {icon && ( - {repo!.codeHostType} - )} - {displayName} - - ) - - if (isPrimary) { - return ( - - {Content} - - ) - } else { - return ( -
- {Content} -
- ) - } -} - -const FileRow = ({ file }: { file: GrepFile }) => { - const dir = file.path.includes('/') - ? file.path.split('/').slice(0, -1).join('/') - : ''; - - const href = getBrowsePath({ - repoName: file.repo, - revisionName: file.revision, - path: file.path, - pathType: 'blob', - domain: SINGLE_TENANT_ORG_DOMAIN, - }); - - return ( - - - {file.name} - {dir && ( - <> - · - {dir} - - )} - - ); -} diff --git a/packages/web/src/features/chat/components/chatThread/tools/repoHeader.tsx b/packages/web/src/features/chat/components/chatThread/tools/repoHeader.tsx new file mode 100644 index 000000000..4ce148ca7 --- /dev/null +++ b/packages/web/src/features/chat/components/chatThread/tools/repoHeader.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils"; +import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants"; +import { cn, getCodeHostIcon } from "@/lib/utils"; +import { CodeHostType } from "@sourcebot/db"; +import Image from "next/image"; +import Link from "next/link"; + +type RepoInfo = { + displayName: string; + codeHostType: CodeHostType; +}; + +export const RepoHeader = ({ repo, repoName, isPrimary }: { repo: RepoInfo | undefined; repoName: string; isPrimary: boolean }) => { + const displayName = repo?.displayName ?? repoName.split('/').slice(1).join('/'); + const icon = repo ? getCodeHostIcon(repo.codeHostType) : null; + + const href = getBrowsePath({ + repoName: repoName, + path: '', + pathType: 'tree', + domain: SINGLE_TENANT_ORG_DOMAIN, + }); + + const className = cn("top-0 flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-popover border-b border-border", + { + 'sticky text-muted-foreground': !isPrimary, + 'text-foreground cursor-pointer hover:bg-accent transition-colors': isPrimary, + } + ); + + const Content = ( + <> + {icon && ( + {repo!.codeHostType} + )} + {displayName} + + ); + + if (isPrimary) { + return ( + + {Content} + + ); + } else { + return ( +
+ {Content} +
+ ); + } +} diff --git a/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx index c682cfcad..b31540b6f 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/toolOutputGuard.tsx @@ -69,7 +69,7 @@ export const ToolOutputGuard = {hasInput && isExpanded && (
- +
                             {requestText}
                         
diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts index d3673a7b8..37ad7e0a8 100644 --- a/packages/web/src/features/chat/tools.ts +++ b/packages/web/src/features/chat/tools.ts @@ -4,6 +4,7 @@ import { listCommitsDefinition, listReposDefinition, grepDefinition, + globDefinition, findSymbolReferencesDefinition, findSymbolDefinitionsDefinition, listTreeDefinition, @@ -17,6 +18,7 @@ export const createTools = (context: ToolContext) => ({ [listCommitsDefinition.name]: toVercelAITool(listCommitsDefinition, context), [listReposDefinition.name]: toVercelAITool(listReposDefinition, context), [grepDefinition.name]: toVercelAITool(grepDefinition, context), + [globDefinition.name]: toVercelAITool(globDefinition, context), [findSymbolReferencesDefinition.name]: toVercelAITool(findSymbolReferencesDefinition, context), [findSymbolDefinitionsDefinition.name]: toVercelAITool(findSymbolDefinitionsDefinition, context), [listTreeDefinition.name]: toVercelAITool(listTreeDefinition, context), @@ -26,6 +28,7 @@ export type ReadFileToolUIPart = ToolUIPart<{ read_file: SBChatMessageToolTypes[ export type ListCommitsToolUIPart = ToolUIPart<{ list_commits: SBChatMessageToolTypes['list_commits'] }>; export type ListReposToolUIPart = ToolUIPart<{ list_repos: SBChatMessageToolTypes['list_repos'] }>; export type GrepToolUIPart = ToolUIPart<{ grep: SBChatMessageToolTypes['grep'] }>; +export type GlobToolUIPart = ToolUIPart<{ glob: SBChatMessageToolTypes['glob'] }>; export type FindSymbolReferencesToolUIPart = ToolUIPart<{ find_symbol_references: SBChatMessageToolTypes['find_symbol_references'] }>; export type FindSymbolDefinitionsToolUIPart = ToolUIPart<{ find_symbol_definitions: SBChatMessageToolTypes['find_symbol_definitions'] }>; export type ListTreeToolUIPart = ToolUIPart<{ list_tree: SBChatMessageToolTypes['list_tree'] }>; diff --git a/packages/web/src/features/tools/glob.ts b/packages/web/src/features/tools/glob.ts new file mode 100644 index 000000000..054bfa34d --- /dev/null +++ b/packages/web/src/features/tools/glob.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; +import globToRegexp from "glob-to-regexp"; +import { isServiceError } from "@/lib/utils"; +import { search } from "@/features/search"; +import escapeStringRegexp from "escape-string-regexp"; +import { ToolDefinition } from "./types"; +import { logger } from "./logger"; +import description from "./glob.txt"; +import { CodeHostType } from "@sourcebot/db"; +import { getRepoInfoByName } from "@/actions"; + +const DEFAULT_LIMIT = 100; +const TRUNCATION_MESSAGE = `(Results truncated. Consider using a more specific pattern, specifying a repo, or increasing the limit.)`; + +function globToFileRegexp(glob: string): string { + const re = globToRegexp(glob, { extended: true, globstar: true }); + return re.source.replace(/^\^/, ''); +} + +const globShape = { + pattern: z + .string() + .describe(`The glob pattern to match file paths against (e.g. "**/*.ts", "src/**/*.test.{ts,tsx}")`), + path: z + .string() + .describe(`Restrict results to files under this subdirectory.`) + .optional(), + repo: z + .string() + .describe(`The name of the repository to search in. If not provided, searches all repositories.`) + .optional(), + ref: z + .string() + .describe(`The commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`) + .optional(), + limit: z + .number() + .describe(`The maximum number of files to return (default: ${DEFAULT_LIMIT})`) + .optional(), +}; + +export type GlobFile = { + path: string; + name: string; + repo: string; + revision: string; +}; + +export type GlobRepoInfo = { + name: string; + displayName: string; + codeHostType: CodeHostType; +}; + +export type GlobMetadata = { + files: GlobFile[]; + pattern: string; + query: string; + fileCount: number; + repoCount: number; + repoInfoMap: Record; + inputRepo?: GlobRepoInfo; + truncated: boolean; +}; + +export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetadata> = { + name: 'glob', + title: 'Find files', + isReadOnly: true, + isIdempotent: true, + description, + inputSchema: z.object(globShape), + execute: async ({ + pattern, + repo, + ref, + path, + limit: _limit, + }, context) => { + const limit = _limit ?? DEFAULT_LIMIT; + + logger.debug('glob', { pattern, repo, ref, path, limit }); + + let query = `file:${globToFileRegexp(pattern)}`; + + if (path) { + query += ` file:${escapeStringRegexp(path)}`; + } + + if (repo) { + query += ` repo:${escapeStringRegexp(repo)}`; + } else if (context.selectedRepos && context.selectedRepos.length > 0) { + query += ` reposet:${context.selectedRepos.join(',')}`; + } + + if (ref) { + query += ` rev:${ref}`; + } + + const response = await search({ + queryType: 'string', + query, + options: { + matches: limit, + contextLines: 0, + isCaseSensitivityEnabled: true, + isRegexEnabled: true, + }, + source: context.source, + }); + + if (isServiceError(response)) { + throw new Error(response.message); + } + + const files = response.files.map((file) => ({ + path: file.fileName.text, + name: file.fileName.text.split('/').pop() ?? file.fileName.text, + repo: file.repository, + revision: ref ?? 'HEAD', + } satisfies GlobFile)); + + const repoInfoMap = Object.fromEntries( + response.repositoryInfo.map((info) => [info.name, { + name: info.name, + displayName: info.displayName ?? info.name, + codeHostType: info.codeHostType, + }]) + ); + + const truncated = !response.isSearchExhaustive; + + const inputRepoResult = repo ? await getRepoInfoByName(repo) : undefined; + if (isServiceError(inputRepoResult)) { + throw new Error(`Repository "${repo}" not found.`); + } + + const inputRepo = inputRepoResult ? { + name: inputRepoResult.name, + displayName: inputRepoResult.displayName ?? inputRepoResult.name, + codeHostType: inputRepoResult.codeHostType, + } : undefined; + + const metadata: GlobMetadata = { + files, + pattern, + query, + fileCount: files.length, + repoCount: new Set(files.map((f) => f.repo)).size, + repoInfoMap, + inputRepo: inputRepo, + truncated, + }; + + if (files.length === 0) { + return { + output: 'No files found', + metadata, + }; + } + + const filesByRepo = new Map(); + for (const file of files) { + if (!filesByRepo.has(file.repo)) { + filesByRepo.set(file.repo, []); + } + filesByRepo.get(file.repo)!.push(file); + } + + const outputLines: string[] = [ + `Found ${files.length} file${files.length !== 1 ? 's' : ''} matching "${pattern}":`, + ]; + + for (const [repo, repoFiles] of filesByRepo) { + outputLines.push(''); + outputLines.push(`[${repo}]`); + for (const file of repoFiles) { + outputLines.push(` ${file.path}`); + } + } + + if (truncated) { + outputLines.push(''); + outputLines.push(TRUNCATION_MESSAGE); + } + + return { + output: outputLines.join('\n'), + metadata, + }; + }, +}; diff --git a/packages/web/src/features/tools/glob.txt b/packages/web/src/features/tools/glob.txt new file mode 100644 index 000000000..c224ba6e4 --- /dev/null +++ b/packages/web/src/features/tools/glob.txt @@ -0,0 +1,6 @@ +- Find files by name/path pattern using glob syntax (e.g. "**/*.ts", "src/**/*.test.{ts,tsx}") +- Returns matching file paths only — does NOT search file contents +- Use this tool when you need to enumerate files by type or naming convention +- Use grep instead if you need to find files containing specific content +- Supports standard glob syntax: * matches within a path segment, ** matches across segments, {a,b} matches alternatives +- When using the repo param, if the repository name is not known, use list_repos first to discover the correct name diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index 1f200d961..6627f3f14 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -7,6 +7,7 @@ import { ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./grep.txt"; import { CodeHostType } from "@sourcebot/db"; +import { getRepoInfoByName } from "@/actions"; const DEFAULT_LIMIT = 100; const DEFAULT_GROUP_BY_REPO_LIMIT = 10_000; @@ -69,6 +70,7 @@ export type GrepMetadata = { matchCount: number; repoCount: number; repoInfoMap: Record; + inputRepo?: GrepRepoInfo; groupByRepo: boolean; }; @@ -111,7 +113,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada } if (ref) { - query += ` (rev:${ref})`; + query += ` rev:${ref}`; } const response = await search({ @@ -145,6 +147,17 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada }]) ); + const inputRepoResult = repo ? await getRepoInfoByName(repo) : undefined; + if (isServiceError(inputRepoResult)) { + throw new Error(`Repository "${repo}" not found.`); + } + + const inputRepo = inputRepoResult ? { + name: inputRepoResult.name, + displayName: inputRepoResult.displayName ?? inputRepoResult.name, + codeHostType: inputRepoResult.codeHostType, + } : undefined; + const metadata: GrepMetadata = { files, pattern, @@ -152,6 +165,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada matchCount: response.stats.actualMatchCount, repoCount: new Set(files.map((f) => f.repo)).size, repoInfoMap, + inputRepo, groupByRepo, }; @@ -190,37 +204,37 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada output: outputLines.join('\n'), metadata, }; - } - - const outputLines: string[] = [ - `Found ${actualMatches} match${actualMatches !== 1 ? 'es' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`, - ]; - - for (const file of response.files) { - outputLines.push(''); - outputLines.push(`[${file.repository}] ${file.fileName.text}:`); - for (const chunk of file.chunks) { - chunk.content.split('\n').forEach((content, i) => { - if (!content.trim()) { - return; - } - const lineNum = chunk.contentStart.lineNumber + i; - const line = content.length > MAX_LINE_LENGTH - ? content.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX - : content; - outputLines.push(` ${lineNum}: ${line}`); - }); + } else { + const outputLines: string[] = [ + `Found ${actualMatches} match${actualMatches !== 1 ? 'es' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}`, + ]; + + for (const file of response.files) { + outputLines.push(''); + outputLines.push(`[${file.repository}] ${file.fileName.text}:`); + for (const chunk of file.chunks) { + chunk.content.split('\n').forEach((content, i) => { + if (!content.trim()) { + return; + } + const lineNum = chunk.contentStart.lineNumber + i; + const line = content.length > MAX_LINE_LENGTH + ? content.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX + : content; + outputLines.push(` ${lineNum}: ${line}`); + }); + } } + + if (!response.isSearchExhaustive) { + outputLines.push(''); + outputLines.push(TRUNCATION_MESSAGE); + } + + return { + output: outputLines.join('\n'), + metadata, + }; } - - if (!response.isSearchExhaustive) { - outputLines.push(''); - outputLines.push(TRUNCATION_MESSAGE); - } - - return { - output: outputLines.join('\n'), - metadata, - }; }, }; diff --git a/packages/web/src/features/tools/index.ts b/packages/web/src/features/tools/index.ts index 38fae0da6..1a8f04013 100644 --- a/packages/web/src/features/tools/index.ts +++ b/packages/web/src/features/tools/index.ts @@ -2,6 +2,7 @@ export * from './readFile'; export * from './listCommits'; export * from './listRepos'; export * from './grep'; +export * from './glob'; export * from './findSymbolReferences'; export * from './findSymbolDefinitions'; export * from './listTree'; From ca354f87b33365022cd0ed6b815e8332300ae60f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 15:17:25 -0700 Subject: [PATCH 33/43] add param to tool definitions --- packages/web/src/features/chat/agent.ts | 52 +------------ packages/web/src/features/chat/types.ts | 17 +---- packages/web/src/features/mcp/utils.ts | 61 --------------- .../features/tools/findSymbolDefinitions.ts | 9 +++ .../features/tools/findSymbolReferences.ts | 9 +++ packages/web/src/features/tools/glob.ts | 9 +++ packages/web/src/features/tools/grep.ts | 10 +++ packages/web/src/features/tools/listTree.ts | 76 ++++++++++++++++--- packages/web/src/features/tools/readFile.ts | 14 +++- packages/web/src/features/tools/types.ts | 15 ++++ 10 files changed, 132 insertions(+), 140 deletions(-) delete mode 100644 packages/web/src/features/mcp/utils.ts diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 4d6d1f4c5..d7e87d7b1 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -221,57 +221,7 @@ const createAgentStream = async ({ return; } - if (toolName === readFileDefinition.name) { - onWriteSource({ - type: 'file', - repo: output.metadata.repo, - path: output.metadata.path, - revision: output.metadata.revision, - name: output.metadata.path.split('/').pop() ?? output.metadata.path, - }); - } else if (toolName === grepDefinition.name) { - output.metadata.files.forEach((file) => { - onWriteSource({ - type: 'file', - repo: file.repo, - path: file.path, - revision: file.revision, - name: file.path.split('/').pop() ?? file.path, - }); - }); - } else if (toolName === findSymbolDefinitionsDefinition.name || toolName === findSymbolReferencesDefinition.name) { - output.metadata.files.forEach((file) => { - onWriteSource({ - type: 'file', - repo: file.repo, - path: file.fileName, - revision: file.revision, - name: file.fileName.split('/').pop() ?? file.fileName, - }); - }); - } else if (toolName === listTreeDefinition.name) { - output.metadata.entries - .filter((entry) => entry.type === 'blob') - .forEach((entry) => { - onWriteSource({ - type: 'file', - repo: output.metadata.repo, - path: entry.path, - revision: output.metadata.ref, - name: entry.name, - }); - }); - } else if (toolName === globDefinition.name) { - output.metadata.files.forEach((file) => { - onWriteSource({ - type: 'file', - repo: file.repo, - path: file.path, - revision: file.revision, - name: file.path.split('/').pop() ?? file.path, - }); - }); - } + output.sources?.forEach(onWriteSource); }); }, experimental_telemetry: { diff --git a/packages/web/src/features/chat/types.ts b/packages/web/src/features/chat/types.ts index 11fa7f360..04cb60777 100644 --- a/packages/web/src/features/chat/types.ts +++ b/packages/web/src/features/chat/types.ts @@ -5,20 +5,9 @@ import { ReactEditor, RenderElementProps } from "slate-react"; import { z } from "zod"; import { LanguageModel } from "@sourcebot/schemas/v3/index.type"; import { createTools } from "./tools"; - -const fileSourceSchema = z.object({ - type: z.literal('file'), - repo: z.string(), - path: z.string(), - name: z.string(), - revision: z.string(), -}); -export type FileSource = z.infer; - -export const sourceSchema = z.discriminatedUnion('type', [ - fileSourceSchema, -]); -export type Source = z.infer; +export { sourceSchema } from "@/features/tools/types"; +export type { FileSource, Source } from "@/features/tools/types"; +import type { Source } from "@/features/tools/types"; const fileReferenceSchema = z.object({ type: z.literal('file'), diff --git a/packages/web/src/features/mcp/utils.ts b/packages/web/src/features/mcp/utils.ts deleted file mode 100644 index b6de4c71a..000000000 --- a/packages/web/src/features/mcp/utils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FileTreeNode } from "../git"; -import { ServiceError } from "@/lib/serviceError"; -import { ListTreeEntry } from "@/features/tools/listTree"; - -export const isServiceError = (data: unknown): data is ServiceError => { - return typeof data === 'object' && - data !== null && - 'statusCode' in data && - 'errorCode' in data && - 'message' in data; -} - -export class ServiceErrorException extends Error { - constructor(public readonly serviceError: ServiceError) { - super(JSON.stringify(serviceError)); - } -} - -export const normalizeTreePath = (path: string): string => { - const withoutLeading = path.replace(/^\/+/, ''); - return withoutLeading.replace(/\/+$/, ''); -} - -export const joinTreePath = (parentPath: string, name: string): string => { - if (!parentPath) { - return name; - } - return `${parentPath}/${name}`; -} - -export const buildTreeNodeIndex = (root: FileTreeNode): Map => { - const nodeIndex = new Map(); - - const visit = (node: FileTreeNode, currentPath: string) => { - nodeIndex.set(currentPath, node); - for (const child of node.children) { - visit(child, joinTreePath(currentPath, child.name)); - } - }; - - visit(root, ''); - return nodeIndex; -} - -export const sortTreeEntries = (entries: ListTreeEntry[]): ListTreeEntry[] => { - const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); - - return [...entries].sort((a, b) => { - const parentCompare = collator.compare(a.parentPath, b.parentPath); - if (parentCompare !== 0) return parentCompare; - - if (a.type !== b.type) { - return a.type === 'tree' ? -1 : 1; - } - - const nameCompare = collator.compare(a.name, b.name); - if (nameCompare !== 0) return nameCompare; - - return collator.compare(a.path, b.path); - }); -} diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index b52813a1e..943f3d6e1 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -99,9 +99,18 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< } } + const sources = metadata.files.map((file) => ({ + type: 'file' as const, + repo: file.repo, + path: file.fileName, + name: file.fileName.split('/').pop() ?? file.fileName, + revision: file.revision, + })); + return { output: outputLines.join('\n'), metadata, + sources, }; }, }; diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index 3013bd34d..b957d5981 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -109,9 +109,18 @@ export const findSymbolReferencesDefinition: ToolDefinition< } } + const sources = metadata.files.map((file) => ({ + type: 'file' as const, + repo: file.repo, + path: file.fileName, + name: file.fileName.split('/').pop() ?? file.fileName, + revision: file.revision, + })); + return { output: outputLines.join('\n'), metadata, + sources, }; }, }; diff --git a/packages/web/src/features/tools/glob.ts b/packages/web/src/features/tools/glob.ts index 054bfa34d..53a4f00c3 100644 --- a/packages/web/src/features/tools/glob.ts +++ b/packages/web/src/features/tools/glob.ts @@ -184,9 +184,18 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada outputLines.push(TRUNCATION_MESSAGE); } + const sources = files.map((file) => ({ + type: 'file' as const, + repo: file.repo, + path: file.path, + name: file.name, + revision: file.revision, + })); + return { output: outputLines.join('\n'), metadata, + sources, }; }, }; diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index 6627f3f14..d550696e4 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -179,6 +179,14 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada }; } + const sources = files.map((file) => ({ + type: 'file' as const, + repo: file.repo, + path: file.path, + name: file.path.split('/').pop() ?? file.path, + revision: file.revision, + })); + if (groupByRepo) { const repoCounts = new Map(); for (const file of response.files) { @@ -203,6 +211,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada return { output: outputLines.join('\n'), metadata, + sources, }; } else { const outputLines: string[] = [ @@ -234,6 +243,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada return { output: outputLines.join('\n'), metadata, + sources, }; } }, diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index 08ae8f4b1..cf48bea3b 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -1,12 +1,11 @@ -import { z } from "zod"; +import { getRepoInfoByName } from "@/actions"; +import { FileTreeNode, getTree } from "@/features/git"; import { isServiceError } from "@/lib/utils"; -import { getTree } from "@/features/git"; -import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from "@/features/mcp/utils"; -import { ToolDefinition } from "./types"; -import { logger } from "./logger"; -import description from "./listTree.txt"; import { CodeHostType } from "@sourcebot/db"; -import { getRepoInfoByName } from "@/actions"; +import { z } from "zod"; +import description from "./listTree.txt"; +import { logger } from "./logger"; +import { ToolDefinition } from "./types"; const DEFAULT_TREE_DEPTH = 1; const MAX_TREE_DEPTH = 10; @@ -42,7 +41,6 @@ export type ListTreeMetadata = { repoInfo: ListTreeRepoInfo; ref: string; path: string; - entries: ListTreeEntry[]; totalReturned: number; truncated: boolean; }; @@ -152,8 +150,10 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap const sortedEntries = sortTreeEntries(entries); const metadata: ListTreeMetadata = { - repo, repoInfo, ref, path: normalizedPath, - entries: sortedEntries, + repo, + repoInfo, + ref, + path: normalizedPath, totalReturned: sortedEntries.length, truncated, }; @@ -190,6 +190,60 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap outputLines.push(`(truncated — showing first ${normalizedMaxEntries} entries)`); } - return { output: outputLines.join('\n'), metadata }; + const sources = sortedEntries + .filter((entry) => entry.type === 'blob') + .map((entry) => ({ + type: 'file' as const, + repo, + path: entry.path, + name: entry.name, + revision: ref, + })); + + return { output: outputLines.join('\n'), metadata, sources }; }, }; + +const normalizeTreePath = (path: string): string => { + const withoutLeading = path.replace(/^\/+/, ''); + return withoutLeading.replace(/\/+$/, ''); +} + +const joinTreePath = (parentPath: string, name: string): string => { + if (!parentPath) { + return name; + } + return `${parentPath}/${name}`; +} + +const buildTreeNodeIndex = (root: FileTreeNode): Map => { + const nodeIndex = new Map(); + + const visit = (node: FileTreeNode, currentPath: string) => { + nodeIndex.set(currentPath, node); + for (const child of node.children) { + visit(child, joinTreePath(currentPath, child.name)); + } + }; + + visit(root, ''); + return nodeIndex; +} + +const sortTreeEntries = (entries: ListTreeEntry[]): ListTreeEntry[] => { + const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); + + return [...entries].sort((a, b) => { + const parentCompare = collator.compare(a.parentPath, b.parentPath); + if (parentCompare !== 0) return parentCompare; + + if (a.type !== b.type) { + return a.type === 'tree' ? -1 : 1; + } + + const nameCompare = collator.compare(a.name, b.name); + if (nameCompare !== 0) return nameCompare; + + return collator.compare(a.path, b.path); + }); +} diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 0119d59aa..74ce6ad00 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -35,7 +35,6 @@ export type ReadFileMetadata = { path: string; repo: string; repoInfo: ReadFileRepoInfo; - language: string; startLine: number; endLine: number; isTruncated: boolean; @@ -120,13 +119,22 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap path: fileSource.path, repo: fileSource.repo, repoInfo, - language: fileSource.language, startLine, endLine: lastReadLine, isTruncated: truncatedByBytes || truncatedByLines, revision, }; - return { output, metadata }; + return { + output, + metadata, + sources: [{ + type: 'file', + repo: fileSource.repo, + path: fileSource.path, + name: fileSource.path.split('/').pop() ?? fileSource.path, + revision, + }], + }; }, }; diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 437f17b01..9c221a409 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -1,5 +1,19 @@ import { z } from "zod"; +const fileSourceSchema = z.object({ + type: z.literal('file'), + repo: z.string(), + path: z.string(), + name: z.string(), + revision: z.string(), +}); +export type FileSource = z.infer; + +export const sourceSchema = z.discriminatedUnion('type', [ + fileSourceSchema, +]); +export type Source = z.infer; + export interface ToolContext { source?: string; selectedRepos?: string[]; @@ -22,4 +36,5 @@ export interface ToolDefinition< export interface ToolResult> { output: string; metadata: TMetadata; + sources?: Source[]; } From 8c65eeac1fc7bf2f929d2fabfd44da3ff46fabfa Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 20:40:27 -0700 Subject: [PATCH 34/43] fix reference panel overflow issue --- .../chat/components/chatThread/chatThreadListItem.tsx | 5 +++-- .../components/chatThread/referencedFileSourceListItem.tsx | 6 +++--- .../components/chatThread/referencedSourcesListView.tsx | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index b9c9a65b1..f84abe97b 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -384,8 +384,9 @@ const ChatThreadListItemComponent = forwardRef
void; } -const ReferencedFileSourceListItem = ({ +const ReferencedFileSourceListItemComponent = ({ id, code, language, @@ -271,6 +271,6 @@ const ReferencedFileSourceListItem = ({ ) } -export default memo(forwardRef(ReferencedFileSourceListItem), isEqual) as ( +export const ReferencedFileSourceListItem = memo(forwardRef(ReferencedFileSourceListItemComponent), isEqual) as ( props: ReferencedFileSourceListItemProps & { ref?: Ref }, -) => ReturnType; +) => ReturnType; diff --git a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx index 88564228d..6bf49427f 100644 --- a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -11,7 +11,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import scrollIntoView from 'scroll-into-view-if-needed'; import { FileReference, FileSource, Reference } from "../../types"; import { tryResolveFileReference } from '../../utils'; -import ReferencedFileSourceListItem from "./referencedFileSourceListItem"; +import { ReferencedFileSourceListItem } from "./referencedFileSourceListItem"; import isEqual from 'fast-deep-equal/react'; interface ReferencedSourcesListViewProps { From d978e6b44b9c7127a5a20c153f504f4559e565cb Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 21:28:59 -0700 Subject: [PATCH 35/43] remove search scope selector constraint --- .../[owner]/[repo]/components/landingPage.tsx | 1 - .../chat/components/landingPageChatBox.tsx | 1 - packages/web/src/features/chat/agent.ts | 5 --- .../chat/components/chatBox/chatBox.tsx | 39 ++----------------- .../chatBox/searchScopeSelector.tsx | 19 +-------- .../chat/components/chatThread/chatThread.tsx | 1 - 6 files changed, 5 insertions(+), 61 deletions(-) diff --git a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx index 8a6aabf02..511057cc3 100644 --- a/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx +++ b/packages/web/src/app/[domain]/askgh/[owner]/[repo]/components/landingPage.tsx @@ -77,7 +77,6 @@ export const LandingPage = ({ languageModels={languageModels} selectedSearchScopes={selectedSearchScopes} searchContexts={[]} - onContextSelectorOpenChanged={setIsContextSelectorOpen} isDisabled={isChatBoxDisabled} /> diff --git a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx index f7c0742ab..8e43730ec 100644 --- a/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx +++ b/packages/web/src/app/[domain]/chat/components/landingPageChatBox.tsx @@ -42,7 +42,6 @@ export const LandingPageChatBox = ({ languageModels={languageModels} selectedSearchScopes={selectedSearchScopes} searchContexts={searchContexts} - onContextSelectorOpenChanged={setIsContextSelectorOpen} isDisabled={isChatBoxDisabled} /> diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index d7e87d7b1..50fff96a2 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -15,14 +15,9 @@ import { import { randomUUID } from "crypto"; import _dedent from "dedent"; import { ANSWER_TAG, FILE_REFERENCE_PREFIX } from "./constants"; -import { findSymbolReferencesDefinition } from "@/features/tools/findSymbolReferences"; -import { findSymbolDefinitionsDefinition } from "@/features/tools/findSymbolDefinitions"; -import { readFileDefinition } from "@/features/tools/readFile"; -import { grepDefinition } from "@/features/tools/grep"; import { Source } from "./types"; import { addLineNumbers, fileReferenceToString } from "./utils"; import { createTools } from "./tools"; -import { globDefinition, listTreeDefinition } from "../tools"; const dedent = _dedent.withOptions({ alignValues: true }); diff --git a/packages/web/src/features/chat/components/chatBox/chatBox.tsx b/packages/web/src/features/chat/components/chatBox/chatBox.tsx index 02b4c3e96..32602c75c 100644 --- a/packages/web/src/features/chat/components/chatBox/chatBox.tsx +++ b/packages/web/src/features/chat/components/chatBox/chatBox.tsx @@ -7,7 +7,7 @@ import { CustomEditor, LanguageModelInfo, MentionElement, RenderElementPropsFor, import { insertMention, slateContentToString } from "@/features/chat/utils"; import { cn, IS_MAC } from "@/lib/utils"; import { computePosition, flip, offset, shift, VirtualElement } from "@floating-ui/react"; -import { ArrowUp, Loader2, StopCircleIcon, TriangleAlertIcon } from "lucide-react"; +import { ArrowUp, Loader2, StopCircleIcon } from "lucide-react"; import { Fragment, KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { Descendant, insertText } from "slate"; @@ -32,7 +32,6 @@ interface ChatBoxProps { languageModels: LanguageModelInfo[]; selectedSearchScopes: SearchScope[]; searchContexts: SearchContextQuery[]; - onContextSelectorOpenChanged: (isOpen: boolean) => void; } const ChatBoxComponent = ({ @@ -46,7 +45,6 @@ const ChatBoxComponent = ({ languageModels, selectedSearchScopes, searchContexts, - onContextSelectorOpenChanged, }: ChatBoxProps) => { const suggestionsBoxRef = useRef(null); const [index, setIndex] = useState(0); @@ -106,7 +104,7 @@ const ChatBoxComponent = ({ const { isSubmitDisabled, isSubmitDisabledReason } = useMemo((): { isSubmitDisabled: true, - isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-repos-selected" | "no-language-model-selected" + isSubmitDisabledReason: "empty" | "redirecting" | "generating" | "no-language-model-selected" } | { isSubmitDisabled: false, isSubmitDisabledReason: undefined, @@ -132,13 +130,6 @@ const ChatBoxComponent = ({ } } - if (selectedSearchScopes.length === 0) { - return { - isSubmitDisabled: true, - isSubmitDisabledReason: "no-repos-selected", - } - } - if (selectedLanguageModel === undefined) { return { @@ -152,24 +143,10 @@ const ChatBoxComponent = ({ isSubmitDisabledReason: undefined, } - }, [ - editor.children, - isRedirecting, - isGenerating, - selectedSearchScopes.length, - selectedLanguageModel, - ]) + }, [editor.children, isRedirecting, isGenerating, selectedLanguageModel]) const onSubmit = useCallback(() => { if (isSubmitDisabled) { - if (isSubmitDisabledReason === "no-repos-selected") { - toast({ - description: "⚠️ You must select at least one search scope", - variant: "destructive", - }); - onContextSelectorOpenChanged(true); - } - if (isSubmitDisabledReason === "no-language-model-selected") { toast({ description: "⚠️ You must select a language model", @@ -181,7 +158,7 @@ const ChatBoxComponent = ({ } _onSubmit(editor.children, editor); - }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast, onContextSelectorOpenChanged]); + }, [_onSubmit, editor, isSubmitDisabled, isSubmitDisabledReason, toast]); const onInsertSuggestion = useCallback((suggestion: Suggestion) => { switch (suggestion.type) { @@ -351,14 +328,6 @@ const ChatBoxComponent = ({
- {(isSubmitDisabled && isSubmitDisabledReason === "no-repos-selected") && ( - -
- - You must select at least one search scope -
-
- )} )}
diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx index 1fb44d79f..50c9b3940 100644 --- a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx @@ -103,15 +103,6 @@ export const SearchScopeSelector = forwardRef< }) }, [onSelectedSearchScopesChange]); - const handleSelectAll = useCallback(() => { - onSelectedSearchScopesChange(allSearchScopeItems); - requestAnimationFrame(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = 0; - } - }); - }, [onSelectedSearchScopesChange, allSearchScopeItems]); - const handleTogglePopover = useCallback(() => { onOpenChanged(!isOpen); }, [onOpenChanged, isOpen]); @@ -241,7 +232,7 @@ export const SearchScopeSelector = forwardRef< className={cn("text-sm text-muted-foreground mx-1 font-medium")} > { - selectedSearchScopes.length === 0 ? `Search scopes` : + selectedSearchScopes.length === 0 ? `All repos` : selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name : `${selectedSearchScopes.length} selected` } @@ -279,14 +270,6 @@ export const SearchScopeSelector = forwardRef<
) : (
- {!searchQuery && ( -
- Select all -
- )}
From e5f5d224777250f601aa380059a94f3b52da53e7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 21:31:46 -0700 Subject: [PATCH 36/43] s --- CHANGELOG.md | 1 + .../features/chat/components/chatBox/searchScopeSelector.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f66a58da6..5bc9e8904 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Changed language detection to resolve file extensions with multiple language resolutions (e.g., .md) to the most common resolution. [#1026](https://github.com/sourcebot-dev/sourcebot/pull/1026) - Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Changed the ask search scope selector to allow submitting questions with no search scope selected. When no selection is made, the agent will be able to search over all repos the user has access to. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) ## [4.15.11] - 2026-03-20 diff --git a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx index 50c9b3940..78895c266 100644 --- a/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx +++ b/packages/web/src/features/chat/components/chatBox/searchScopeSelector.tsx @@ -232,7 +232,7 @@ export const SearchScopeSelector = forwardRef< className={cn("text-sm text-muted-foreground mx-1 font-medium")} > { - selectedSearchScopes.length === 0 ? `All repos` : + selectedSearchScopes.length === 0 ? `All repositories` : selectedSearchScopes.length === 1 ? selectedSearchScopes[0].name : `${selectedSearchScopes.length} selected` } From ff8ca8a1a3050317e24b5d23c4ab2b54055bf3b8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 21:47:57 -0700 Subject: [PATCH 37/43] fix homepage scrolling issue --- packages/web/src/app/[domain]/chat/page.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/web/src/app/[domain]/chat/page.tsx b/packages/web/src/app/[domain]/chat/page.tsx index 75cdc220a..f7c98adbb 100644 --- a/packages/web/src/app/[domain]/chat/page.tsx +++ b/packages/web/src/app/[domain]/chat/page.tsx @@ -73,7 +73,7 @@ export default async function Page(props: PageProps) { })() : undefined; return ( -
+
@@ -88,12 +88,13 @@ export default async function Page(props: PageProps) { isCollapsedInitially={true} /> - -
+
Date: Sun, 22 Mar 2026 22:02:17 -0700 Subject: [PATCH 38/43] s --- packages/web/src/features/chat/agent.ts | 4 ++-- .../components/chatThread/chatThreadListItem.tsx | 2 +- .../chatThread/referencedSourcesListView.tsx | 6 +++--- .../chatThread/tools/readFileToolComponent.tsx | 2 +- packages/web/src/features/chat/utils.ts | 2 +- packages/web/src/features/mcp/server.ts | 2 ++ .../src/features/tools/findSymbolDefinitions.ts | 6 +++--- .../src/features/tools/findSymbolReferences.ts | 6 +++--- packages/web/src/features/tools/glob.ts | 6 +++--- packages/web/src/features/tools/grep.ts | 6 +++--- packages/web/src/features/tools/listTree.ts | 7 +++---- packages/web/src/features/tools/readFile.ts | 15 +++++++-------- packages/web/src/features/tools/types.ts | 2 +- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 50fff96a2..0798fa09e 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -170,7 +170,7 @@ const createAgentStream = async ({ const fileSource = await getFileSource({ path: source.path, repo: source.repo, - ref: source.revision, + ref: source.ref, }, { source: 'sourcebot-ask-agent' }); if (isServiceError(fileSource)) { @@ -183,7 +183,7 @@ const createAgentStream = async ({ source: fileSource.source, repo: fileSource.repo, language: fileSource.language, - revision: source.revision, + revision: source.ref, }; })) ).filter((source) => source !== undefined); diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index f84abe97b..fad408f24 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -299,7 +299,7 @@ const ChatThreadListItemComponent = forwardRef t?.path === file?.path && t?.repo === file?.repo - && t?.revision === file?.revision + && t?.ref === file?.ref ) ); }, [references, sources]); diff --git a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx index 6bf49427f..7f41ca56a 100644 --- a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -74,11 +74,11 @@ const ReferencedSourcesListViewComponent = ({ const fileSourceQueries = useQueries({ queries: sources.map((file) => ({ - queryKey: ['fileSource', file.path, file.repo, file.revision], + queryKey: ['fileSource', file.path, file.repo, file.ref], queryFn: () => unwrapServiceError(getFileSource({ path: file.path, repo: file.repo, - ref: file.revision, + ref: file.ref, })), staleTime: Infinity, })), @@ -233,7 +233,7 @@ const ReferencedSourcesListViewComponent = ({ id={fileId} code={fileData.source} language={fileData.language} - revision={fileSource.revision} + revision={fileSource.ref} repoName={fileSource.repo} repoCodeHostType={fileData.repoCodeHostType} repoDisplayName={fileData.repoDisplayName} diff --git a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx index 44e207683..1adfe23d7 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/readFileToolComponent.tsx @@ -12,7 +12,7 @@ export const ReadFileToolComponent = ({ metadata }: ToolResult const fileName = metadata.path.split('/').pop() ?? metadata.path; const href = getBrowsePath({ repoName: metadata.repo, - revisionName: metadata.revision, + revisionName: metadata.ref, path: metadata.path, pathType: 'blob', domain: SINGLE_TENANT_ORG_DOMAIN, diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index a1c9fd9f4..f878a6b34 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -187,7 +187,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedS path: mention.path, repo: mention.repo, name: mention.name, - revision: mention.revision, + ref: mention.revision, } return fileSource; } diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts index 390334ca2..1de4efee4 100644 --- a/packages/web/src/features/mcp/server.ts +++ b/packages/web/src/features/mcp/server.ts @@ -19,6 +19,7 @@ import { registerMcpTool, grepDefinition, ToolContext, + globDefinition, } from '../tools'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -37,6 +38,7 @@ export async function createMcpServer(): Promise { } registerMcpTool(server, grepDefinition, toolContext); + registerMcpTool(server, globDefinition, toolContext); registerMcpTool(server, listCommitsDefinition, toolContext); registerMcpTool(server, listReposDefinition, toolContext); registerMcpTool(server, readFileDefinition, toolContext); diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 943f3d6e1..155da5729 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import description from "./findSymbolDefinitions.txt"; import { FindSymbolFile, FindSymbolRepoInfo } from "./findSymbolReferences"; import { logger } from "./logger"; -import { ToolDefinition } from "./types"; +import { Source, ToolDefinition } from "./types"; const MAX_LINE_LENGTH = 2000; @@ -99,12 +99,12 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< } } - const sources = metadata.files.map((file) => ({ + const sources: Source[] = metadata.files.map((file) => ({ type: 'file' as const, repo: file.repo, path: file.fileName, name: file.fileName.split('/').pop() ?? file.fileName, - revision: file.revision, + ref: file.revision, })); return { diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index b957d5981..82dc4e132 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { isServiceError } from "@/lib/utils"; import { findSearchBasedSymbolReferences } from "@/features/codeNav/api"; -import { ToolDefinition } from "./types"; +import { Source, ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./findSymbolReferences.txt"; import { getRepoInfoByName } from "@/actions"; @@ -109,12 +109,12 @@ export const findSymbolReferencesDefinition: ToolDefinition< } } - const sources = metadata.files.map((file) => ({ + const sources: Source[] = metadata.files.map((file) => ({ type: 'file' as const, repo: file.repo, path: file.fileName, name: file.fileName.split('/').pop() ?? file.fileName, - revision: file.revision, + ref: file.revision, })); return { diff --git a/packages/web/src/features/tools/glob.ts b/packages/web/src/features/tools/glob.ts index 53a4f00c3..93813df77 100644 --- a/packages/web/src/features/tools/glob.ts +++ b/packages/web/src/features/tools/glob.ts @@ -3,7 +3,7 @@ import globToRegexp from "glob-to-regexp"; import { isServiceError } from "@/lib/utils"; import { search } from "@/features/search"; import escapeStringRegexp from "escape-string-regexp"; -import { ToolDefinition } from "./types"; +import { Source, ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./glob.txt"; import { CodeHostType } from "@sourcebot/db"; @@ -184,12 +184,12 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada outputLines.push(TRUNCATION_MESSAGE); } - const sources = files.map((file) => ({ + const sources: Source[] = files.map((file) => ({ type: 'file' as const, repo: file.repo, path: file.path, name: file.name, - revision: file.revision, + ref: file.revision, })); return { diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index d550696e4..4b0ecf896 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -3,7 +3,7 @@ import globToRegexp from "glob-to-regexp"; import { isServiceError } from "@/lib/utils"; import { search } from "@/features/search"; import escapeStringRegexp from "escape-string-regexp"; -import { ToolDefinition } from "./types"; +import { Source, ToolDefinition } from "./types"; import { logger } from "./logger"; import description from "./grep.txt"; import { CodeHostType } from "@sourcebot/db"; @@ -179,12 +179,12 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada }; } - const sources = files.map((file) => ({ + const sources: Source[] = files.map((file) => ({ type: 'file' as const, repo: file.repo, path: file.path, name: file.path.split('/').pop() ?? file.path, - revision: file.revision, + ref: file.revision, })); if (groupByRepo) { diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index cf48bea3b..f398c41c0 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -5,7 +5,7 @@ import { CodeHostType } from "@sourcebot/db"; import { z } from "zod"; import description from "./listTree.txt"; import { logger } from "./logger"; -import { ToolDefinition } from "./types"; +import { Source, ToolDefinition } from "./types"; const DEFAULT_TREE_DEPTH = 1; const MAX_TREE_DEPTH = 10; @@ -74,7 +74,6 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap repoInfo, ref, path: normalizedPath, - entries: [], totalReturned: 0, truncated: false, }; @@ -190,14 +189,14 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap outputLines.push(`(truncated — showing first ${normalizedMaxEntries} entries)`); } - const sources = sortedEntries + const sources: Source[] = sortedEntries .filter((entry) => entry.type === 'blob') .map((entry) => ({ type: 'file' as const, repo, path: entry.path, name: entry.name, - revision: ref, + ref, })); return { output: outputLines.join('\n'), metadata, sources }; diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 74ce6ad00..2455e3c92 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -17,6 +17,7 @@ const MAX_BYTES_LABEL = `${MAX_BYTES / 1024}KB`; const readFileShape = { path: z.string().describe("The path to the file"), repo: z.string().describe("The repository to read the file from"), + ref: z.string().describe("Commit SHA, branch or tag name to read the file from. If not provided, uses the default branch.").optional().default('HEAD'), offset: z.number().int().positive() .optional() .describe("Line number to start reading from (1-indexed). Omit to start from the beginning."), @@ -38,7 +39,7 @@ export type ReadFileMetadata = { startLine: number; endLine: number; isTruncated: boolean; - revision: string; + ref: string; }; export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShape, ReadFileMetadata> = { @@ -48,15 +49,13 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap isIdempotent: true, description, inputSchema: z.object(readFileShape), - execute: async ({ path, repo, offset, limit }, context) => { - logger.debug('read_file', { path, repo, offset, limit }); - // @todo: make revision configurable. - const revision = "HEAD"; + execute: async ({ path, repo, ref = 'HEAD', offset, limit }, context) => { + logger.debug('read_file', { path, repo, ref, offset, limit }); const fileSource = await getFileSource({ path, repo, - ref: revision, + ref, }, { source: context.source }); if (isServiceError(fileSource)) { @@ -122,7 +121,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap startLine, endLine: lastReadLine, isTruncated: truncatedByBytes || truncatedByLines, - revision, + ref, }; return { @@ -133,7 +132,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap repo: fileSource.repo, path: fileSource.path, name: fileSource.path.split('/').pop() ?? fileSource.path, - revision, + ref, }], }; }, diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 9c221a409..529aec09f 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -5,7 +5,7 @@ const fileSourceSchema = z.object({ repo: z.string(), path: z.string(), name: z.string(), - revision: z.string(), + ref: z.string(), }); export type FileSource = z.infer; From 80f803b3fe80746bf1b392bd639f13bf75980084 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 22:06:41 -0700 Subject: [PATCH 39/43] back-compat: revert change from revision to ref --- packages/web/src/features/chat/agent.ts | 4 ++-- .../chat/components/chatThread/chatThreadListItem.tsx | 2 +- .../components/chatThread/referencedSourcesListView.tsx | 6 +++--- packages/web/src/features/chat/utils.ts | 2 +- packages/web/src/features/tools/findSymbolDefinitions.ts | 2 +- packages/web/src/features/tools/findSymbolReferences.ts | 2 +- packages/web/src/features/tools/glob.ts | 2 +- packages/web/src/features/tools/grep.ts | 2 +- packages/web/src/features/tools/listTree.ts | 2 +- packages/web/src/features/tools/readFile.ts | 2 +- packages/web/src/features/tools/types.ts | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts index 0798fa09e..50fff96a2 100644 --- a/packages/web/src/features/chat/agent.ts +++ b/packages/web/src/features/chat/agent.ts @@ -170,7 +170,7 @@ const createAgentStream = async ({ const fileSource = await getFileSource({ path: source.path, repo: source.repo, - ref: source.ref, + ref: source.revision, }, { source: 'sourcebot-ask-agent' }); if (isServiceError(fileSource)) { @@ -183,7 +183,7 @@ const createAgentStream = async ({ source: fileSource.source, repo: fileSource.repo, language: fileSource.language, - revision: source.ref, + revision: source.revision, }; })) ).filter((source) => source !== undefined); diff --git a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx index fad408f24..f84abe97b 100644 --- a/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx +++ b/packages/web/src/features/chat/components/chatThread/chatThreadListItem.tsx @@ -299,7 +299,7 @@ const ChatThreadListItemComponent = forwardRef t?.path === file?.path && t?.repo === file?.repo - && t?.ref === file?.ref + && t?.revision === file?.revision ) ); }, [references, sources]); diff --git a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx index 7f41ca56a..6bf49427f 100644 --- a/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx +++ b/packages/web/src/features/chat/components/chatThread/referencedSourcesListView.tsx @@ -74,11 +74,11 @@ const ReferencedSourcesListViewComponent = ({ const fileSourceQueries = useQueries({ queries: sources.map((file) => ({ - queryKey: ['fileSource', file.path, file.repo, file.ref], + queryKey: ['fileSource', file.path, file.repo, file.revision], queryFn: () => unwrapServiceError(getFileSource({ path: file.path, repo: file.repo, - ref: file.ref, + ref: file.revision, })), staleTime: Infinity, })), @@ -233,7 +233,7 @@ const ReferencedSourcesListViewComponent = ({ id={fileId} code={fileData.source} language={fileData.language} - revision={fileSource.ref} + revision={fileSource.revision} repoName={fileSource.repo} repoCodeHostType={fileData.repoCodeHostType} repoDisplayName={fileData.repoDisplayName} diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index f878a6b34..a1c9fd9f4 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -187,7 +187,7 @@ export const createUIMessage = (text: string, mentions: MentionData[], selectedS path: mention.path, repo: mention.repo, name: mention.name, - ref: mention.revision, + revision: mention.revision, } return fileSource; } diff --git a/packages/web/src/features/tools/findSymbolDefinitions.ts b/packages/web/src/features/tools/findSymbolDefinitions.ts index 155da5729..b77176163 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.ts +++ b/packages/web/src/features/tools/findSymbolDefinitions.ts @@ -104,7 +104,7 @@ export const findSymbolDefinitionsDefinition: ToolDefinition< repo: file.repo, path: file.fileName, name: file.fileName.split('/').pop() ?? file.fileName, - ref: file.revision, + revision: file.revision, })); return { diff --git a/packages/web/src/features/tools/findSymbolReferences.ts b/packages/web/src/features/tools/findSymbolReferences.ts index 82dc4e132..0b04c9f91 100644 --- a/packages/web/src/features/tools/findSymbolReferences.ts +++ b/packages/web/src/features/tools/findSymbolReferences.ts @@ -114,7 +114,7 @@ export const findSymbolReferencesDefinition: ToolDefinition< repo: file.repo, path: file.fileName, name: file.fileName.split('/').pop() ?? file.fileName, - ref: file.revision, + revision: file.revision, })); return { diff --git a/packages/web/src/features/tools/glob.ts b/packages/web/src/features/tools/glob.ts index 93813df77..6e855a32c 100644 --- a/packages/web/src/features/tools/glob.ts +++ b/packages/web/src/features/tools/glob.ts @@ -189,7 +189,7 @@ export const globDefinition: ToolDefinition<'glob', typeof globShape, GlobMetada repo: file.repo, path: file.path, name: file.name, - ref: file.revision, + revision: file.revision, })); return { diff --git a/packages/web/src/features/tools/grep.ts b/packages/web/src/features/tools/grep.ts index 4b0ecf896..a3c0e7822 100644 --- a/packages/web/src/features/tools/grep.ts +++ b/packages/web/src/features/tools/grep.ts @@ -184,7 +184,7 @@ export const grepDefinition: ToolDefinition<'grep', typeof grepShape, GrepMetada repo: file.repo, path: file.path, name: file.path.split('/').pop() ?? file.path, - ref: file.revision, + revision: file.revision, })); if (groupByRepo) { diff --git a/packages/web/src/features/tools/listTree.ts b/packages/web/src/features/tools/listTree.ts index f398c41c0..6e37ead92 100644 --- a/packages/web/src/features/tools/listTree.ts +++ b/packages/web/src/features/tools/listTree.ts @@ -196,7 +196,7 @@ export const listTreeDefinition: ToolDefinition<'list_tree', typeof listTreeShap repo, path: entry.path, name: entry.name, - ref, + revision: ref, })); return { output: outputLines.join('\n'), metadata, sources }; diff --git a/packages/web/src/features/tools/readFile.ts b/packages/web/src/features/tools/readFile.ts index 2455e3c92..a24b83816 100644 --- a/packages/web/src/features/tools/readFile.ts +++ b/packages/web/src/features/tools/readFile.ts @@ -132,7 +132,7 @@ export const readFileDefinition: ToolDefinition<"read_file", typeof readFileShap repo: fileSource.repo, path: fileSource.path, name: fileSource.path.split('/').pop() ?? fileSource.path, - ref, + revision: ref, }], }; }, diff --git a/packages/web/src/features/tools/types.ts b/packages/web/src/features/tools/types.ts index 529aec09f..9c221a409 100644 --- a/packages/web/src/features/tools/types.ts +++ b/packages/web/src/features/tools/types.ts @@ -5,7 +5,7 @@ const fileSourceSchema = z.object({ repo: z.string(), path: z.string(), name: z.string(), - ref: z.string(), + revision: z.string(), }); export type FileSource = z.infer; From 199bab27d5bc0631a67f34534ce4598b2b5d7232 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 22:11:41 -0700 Subject: [PATCH 40/43] fix tests --- packages/web/src/features/chat/utils.test.ts | 4 +- .../src/features/git/listCommitsApi.test.ts | 96 +++++++++---------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/packages/web/src/features/chat/utils.test.ts b/packages/web/src/features/chat/utils.test.ts index 5c0932eb3..26359d2a9 100644 --- a/packages/web/src/features/chat/utils.test.ts +++ b/packages/web/src/features/chat/utils.test.ts @@ -167,7 +167,7 @@ test('getAnswerPartFromAssistantMessage returns text part when it starts with AN expect(result).toEqual({ type: 'text', - text: `${ANSWER_TAG}This is the answer to your question.` + text: `This is the answer to your question.` }); }); @@ -190,7 +190,7 @@ test('getAnswerPartFromAssistantMessage returns text part when it starts with AN expect(result).toEqual({ type: 'text', - text: `${ANSWER_TAG}This is the answer to your question.` + text: `This is the answer to your question.` }); }); diff --git a/packages/web/src/features/git/listCommitsApi.test.ts b/packages/web/src/features/git/listCommitsApi.test.ts index 887cd5196..e0e9ffa93 100644 --- a/packages/web/src/features/git/listCommitsApi.test.ts +++ b/packages/web/src/features/git/listCommitsApi.test.ts @@ -191,10 +191,10 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - '--since': '2024-01-01', - '--until': '2024-12-31', - }) + expect.arrayContaining([ + '--since=2024-01-01', + '--until=2024-12-31', + ]) ); }); }); @@ -211,10 +211,10 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - maxCount: 50, - HEAD: null, - }) + expect.arrayContaining([ + '--max-count=50', + 'HEAD', + ]) ); }); @@ -225,10 +225,10 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - maxCount: 100, - HEAD: null, - }) + expect.arrayContaining([ + '--max-count=100', + 'HEAD', + ]) ); }); @@ -239,10 +239,10 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - '--since': '30 days ago', - HEAD: null, - }) + expect.arrayContaining([ + '--since=30 days ago', + 'HEAD', + ]) ); }); @@ -253,10 +253,10 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - '--until': 'yesterday', - HEAD: null, - }) + expect.arrayContaining([ + '--until=yesterday', + 'HEAD', + ]) ); }); @@ -267,11 +267,11 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - '--author': 'john@example.com', - '--regexp-ignore-case': null, - HEAD: null, - }) + expect.arrayContaining([ + '--author=john@example.com', + '--regexp-ignore-case', + 'HEAD', + ]) ); }); @@ -282,11 +282,11 @@ describe('searchCommits', () => { }); expect(mockGitLog).toHaveBeenCalledWith( - expect.objectContaining({ - '--grep': 'fix bug', - '--regexp-ignore-case': null, - HEAD: null, - }) + expect.arrayContaining([ + '--grep=fix bug', + '--regexp-ignore-case', + 'HEAD', + ]) ); }); @@ -300,15 +300,15 @@ describe('searchCommits', () => { maxCount: 25, }); - expect(mockGitLog).toHaveBeenCalledWith({ - maxCount: 25, - HEAD: null, - '--since': '2024-01-01', - '--until': '2024-12-31', - '--author': 'jane@example.com', - '--regexp-ignore-case': null, - '--grep': 'feature', - }); + expect(mockGitLog).toHaveBeenCalledWith([ + '--max-count=25', + '--since=2024-01-01', + '--until=2024-12-31', + '--author=jane@example.com', + '--regexp-ignore-case', + '--grep=feature', + 'HEAD', + ]); }); }); @@ -478,15 +478,15 @@ describe('searchCommits', () => { }); expect(result).toEqual({ commits: mockCommits, totalCount: 1 }); - expect(mockGitLog).toHaveBeenCalledWith({ - maxCount: 20, - HEAD: null, - '--since': '30 days ago', - '--until': 'yesterday', - '--author': 'security', - '--regexp-ignore-case': null, - '--grep': 'authentication', - }); + expect(mockGitLog).toHaveBeenCalledWith([ + '--max-count=20', + '--since=30 days ago', + '--until=yesterday', + '--author=security', + '--regexp-ignore-case', + '--grep=authentication', + 'HEAD', + ]); }); it('should handle repository not found in database', async () => { From f057b1194a4c96cc04890ab1604fc8842a52d4c3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 22:36:46 -0700 Subject: [PATCH 41/43] update mcp docs --- .env.development | 2 +- docs/docs/features/mcp-server.mdx | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.env.development b/.env.development index b86e25e8a..84670f852 100644 --- a/.env.development +++ b/.env.development @@ -77,4 +77,4 @@ SOURCEBOT_TELEMETRY_DISABLED=true # Disables telemetry collection NODE_ENV=development # SOURCEBOT_TENANCY_MODE=single -DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true \ No newline at end of file +DEBUG_WRITE_CHAT_MESSAGES_TO_FILE=true diff --git a/docs/docs/features/mcp-server.mdx b/docs/docs/features/mcp-server.mdx index b1ce9c275..50cc4fe02 100644 --- a/docs/docs/features/mcp-server.mdx +++ b/docs/docs/features/mcp-server.mdx @@ -375,6 +375,39 @@ Parameters: | `page` | no | Page number for pagination (min 1, default: 1). | | `perPage` | no | Results per page for pagination (min 1, max 100, default: 50). | +### `glob` + +Finds files whose paths match a glob pattern across repositories (e.g. `**/*.ts`, `src/**/*.test.{ts,tsx}`). Results are grouped by repository. + +Parameters: +| Name | Required | Description | +|:----------|:---------|:------------| +| `pattern` | yes | Glob pattern to match file paths against (e.g. `**/*.ts`, `src/**/*.test.{ts,tsx}`). | +| `path` | no | Restrict results to files under this subdirectory. | +| `repo` | no | Repository name to search in. If not provided, searches all repositories. Use the full name including host (e.g. `github.com/org/repo`). | +| `ref` | no | Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch. | +| `limit` | no | Maximum number of files to return (default: 100). | + +### `find_symbol_definitions` + +Finds where a symbol (function, class, variable, etc.) is defined in a repository. + +Parameters: +| Name | Required | Description | +|:---------|:---------|:------------| +| `symbol` | yes | The symbol name to find definitions of. | +| `repo` | yes | Repository name to scope the search to. Use the full name including host (e.g. `github.com/org/repo`). | + +### `find_symbol_references` + +Finds all usages of a symbol (function, class, variable, etc.) across a repository. + +Parameters: +| Name | Required | Description | +|:---------|:---------|:------------| +| `symbol` | yes | The symbol name to find references to. | +| `repo` | yes | Repository name to scope the search to. Use the full name including host (e.g. `github.com/org/repo`). | + ### `list_language_models` Lists the available language models configured on the Sourcebot instance. Use this to discover which models can be specified when calling `ask_codebase`. From c27c9a7fa3dff7cb8a91cdb20c02a164d832b4b7 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 22:48:57 -0700 Subject: [PATCH 42/43] changelog --- CHANGELOG.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bc9e8904..6e3b5a892 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Changed language detection to resolve file extensions with multiple language resolutions (e.g., .md) to the most common resolution. [#1026](https://github.com/sourcebot-dev/sourcebot/pull/1026) +- Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Changed the ask search scope selector to allow submitting questions with no search scope selected. When no selection is made, the agent will be able to search over all repos the user has access to. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Renamed the `search_code` tool to `grep` for ask and mcp. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) + ### Added -- Added `find_symbol_definitions`, and `find_symbol_references` tools to the MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Added `glob`, `find_symbol_definitions`, and `find_symbol_references` tools to the ask agent and MCP server. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added `list_tree` tool to the ask agent. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added input & output token breakdown in ask details card. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - Added `path` parameter to the `/api/commits` api to allow filtering commits by paths. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) ### Fixed - Fixed issue where ask responses would sometimes appear in the details panel while generating. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) - -### Changed -- Changed language detection to resolve file extensions with multiple language resolutions (e.g., .md) to the most common resolution. [#1026](https://github.com/sourcebot-dev/sourcebot/pull/1026) -- Changed the `webUrl` property of the `/api/repos` api to return a URL rather than just a path. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) -- Changed the ask search scope selector to allow submitting questions with no search scope selected. When no selection is made, the agent will be able to search over all repos the user has access to. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Fixed reference panel overflow issue in the ask UI. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) +- Fixed homepage scrolling issue in the ask UI. [#1014](https://github.com/sourcebot-dev/sourcebot/pull/1014) ## [4.15.11] - 2026-03-20 From 3c79c069849d618f2ea1858acec8d895a7c43b33 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sun, 22 Mar 2026 23:24:21 -0700 Subject: [PATCH 43/43] improve tool descriptions --- .../web/src/features/tools/findSymbolDefinitions.txt | 9 ++++++++- .../web/src/features/tools/findSymbolReferences.txt | 9 ++++++++- packages/web/src/features/tools/listCommits.txt | 11 ++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/web/src/features/tools/findSymbolDefinitions.txt b/packages/web/src/features/tools/findSymbolDefinitions.txt index 0ba87ff08..2588cd31a 100644 --- a/packages/web/src/features/tools/findSymbolDefinitions.txt +++ b/packages/web/src/features/tools/findSymbolDefinitions.txt @@ -1 +1,8 @@ -Finds definitions of a symbol in the codebase. +Finds where a symbol (function, class, variable, method, type, etc.) is defined in a repository. + +Usage: +- Use this to answer "where is X defined/implemented?" questions. +- Results include file paths and line numbers for each definition site, grouped by file. +- Symbol matching is exact — use the precise identifier name, not a description (e.g., "UserService" not "user service class"). +- Prefer this over `grep` for symbol lookups: it understands symbol boundaries and won't match partial strings or comments. +- If the repository name is not known, use `list_repos` first to discover the correct name. diff --git a/packages/web/src/features/tools/findSymbolReferences.txt b/packages/web/src/features/tools/findSymbolReferences.txt index e35a2c87b..d1076caaf 100644 --- a/packages/web/src/features/tools/findSymbolReferences.txt +++ b/packages/web/src/features/tools/findSymbolReferences.txt @@ -1 +1,8 @@ -Finds references to a symbol in the codebase. +Finds all usages of a symbol (function, class, variable, method, type, etc.) across a repository. + +Usage: +- Use this to answer "where is X used/called?" questions. +- Results include file paths and line numbers for each reference, grouped by file. +- Symbol matching is exact — use the precise identifier name, not a description (e.g., "getUserById" not "get user by id"). +- Prefer this over `grep` for symbol lookups: it understands symbol boundaries and won't match partial strings or comments. +- If the repository name is not known, use `list_repos` first to discover the correct name. diff --git a/packages/web/src/features/tools/listCommits.txt b/packages/web/src/features/tools/listCommits.txt index b82afe97a..a6b95c1c6 100644 --- a/packages/web/src/features/tools/listCommits.txt +++ b/packages/web/src/features/tools/listCommits.txt @@ -1 +1,10 @@ -Lists commits in a repository with optional filtering by date range, author, and commit message. +Lists commits in a repository with optional filtering by message, date range, author, branch, and path. + +Usage: +- Use `query` to search commit messages (case-insensitive). Supports substrings and simple patterns. +- Use `since`/`until` for date filtering. Both ISO 8601 dates (e.g., "2024-01-01") and relative formats (e.g., "30 days ago", "last week", "yesterday") are supported. +- Use `author` to filter by author name or email (case-insensitive, partial match). +- Use `path` to limit results to commits that touched a specific file or directory. +- Use `ref` to list commits on a specific branch, tag, or from a specific commit SHA. Defaults to the default branch. +- Use `page`/`perPage` to paginate through large result sets. Check `totalCount` in the response to know if more pages exist. +- If the repository name is not known, use `list_repos` first to discover the correct name.