From a271c5df6d55a2c08a106276a95542d8ad982f94 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Sat, 28 Feb 2026 18:32:04 -0800 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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(() => {