refactor: codebase audit cleanup — dead code, dedup, module splits
Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
apps/server/src/services/tools/codecontext/factory.ts
Normal file
43
apps/server/src/services/tools/codecontext/factory.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../types.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
// Shared factory for the 12 codecontext shim ToolDefs.
|
||||
// Each shim provides name/schema/description/jsonParameters/mapArgs; the
|
||||
// factory builds the ToolDef and returns both the ToolDef and the standalone
|
||||
// execute function (used by tests that inject a custom fetcher).
|
||||
export function makeCodecontextTool<TInput>(opts: {
|
||||
name: string;
|
||||
schema: z.ZodType<TInput>;
|
||||
description: string;
|
||||
jsonParameters: Record<string, unknown>;
|
||||
mapArgs: (input: TInput) => Record<string, unknown>;
|
||||
}): {
|
||||
toolDef: ToolDef<TInput>;
|
||||
execute: (input: TInput, projectPath: string, fetcher?: typeof fetch) => Promise<CodecontextResponse>;
|
||||
} {
|
||||
const { name, schema, description, jsonParameters, mapArgs } = opts;
|
||||
|
||||
async function execute(
|
||||
input: TInput,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext({ toolName: name, args: mapArgs(input), projectPath }, fetcher);
|
||||
}
|
||||
|
||||
const toolDef: ToolDef<TInput> = {
|
||||
name,
|
||||
description,
|
||||
inputSchema: schema,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: { name, description, parameters: jsonParameters },
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return execute(input, projectRoot);
|
||||
},
|
||||
};
|
||||
|
||||
return { toolDef, execute };
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetBlastRadiusInput = z.object({
|
||||
file_path: z.string().trim().min(1),
|
||||
@@ -12,40 +11,23 @@ const DESCRIPTION =
|
||||
'Use to assess the impact of changing a file — "what breaks if I modify this?" ' +
|
||||
'Traverses the import graph in reverse via BFS. Results sorted by distance (closest dependents first).';
|
||||
|
||||
export async function executeGetBlastRadius(
|
||||
input: GetBlastRadiusInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{ toolName: 'get_blast_radius', args: { file_path: input.file_path }, projectPath },
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getBlastRadius: ToolDef<GetBlastRadiusInputT> = {
|
||||
name: 'get_blast_radius',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetBlastRadiusInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_blast_radius',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Absolute or project-relative path to the file to analyze.',
|
||||
},
|
||||
const { toolDef: getBlastRadius, execute: executeGetBlastRadius } =
|
||||
makeCodecontextTool<GetBlastRadiusInputT>({
|
||||
name: 'get_blast_radius',
|
||||
schema: GetBlastRadiusInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Absolute or project-relative path to the file to analyze.',
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetBlastRadius(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => ({ file_path: input.file_path }),
|
||||
});
|
||||
|
||||
export { getBlastRadius, executeGetBlastRadius };
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_codebase_overview.
|
||||
// Pattern mirrors services/web_search.ts: pure executor + ToolDef wrapper.
|
||||
// target_dir is supplied by callCodecontext from the resolved project root.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetCodebaseOverviewInput = z.object({
|
||||
include_stats: z.boolean().optional(),
|
||||
@@ -17,43 +12,22 @@ const DESCRIPTION =
|
||||
'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' +
|
||||
'PHP and SQL are not supported — fall back to view_file/grep for those.';
|
||||
|
||||
export async function executeGetCodebaseOverview(
|
||||
input: GetCodebaseOverviewInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{
|
||||
toolName: 'get_codebase_overview',
|
||||
args: { include_stats: input.include_stats ?? true },
|
||||
projectPath,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getCodebaseOverview: ToolDef<GetCodebaseOverviewInputT> = {
|
||||
name: 'get_codebase_overview',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetCodebaseOverviewInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_codebase_overview',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
include_stats: {
|
||||
type: 'boolean',
|
||||
description: 'Include file count, symbol count, language stats. Defaults to true.',
|
||||
},
|
||||
const { toolDef: getCodebaseOverview, execute: executeGetCodebaseOverview } =
|
||||
makeCodecontextTool<GetCodebaseOverviewInputT>({
|
||||
name: 'get_codebase_overview',
|
||||
schema: GetCodebaseOverviewInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
include_stats: {
|
||||
type: 'boolean',
|
||||
description: 'Include file count, symbol count, language stats. Defaults to true.',
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetCodebaseOverview(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => ({ include_stats: input.include_stats ?? true }),
|
||||
});
|
||||
|
||||
export { getCodebaseOverview, executeGetCodebaseOverview };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_dependencies.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetDependenciesInput = z.object({
|
||||
file_path: z.string().trim().optional(),
|
||||
@@ -16,45 +13,31 @@ const DESCRIPTION =
|
||||
'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript dependencies are approximate. ' +
|
||||
'PHP and SQL are not supported.';
|
||||
|
||||
export async function executeGetDependencies(
|
||||
input: GetDependenciesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {
|
||||
direction: input.direction ?? 'both',
|
||||
};
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
return callCodecontext({ toolName: 'get_dependencies', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getDependencies: ToolDef<GetDependenciesInputT> = {
|
||||
name: 'get_dependencies',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetDependenciesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_dependencies',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Narrow to a single file. Omit for a project-wide graph.',
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['incoming', 'outgoing', 'both'],
|
||||
description: 'Which edges to include. Defaults to "both".',
|
||||
},
|
||||
const { toolDef: getDependencies, execute: executeGetDependencies } =
|
||||
makeCodecontextTool<GetDependenciesInputT>({
|
||||
name: 'get_dependencies',
|
||||
schema: GetDependenciesInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Narrow to a single file. Omit for a project-wide graph.',
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['incoming', 'outgoing', 'both'],
|
||||
description: 'Which edges to include. Defaults to "both".',
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetDependencies(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = { direction: input.direction ?? 'both' };
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { getDependencies, executeGetDependencies };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_file_analysis.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetFileAnalysisInput = z.object({
|
||||
file_path: z.string().trim().min(1),
|
||||
@@ -15,44 +12,23 @@ const DESCRIPTION =
|
||||
'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate. ' +
|
||||
'PHP and SQL are not supported — fall back to view_file for those.';
|
||||
|
||||
export async function executeGetFileAnalysis(
|
||||
input: GetFileAnalysisInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: input.file_path },
|
||||
projectPath,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getFileAnalysis: ToolDef<GetFileAnalysisInputT> = {
|
||||
name: 'get_file_analysis',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetFileAnalysisInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_file_analysis',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Absolute or project-relative path to the file.',
|
||||
},
|
||||
const { toolDef: getFileAnalysis, execute: executeGetFileAnalysis } =
|
||||
makeCodecontextTool<GetFileAnalysisInputT>({
|
||||
name: 'get_file_analysis',
|
||||
schema: GetFileAnalysisInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Absolute or project-relative path to the file.',
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetFileAnalysis(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => ({ file_path: input.file_path }),
|
||||
});
|
||||
|
||||
export { getFileAnalysis, executeGetFileAnalysis };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_framework_analysis.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetFrameworkAnalysisInput = z.object({
|
||||
framework: z.string().optional(),
|
||||
@@ -16,43 +13,31 @@ const DESCRIPTION =
|
||||
'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript is approximate. ' +
|
||||
'PHP and SQL are not supported.';
|
||||
|
||||
export async function executeGetFrameworkAnalysis(
|
||||
input: GetFrameworkAnalysisInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.framework) args['framework'] = input.framework;
|
||||
if (input.include_stats !== undefined) args['include_stats'] = input.include_stats;
|
||||
return callCodecontext({ toolName: 'get_framework_analysis', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getFrameworkAnalysis: ToolDef<GetFrameworkAnalysisInputT> = {
|
||||
name: 'get_framework_analysis',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetFrameworkAnalysisInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_framework_analysis',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
framework: {
|
||||
type: 'string',
|
||||
description: 'Framework name. Auto-detected if omitted.',
|
||||
},
|
||||
include_stats: {
|
||||
type: 'boolean',
|
||||
description: 'Include component/hook/service counts.',
|
||||
},
|
||||
const { toolDef: getFrameworkAnalysis, execute: executeGetFrameworkAnalysis } =
|
||||
makeCodecontextTool<GetFrameworkAnalysisInputT>({
|
||||
name: 'get_framework_analysis',
|
||||
schema: GetFrameworkAnalysisInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
framework: {
|
||||
type: 'string',
|
||||
description: 'Framework name. Auto-detected if omitted.',
|
||||
},
|
||||
include_stats: {
|
||||
type: 'boolean',
|
||||
description: 'Include component/hook/service counts.',
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetFrameworkAnalysis(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.framework) args['framework'] = input.framework;
|
||||
if (input.include_stats !== undefined) args['include_stats'] = input.include_stats;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { getFrameworkAnalysis, executeGetFrameworkAnalysis };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetHotFilesInput = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
@@ -12,39 +11,22 @@ const DESCRIPTION =
|
||||
'Hot files are high-risk change targets — many other files depend on them. ' +
|
||||
'Use to identify core modules and assess refactoring risk.';
|
||||
|
||||
export async function executeGetHotFiles(
|
||||
input: GetHotFilesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{ toolName: 'get_hot_files', args: input.limit != null ? { limit: input.limit } : {}, projectPath },
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getHotFiles: ToolDef<GetHotFilesInputT> = {
|
||||
name: 'get_hot_files',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetHotFilesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_hot_files',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of files to return (default 20, max 100).',
|
||||
},
|
||||
const { toolDef: getHotFiles, execute: executeGetHotFiles } =
|
||||
makeCodecontextTool<GetHotFilesInputT>({
|
||||
name: 'get_hot_files',
|
||||
schema: GetHotFilesInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of files to return (default 20, max 100).',
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetHotFiles(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => (input.limit != null ? { limit: input.limit } : {}),
|
||||
});
|
||||
|
||||
export { getHotFiles, executeGetHotFiles };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetMiddlewareInput = z.object({});
|
||||
export type GetMiddlewareInputT = z.infer<typeof GetMiddlewareInput>;
|
||||
@@ -11,31 +10,17 @@ const DESCRIPTION =
|
||||
'import names (@fastify/cors, helmet, etc.) and registration patterns ' +
|
||||
'(app.register, app.addHook, app.setErrorHandler).';
|
||||
|
||||
export async function executeGetMiddleware(
|
||||
_input: GetMiddlewareInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext({ toolName: 'get_middleware', args: {}, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getMiddleware: ToolDef<GetMiddlewareInputT> = {
|
||||
name: 'get_middleware',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetMiddlewareInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_middleware',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
const { toolDef: getMiddleware, execute: executeGetMiddleware } =
|
||||
makeCodecontextTool<GetMiddlewareInputT>({
|
||||
name: 'get_middleware',
|
||||
schema: GetMiddlewareInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetMiddleware(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: () => ({}),
|
||||
});
|
||||
|
||||
export { getMiddleware, executeGetMiddleware };
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetRoutesInput = z.object({
|
||||
framework: z.string().trim().optional(),
|
||||
@@ -13,38 +12,26 @@ const DESCRIPTION =
|
||||
'with method, path, file, line number, and inferred tags (db, auth, cache). ' +
|
||||
'Optional framework filter narrows to "fastify" or "express".';
|
||||
|
||||
export async function executeGetRoutes(
|
||||
input: GetRoutesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.framework) args.framework = input.framework;
|
||||
return callCodecontext({ toolName: 'get_routes', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getRoutes: ToolDef<GetRoutesInputT> = {
|
||||
name: 'get_routes',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetRoutesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_routes',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
framework: {
|
||||
type: 'string',
|
||||
description: 'Filter to a specific framework: "fastify" or "express". Omit for all.',
|
||||
},
|
||||
const { toolDef: getRoutes, execute: executeGetRoutes } =
|
||||
makeCodecontextTool<GetRoutesInputT>({
|
||||
name: 'get_routes',
|
||||
schema: GetRoutesInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
framework: {
|
||||
type: 'string',
|
||||
description: 'Filter to a specific framework: "fastify" or "express". Omit for all.',
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetRoutes(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.framework) args.framework = input.framework;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { getRoutes, executeGetRoutes };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_semantic_neighborhoods.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetSemanticNeighborhoodsInput = z.object({
|
||||
file_path: z.string().trim().optional(),
|
||||
@@ -20,54 +17,42 @@ const DESCRIPTION =
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 10;
|
||||
|
||||
export async function executeGetSemanticNeighborhoods(
|
||||
input: GetSemanticNeighborhoodsInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {
|
||||
max_results: input.max_results ?? DEFAULT_MAX_RESULTS,
|
||||
};
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
if (input.include_basic !== undefined) args['include_basic'] = input.include_basic;
|
||||
if (input.include_quality !== undefined) args['include_quality'] = input.include_quality;
|
||||
return callCodecontext({ toolName: 'get_semantic_neighborhoods', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getSemanticNeighborhoods: ToolDef<GetSemanticNeighborhoodsInputT> = {
|
||||
name: 'get_semantic_neighborhoods',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetSemanticNeighborhoodsInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_semantic_neighborhoods',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Anchor file for the neighborhood query. Omit for a project-wide view.',
|
||||
},
|
||||
include_basic: {
|
||||
type: 'boolean',
|
||||
description: 'Include the basic (import-based) neighborhood. Default true.',
|
||||
},
|
||||
include_quality: {
|
||||
type: 'boolean',
|
||||
description: 'Include code-quality metrics for the neighborhood. Default false.',
|
||||
},
|
||||
max_results: {
|
||||
type: 'integer',
|
||||
description: `Cap on neighborhoods returned. Defaults to ${DEFAULT_MAX_RESULTS}.`,
|
||||
},
|
||||
const { toolDef: getSemanticNeighborhoods, execute: executeGetSemanticNeighborhoods } =
|
||||
makeCodecontextTool<GetSemanticNeighborhoodsInputT>({
|
||||
name: 'get_semantic_neighborhoods',
|
||||
schema: GetSemanticNeighborhoodsInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Anchor file for the neighborhood query. Omit for a project-wide view.',
|
||||
},
|
||||
include_basic: {
|
||||
type: 'boolean',
|
||||
description: 'Include the basic (import-based) neighborhood. Default true.',
|
||||
},
|
||||
include_quality: {
|
||||
type: 'boolean',
|
||||
description: 'Include code-quality metrics for the neighborhood. Default false.',
|
||||
},
|
||||
max_results: {
|
||||
type: 'integer',
|
||||
description: `Cap on neighborhoods returned. Defaults to ${DEFAULT_MAX_RESULTS}.`,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetSemanticNeighborhoods(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = {
|
||||
max_results: input.max_results ?? DEFAULT_MAX_RESULTS,
|
||||
};
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
if (input.include_basic !== undefined) args['include_basic'] = input.include_basic;
|
||||
if (input.include_quality !== undefined) args['include_quality'] = input.include_quality;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { getSemanticNeighborhoods, executeGetSemanticNeighborhoods };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — get_symbol_info.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const GetSymbolInfoInput = z.object({
|
||||
symbol_name: z.string().min(1),
|
||||
@@ -16,48 +13,36 @@ const DESCRIPTION =
|
||||
'Tree-sitter coverage: full for JS/Python/Java/Go/Rust/C++. TypeScript symbols are approximate (uses JS grammar). ' +
|
||||
'PHP and SQL are not supported — fall back to grep for those.';
|
||||
|
||||
export async function executeGetSymbolInfo(
|
||||
input: GetSymbolInfoInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = { symbol_name: input.symbol_name };
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
if (input.framework_type) args['framework_type'] = input.framework_type;
|
||||
return callCodecontext({ toolName: 'get_symbol_info', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getSymbolInfo: ToolDef<GetSymbolInfoInputT> = {
|
||||
name: 'get_symbol_info',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetSymbolInfoInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_symbol_info',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol_name: {
|
||||
type: 'string',
|
||||
description: 'The symbol name to look up (case-sensitive).',
|
||||
},
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Narrow to a specific file when the symbol name is ambiguous.',
|
||||
},
|
||||
framework_type: {
|
||||
type: 'string',
|
||||
description: 'Hint for framework-specific extraction (react|vue|svelte|django|fastapi|express|nest|…).',
|
||||
},
|
||||
const { toolDef: getSymbolInfo, execute: executeGetSymbolInfo } =
|
||||
makeCodecontextTool<GetSymbolInfoInputT>({
|
||||
name: 'get_symbol_info',
|
||||
schema: GetSymbolInfoInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
symbol_name: {
|
||||
type: 'string',
|
||||
description: 'The symbol name to look up (case-sensitive).',
|
||||
},
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Narrow to a specific file when the symbol name is ambiguous.',
|
||||
},
|
||||
framework_type: {
|
||||
type: 'string',
|
||||
description: 'Hint for framework-specific extraction (react|vue|svelte|django|fastapi|express|nest|…).',
|
||||
},
|
||||
required: ['symbol_name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ['symbol_name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetSymbolInfo(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = { symbol_name: input.symbol_name };
|
||||
if (input.file_path) args['file_path'] = input.file_path;
|
||||
if (input.framework_type) args['framework_type'] = input.framework_type;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { getSymbolInfo, executeGetSymbolInfo };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — search_symbols.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const SearchSymbolsInput = z.object({
|
||||
query: z.string().min(1),
|
||||
@@ -21,57 +18,45 @@ const DESCRIPTION =
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
|
||||
export async function executeSearchSymbols(
|
||||
input: SearchSymbolsInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {
|
||||
query: input.query,
|
||||
limit: input.limit ?? DEFAULT_LIMIT,
|
||||
};
|
||||
if (input.file_type) args['file_type'] = input.file_type;
|
||||
if (input.symbol_type) args['symbol_type'] = input.symbol_type;
|
||||
if (input.framework_type) args['framework_type'] = input.framework_type;
|
||||
return callCodecontext({ toolName: 'search_symbols', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const searchSymbols: ToolDef<SearchSymbolsInputT> = {
|
||||
name: 'search_symbols',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: SearchSymbolsInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search_symbols',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Substring or name fragment to match.' },
|
||||
file_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by file extension or language (e.g. "ts", "py", "go").',
|
||||
},
|
||||
symbol_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by kind: function|class|method|variable|type|interface.',
|
||||
},
|
||||
framework_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by framework context (react|vue|svelte|…).',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: `Max matches to return. Defaults to ${DEFAULT_LIMIT}.`,
|
||||
},
|
||||
const { toolDef: searchSymbols, execute: executeSearchSymbols } =
|
||||
makeCodecontextTool<SearchSymbolsInputT>({
|
||||
name: 'search_symbols',
|
||||
schema: SearchSymbolsInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Substring or name fragment to match.' },
|
||||
file_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by file extension or language (e.g. "ts", "py", "go").',
|
||||
},
|
||||
symbol_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by kind: function|class|method|variable|type|interface.',
|
||||
},
|
||||
framework_type: {
|
||||
type: 'string',
|
||||
description: 'Filter by framework context (react|vue|svelte|…).',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: `Max matches to return. Defaults to ${DEFAULT_LIMIT}.`,
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ['query'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeSearchSymbols(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => {
|
||||
const args: Record<string, unknown> = {
|
||||
query: input.query,
|
||||
limit: input.limit ?? DEFAULT_LIMIT,
|
||||
};
|
||||
if (input.file_type) args['file_type'] = input.file_type;
|
||||
if (input.symbol_type) args['symbol_type'] = input.symbol_type;
|
||||
if (input.framework_type) args['framework_type'] = input.framework_type;
|
||||
return args;
|
||||
},
|
||||
});
|
||||
|
||||
export { searchSymbols, executeSearchSymbols };
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext wrapper — watch_changes.
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
import { makeCodecontextTool } from './factory.js';
|
||||
|
||||
export const WatchChangesInput = z.object({
|
||||
enable: z.boolean(),
|
||||
@@ -10,48 +7,27 @@ export const WatchChangesInput = z.object({
|
||||
export type WatchChangesInputT = z.infer<typeof WatchChangesInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Turn codecontext\'s file watcher on or off for this project. ' +
|
||||
"Turn codecontext's file watcher on or off for this project. " +
|
||||
'When on, codecontext re-analyzes files in the background as they change (debounced). Default is on. ' +
|
||||
'Disable temporarily if you\'re doing bulk edits and want to avoid analysis churn.';
|
||||
"Disable temporarily if you're doing bulk edits and want to avoid analysis churn.";
|
||||
|
||||
export async function executeWatchChanges(
|
||||
input: WatchChangesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{
|
||||
toolName: 'watch_changes',
|
||||
args: { enable: input.enable },
|
||||
projectPath,
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const watchChanges: ToolDef<WatchChangesInputT> = {
|
||||
name: 'watch_changes',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: WatchChangesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'watch_changes',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enable: {
|
||||
type: 'boolean',
|
||||
description: 'true = enable the watcher; false = disable.',
|
||||
},
|
||||
const { toolDef: watchChanges, execute: executeWatchChanges } =
|
||||
makeCodecontextTool<WatchChangesInputT>({
|
||||
name: 'watch_changes',
|
||||
schema: WatchChangesInput,
|
||||
description: DESCRIPTION,
|
||||
jsonParameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enable: {
|
||||
type: 'boolean',
|
||||
description: 'true = enable the watcher; false = disable.',
|
||||
},
|
||||
required: ['enable'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
required: ['enable'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeWatchChanges(input, projectRoot);
|
||||
},
|
||||
};
|
||||
mapArgs: (input) => ({ enable: input.enable }),
|
||||
});
|
||||
|
||||
export { watchChanges, executeWatchChanges };
|
||||
|
||||
392
apps/server/src/services/tools/fs-tools.ts
Normal file
392
apps/server/src/services/tools/fs-tools.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve, basename, relative } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
import { pathGuard, PathScopeError } from '../path_guard.js';
|
||||
import { isSecretPath, SecretBlockedError, filterSecretEntries } from '../secret_guard.js';
|
||||
import { grep as fileOpsGrep, findFiles as fileOpsFindFiles } from '../file_ops.js';
|
||||
import { readTruncation, truncateIfNeeded } from '../truncate.js';
|
||||
import type { ToolDef } from './types.js';
|
||||
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const DEFAULT_VIEW_LINES = 200;
|
||||
const MAX_GREP_RESULTS = 200;
|
||||
const DEFAULT_GREP_RESULTS = 100;
|
||||
const MAX_FIND_RESULTS = 200;
|
||||
const DEFAULT_FIND_RESULTS = 100;
|
||||
const MAX_DIR_ENTRIES = 500;
|
||||
|
||||
const ViewFileInput = z.object({
|
||||
path: z.string().min(1),
|
||||
start_line: z.number().int().positive().optional(),
|
||||
end_line: z.number().int().positive().optional(),
|
||||
});
|
||||
type ViewFileInputT = z.infer<typeof ViewFileInput>;
|
||||
|
||||
export const viewFile: ToolDef<ViewFileInputT> = {
|
||||
name: 'view_file',
|
||||
description:
|
||||
"Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused. Output is truncated if longer than the slice; the response indicates truncation.",
|
||||
inputSchema: ViewFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'view_file',
|
||||
description:
|
||||
"Read a file under the project. Returns first 200 lines by default, or a slice via start_line/end_line (1-indexed, inclusive). Files larger than 5MB are refused.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'absolute or project-relative path' },
|
||||
start_line: { type: 'integer', description: 'first line (1-indexed)' },
|
||||
end_line: { type: 'integer', description: 'last line (1-indexed, inclusive)' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
// v1.11.7: secret-file deny check. Test the project-relative path
|
||||
// (matches the form continue.dev's patterns expect: basenames + dir
|
||||
// segments). Throw a typed error so executeToolCall in inference.ts
|
||||
// surfaces a clear "blocked" message to the LLM instead of silently
|
||||
// returning content the user wanted hidden.
|
||||
// v1.13.17: when the resolved path is outside the primary projectRoot
|
||||
// (i.e. via an allowed_read_paths grant), `relative()` returns "../…"
|
||||
// which won't match secret-file basename patterns. Re-anchor on the
|
||||
// file's basename so the secret deny still fires across all grant roots.
|
||||
const rel = relative(projectRoot, real);
|
||||
const relPath = rel && !rel.startsWith('..') ? rel : basename(real);
|
||||
if (isSecretPath(relPath)) {
|
||||
throw new SecretBlockedError(relPath);
|
||||
}
|
||||
const s = await stat(real);
|
||||
if (!s.isFile()) {
|
||||
throw new PathScopeError(`not a file: ${input.path}`);
|
||||
}
|
||||
if (s.size > MAX_FILE_BYTES) {
|
||||
throw new Error(`file too large (${s.size} bytes, max ${MAX_FILE_BYTES})`);
|
||||
}
|
||||
const raw = await readFile(real, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const total = lines.length;
|
||||
let start = input.start_line ?? 1;
|
||||
let end = input.end_line ?? Math.min(total, start + DEFAULT_VIEW_LINES - 1);
|
||||
if (input.start_line == null && input.end_line == null) {
|
||||
end = Math.min(total, DEFAULT_VIEW_LINES);
|
||||
}
|
||||
if (start < 1) start = 1;
|
||||
if (end > total) end = total;
|
||||
if (end < start) end = start;
|
||||
const slice = lines.slice(start - 1, end);
|
||||
const content = slice.join('\n');
|
||||
const truncated = total > end || start > 1;
|
||||
// v1.13.5: stash the full file on tmpfs so the model can retrieve more
|
||||
// via view_truncated_output(id) without re-reading the file (which it
|
||||
// may not have project-relative-path access to in future agent setups).
|
||||
// raw is bounded by MAX_FILE_BYTES (5MB), within truncateIfNeeded's cap.
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: raw,
|
||||
slicedContent: content,
|
||||
wasTruncated: truncated,
|
||||
});
|
||||
return {
|
||||
path: relative(projectRoot, real) || basename(real),
|
||||
content: wrapped.content,
|
||||
total_lines: total,
|
||||
returned_lines: [start, end],
|
||||
truncated: wrapped.truncated,
|
||||
...(wrapped.outputPath ? { outputPath: wrapped.outputPath } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const ListDirInput = z.object({
|
||||
path: z.string().min(1),
|
||||
show_hidden: z.boolean().optional(),
|
||||
});
|
||||
type ListDirInputT = z.infer<typeof ListDirInput>;
|
||||
|
||||
export const listDir: ToolDef<ListDirInputT> = {
|
||||
name: 'list_dir',
|
||||
description: 'List entries in a directory (up to 500). Hidden files excluded unless show_hidden=true.',
|
||||
inputSchema: ListDirInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_dir',
|
||||
description:
|
||||
'List entries in a directory (up to 500). Hidden files (dot-prefixed) excluded unless show_hidden=true.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string' },
|
||||
show_hidden: { type: 'boolean' },
|
||||
},
|
||||
required: ['path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const real = await pathGuard(projectRoot, input.path, extraRoots);
|
||||
const s = await stat(real);
|
||||
if (!s.isDirectory()) {
|
||||
throw new PathScopeError(`not a directory: ${input.path}`);
|
||||
}
|
||||
const entries = await readdir(real, { withFileTypes: true });
|
||||
const filtered = input.show_hidden
|
||||
? entries
|
||||
: entries.filter((e) => !e.name.startsWith('.'));
|
||||
const total = filtered.length;
|
||||
const wasTruncated = total > MAX_DIR_ENTRIES;
|
||||
const relDir = relative(projectRoot, real) || '.';
|
||||
// v1.13.5: when we'd truncate, render the FULL list to tmpfs so
|
||||
// view_truncated_output can serve it. Stat sizes for all entries when
|
||||
// truncating so the stored view matches the visible shape; this is the
|
||||
// one extra cost for big directories, bounded by total entries (which
|
||||
// is itself bounded by filesystem behavior).
|
||||
const processOne = async (e: typeof filtered[number]) => {
|
||||
const child = resolve(real, e.name);
|
||||
let size: number | undefined;
|
||||
if (e.isFile()) {
|
||||
try {
|
||||
const cs = await stat(child);
|
||||
size = cs.size;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? ('dir' as const) : ('file' as const),
|
||||
...(size != null ? { size } : {}),
|
||||
};
|
||||
};
|
||||
const slice = filtered.slice(0, MAX_DIR_ENTRIES);
|
||||
const out = await Promise.all(slice.map(processOne));
|
||||
// v1.11.7: filter entries whose project-relative path matches a secret
|
||||
// pattern. The same filter applies to the full-list snapshot below so
|
||||
// the stashed file never holds entries the slice would have hidden.
|
||||
const secretFilter = filterSecretEntries(out, (e) =>
|
||||
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
||||
);
|
||||
let outputPath: string | undefined;
|
||||
if (wasTruncated) {
|
||||
const fullProcessed = await Promise.all(filtered.map(processOne));
|
||||
const fullFiltered = filterSecretEntries(fullProcessed, (e) =>
|
||||
relDir === '.' ? e.name : `${relDir}/${e.name}`,
|
||||
);
|
||||
// One line per entry, view_truncated_output's line slicing semantics
|
||||
// map cleanly. Format: "<type>\t<name>[\tsize=N]". Header documents
|
||||
// the shape so the model can grep / regex without prior schema lookup.
|
||||
const header = `# list_dir ${relDir} — ${fullFiltered.kept.length} entries`;
|
||||
const lines = [header, ...fullFiltered.kept.map((e) => {
|
||||
const sz = 'size' in e && e.size != null ? `\tsize=${e.size}` : '';
|
||||
return `${e.type}\t${e.name}${sz}`;
|
||||
})];
|
||||
const wrapped = await truncateIfNeeded({
|
||||
fullContent: lines.join('\n'),
|
||||
slicedContent: '',
|
||||
wasTruncated: true,
|
||||
});
|
||||
outputPath = wrapped.outputPath;
|
||||
}
|
||||
return {
|
||||
path: relDir,
|
||||
entries: secretFilter.kept,
|
||||
total: secretFilter.kept.length,
|
||||
truncated: wasTruncated,
|
||||
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||
...(outputPath ? { outputPath } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const GrepInput = z.object({
|
||||
pattern: z.string().min(1),
|
||||
path: z.string().optional(),
|
||||
case_sensitive: z.boolean().optional(),
|
||||
max_results: z.number().int().positive().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
});
|
||||
type GrepInputT = z.infer<typeof GrepInput>;
|
||||
|
||||
export const grep: ToolDef<GrepInputT> = {
|
||||
name: 'grep',
|
||||
description:
|
||||
'Search file contents with ripgrep. Default path is project root. Max 100 results (200 cap).',
|
||||
inputSchema: GrepInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'grep',
|
||||
description:
|
||||
'Search file contents with ripgrep. Returns up to 100 matches (cap 200). Set hidden=true to include dot-prefixed files.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
case_sensitive: { type: 'boolean' },
|
||||
max_results: { type: 'integer' },
|
||||
hidden: { type: 'boolean' },
|
||||
},
|
||||
required: ['pattern'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_GREP_RESULTS, 1),
|
||||
MAX_GREP_RESULTS
|
||||
);
|
||||
// Delegate to file_ops.grep; reshape match objects to preserve LLM output format
|
||||
// (file_ops uses {path, line, text}; tool output uses {path, line, content})
|
||||
const result = await fileOpsGrep(projectRoot, input.pattern, {
|
||||
path: input.path,
|
||||
max_matches: limit,
|
||||
case_sensitive: input.case_sensitive,
|
||||
hidden: input.hidden,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
const reshaped = result.matches.map((m) => ({
|
||||
path: m.path,
|
||||
line: m.line,
|
||||
content: m.text,
|
||||
}));
|
||||
// v1.11.7: drop matches whose source file is a known-secret pattern.
|
||||
// file_ops.grep returns project-relative paths, so we feed them straight
|
||||
// into isSecretPath. Multiple matches in the same secret file each get
|
||||
// dropped individually — they all count in the hidden tally.
|
||||
const secretFilter = filterSecretEntries(reshaped, (m) => m.path);
|
||||
return {
|
||||
matches: secretFilter.kept,
|
||||
total: secretFilter.kept.length,
|
||||
truncated: result.truncated,
|
||||
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const FindFilesInput = z.object({
|
||||
pattern: z.string().min(1),
|
||||
path: z.string().optional(),
|
||||
max_results: z.number().int().positive().optional(),
|
||||
});
|
||||
type FindFilesInputT = z.infer<typeof FindFilesInput>;
|
||||
|
||||
export const findFiles: ToolDef<FindFilesInputT> = {
|
||||
name: 'find_files',
|
||||
description: 'Glob for filenames. Default path is project root. Max 100 results (200 cap).',
|
||||
inputSchema: FindFilesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'find_files',
|
||||
description:
|
||||
'Glob for filenames under a directory. Default path is project root. Max 100 results (cap 200). Pattern uses standard glob (e.g. "**/*.ts").',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pattern: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
max_results: { type: 'integer' },
|
||||
},
|
||||
required: ['pattern'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot, extraRoots) {
|
||||
const limit = Math.min(
|
||||
Math.max(input.max_results ?? DEFAULT_FIND_RESULTS, 1),
|
||||
MAX_FIND_RESULTS
|
||||
);
|
||||
// Delegate to file_ops.findFiles; reshape { files, total, truncated } to
|
||||
// preserve the LLM-visible output format { paths, total, truncated }
|
||||
const result = await fileOpsFindFiles(projectRoot, input.pattern, {
|
||||
path: input.path,
|
||||
max_results: limit,
|
||||
extra_roots: extraRoots,
|
||||
});
|
||||
// v1.11.7: drop paths matching secret patterns. The original `total`
|
||||
// from file_ops includes pre-truncation count; we report the visible
|
||||
// count post-filter so the LLM can't infer hidden-count by subtraction.
|
||||
const secretFilter = filterSecretEntries(result.files, (p) => p);
|
||||
return {
|
||||
paths: secretFilter.kept,
|
||||
total: secretFilter.kept.length,
|
||||
truncated: result.truncated,
|
||||
...(secretFilter.note ? { pathguard_note: secretFilter.note } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// v1.13.5: retrieves the full content of a previously-truncated tool output
|
||||
// via the opaque id stamped on the original tool_result. Line-based slicing
|
||||
// matches view_file's mental model so the model uses the same affordances.
|
||||
// Tmpfs-backed, 7-day TTL (see services/truncate.ts).
|
||||
const VIEW_TRUNCATED_DEFAULT_LINES = 200;
|
||||
|
||||
const ViewTruncatedOutputInput = z.object({
|
||||
id: z.string().regex(/^tr_[0-9a-v]{12}$/),
|
||||
start_line: z.number().int().positive().optional(),
|
||||
end_line: z.number().int().positive().optional(),
|
||||
});
|
||||
type ViewTruncatedOutputInputT = z.infer<typeof ViewTruncatedOutputInput>;
|
||||
|
||||
export const viewTruncatedOutput: ToolDef<ViewTruncatedOutputInputT> = {
|
||||
name: 'view_truncated_output',
|
||||
description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. When a tool returns { truncated: true, outputPath: "tr_..." }, call this to view the full content. Defaults to the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines. Use start_line and end_line (1-indexed, inclusive) to slice. Stored for 7 days.`,
|
||||
inputSchema: ViewTruncatedOutputInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'view_truncated_output',
|
||||
description: `Retrieve the full content of a previously-truncated tool output by its outputPath id. Returns the first ${VIEW_TRUNCATED_DEFAULT_LINES} lines by default; use start_line/end_line to slice. Stored for 7 days.`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'The outputPath value from an earlier truncated tool result (e.g. "tr_abc123def456").' },
|
||||
start_line: { type: 'integer', description: 'First line (1-indexed). Default 1.' },
|
||||
end_line: { type: 'integer', description: `Last line (1-indexed, inclusive). Default ${VIEW_TRUNCATED_DEFAULT_LINES} lines past start.` },
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// view_truncated_output doesn't touch the filesystem — it pulls from tmpfs
|
||||
// by opaque id. extraRoots is irrelevant here; declared for signature parity
|
||||
// with the v1.13.17 ToolDef contract.
|
||||
async execute(input, _projectRoot, _extraRoots) {
|
||||
const content = await readTruncation(input.id);
|
||||
if (content === null) {
|
||||
return {
|
||||
id: input.id,
|
||||
content: '',
|
||||
truncated: false,
|
||||
error: `No truncation found for id "${input.id}". It may have been pruned (7-day TTL) or never existed.`,
|
||||
};
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const total = lines.length;
|
||||
let start = input.start_line ?? 1;
|
||||
let end = input.end_line ?? Math.min(total, start + VIEW_TRUNCATED_DEFAULT_LINES - 1);
|
||||
if (start < 1) start = 1;
|
||||
if (end > total) end = total;
|
||||
if (end < start) end = start;
|
||||
const slice = lines.slice(start - 1, end).join('\n');
|
||||
// Re-slicing this view isn't truncation in the dual-write sense — the
|
||||
// model already has the id; no point stashing the slice again.
|
||||
const truncated = total > end || start > 1;
|
||||
return {
|
||||
id: input.id,
|
||||
content: slice,
|
||||
total_lines: total,
|
||||
returned_lines: [start, end],
|
||||
truncated,
|
||||
};
|
||||
},
|
||||
};
|
||||
216
apps/server/src/services/tools/misc-tools.ts
Normal file
216
apps/server/src/services/tools/misc-tools.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { z } from 'zod';
|
||||
import { getGitMeta } from '../git_meta.js';
|
||||
import { findSkills, getSkillBody, getSkillResource } from '../skills.js';
|
||||
import type { ToolDef } from './types.js';
|
||||
|
||||
// v1.8 Level 1 branch awareness: gives the model a read-only view of the
|
||||
// project's git state. No path input — operates on the inference-resolved
|
||||
// project root via getGitMeta. Subprocess runs with a 2s timeout (see git_meta).
|
||||
const GitStatusInput = z.object({}).strict();
|
||||
type GitStatusInputT = z.infer<typeof GitStatusInput>;
|
||||
|
||||
export const gitStatus: ToolDef<GitStatusInputT> = {
|
||||
name: 'git_status',
|
||||
description:
|
||||
"Returns the current git branch, whether the working tree is dirty, and ahead/behind counts vs upstream. Read-only. Use when you need to know which branch the user is currently working on.",
|
||||
inputSchema: GitStatusInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'git_status',
|
||||
description:
|
||||
'Returns the current git branch, dirty flag, and ahead/behind counts vs upstream. Read-only.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(_input, projectRoot) {
|
||||
const meta = await getGitMeta(projectRoot);
|
||||
if (meta === null) {
|
||||
return { repo: false, branch: null, is_dirty: false, ahead: 0, behind: 0 };
|
||||
}
|
||||
return { repo: true, ...meta };
|
||||
},
|
||||
};
|
||||
|
||||
// Batch 9.6: skill_find, skill_use, skill_resource. Lazy-loaded markdown
|
||||
// playbooks at /data/skills/. Three tools rather than one to keep each call
|
||||
// cheap — the model lists, then loads, then optionally pulls support files.
|
||||
|
||||
const SkillFindInput = z.object({
|
||||
query: z.string().optional(),
|
||||
});
|
||||
type SkillFindInputT = z.infer<typeof SkillFindInput>;
|
||||
|
||||
export const skillFind: ToolDef<SkillFindInputT> = {
|
||||
name: 'skill_find',
|
||||
description:
|
||||
'Find skills (markdown playbooks under /data/skills) by name or description. Returns up to 5 matches. Empty query or "*" returns all available skills. Call this first to discover what skills are available.',
|
||||
inputSchema: SkillFindInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_find',
|
||||
description:
|
||||
'Find skills by name or description. Returns up to 5 matches. Empty or "*" returns all.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'substring matched against skill name and description' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
return await findSkills(input.query ?? '');
|
||||
},
|
||||
};
|
||||
|
||||
const SkillUseInput = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
type SkillUseInputT = z.infer<typeof SkillUseInput>;
|
||||
|
||||
export const skillUse: ToolDef<SkillUseInputT> = {
|
||||
name: 'skill_use',
|
||||
description:
|
||||
"Load the full body of a skill's SKILL.md by name. Returns the markdown playbook to follow. Discover names via skill_find. Errors: unknown_skill.",
|
||||
inputSchema: SkillUseInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_use',
|
||||
description: "Load the full body of a skill's SKILL.md by name.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'skill name from skill_find' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
const body = await getSkillBody(input.name);
|
||||
if (body === null) {
|
||||
return { error: 'unknown_skill', message: `unknown skill: ${input.name}` };
|
||||
}
|
||||
return { body };
|
||||
},
|
||||
};
|
||||
|
||||
const SkillResourceInput = z.object({
|
||||
name: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
});
|
||||
type SkillResourceInputT = z.infer<typeof SkillResourceInput>;
|
||||
|
||||
export const skillResource: ToolDef<SkillResourceInputT> = {
|
||||
name: 'skill_resource',
|
||||
description:
|
||||
"Read a support file inside a skill's folder (e.g. references/root-cause-tracing.md). Path is relative to the skill folder. Use skill_use to read SKILL.md itself. Errors: unknown_skill, unknown_resource, path_escape.",
|
||||
inputSchema: SkillResourceInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'skill_resource',
|
||||
description: "Read a support file inside a skill's folder. Path is relative to the skill folder.",
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'skill name' },
|
||||
path: { type: 'string', description: 'relative path under the skill folder' },
|
||||
},
|
||||
required: ['name', 'path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input) {
|
||||
const result = await getSkillResource(input.name, input.path);
|
||||
if (!result.ok) {
|
||||
return { error: result.code, message: result.message };
|
||||
}
|
||||
return { content: result.content };
|
||||
},
|
||||
};
|
||||
|
||||
// Batch 9.7: ask_user_input. Interactive elicitation. The model emits a tool
|
||||
// call with 1-3 structured questions; the inference loop PAUSES (does not
|
||||
// execute the tool server-side, does not recurse) and waits for the frontend
|
||||
// to POST /api/chats/:id/answer_user_input with the user's selections. See
|
||||
// routes/messages.ts for the resume path and services/inference.ts for the
|
||||
// pause branch in executeToolPhase.
|
||||
const AskUserInputInput = z.object({
|
||||
questions: z
|
||||
.array(
|
||||
z.object({
|
||||
question: z.string().min(1).max(200),
|
||||
type: z.enum(['single_select', 'multi_select']),
|
||||
options: z.array(z.string().min(1).max(80)).min(2).max(6),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(3),
|
||||
});
|
||||
type AskUserInputInputT = z.infer<typeof AskUserInputInput>;
|
||||
|
||||
export const askUserInput: ToolDef<AskUserInputInputT> = {
|
||||
name: 'ask_user_input',
|
||||
description:
|
||||
"Ask the user 1-3 structured questions through an inline picker UI. Use when you genuinely need a choice the user must make (e.g. scope, options, preferences) before continuing. Each question has 2-6 options and accepts free-text answers in addition. The tool call pauses the conversation until the user submits — the next assistant turn sees their answers as the tool result. Do not use for trivial yes/no clarifications you could infer; prefer it over multi-paragraph speculation about what the user might want.",
|
||||
inputSchema: AskUserInputInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'ask_user_input',
|
||||
description:
|
||||
'Ask the user 1-3 structured questions through an inline picker. Pauses the conversation until the user answers; the next turn sees their selections.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
questions: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string', description: '<=200 chars, shown to the user' },
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['single_select', 'multi_select'],
|
||||
description: 'single_select = at most one option; multi_select = any subset',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
minItems: 2,
|
||||
maxItems: 6,
|
||||
items: { type: 'string' },
|
||||
description: '2-6 strings, each <=80 chars; free-text input is always available alongside',
|
||||
},
|
||||
},
|
||||
required: ['question', 'type', 'options'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['questions'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Server-side no-op. The "execution" of ask_user_input is the user's
|
||||
// response, captured client-side and posted to /api/chats/:id/answer_user_input.
|
||||
// The inference loop detects this tool by name and pauses before reaching
|
||||
// executeToolCall — this fallback only runs if something bypasses that
|
||||
// branch, in which case the pending sentinel matches the pause-path shape.
|
||||
async execute(input) {
|
||||
return { _pending: true, questions: input.questions };
|
||||
},
|
||||
};
|
||||
96
apps/server/src/services/tools/registry.ts
Normal file
96
apps/server/src/services/tools/registry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ToolDef, ToolJsonSchema } from './types.js';
|
||||
import { viewFile, listDir, grep, findFiles, viewTruncatedOutput } from './fs-tools.js';
|
||||
import { gitStatus, skillFind, skillUse, skillResource, askUserInput } from './misc-tools.js';
|
||||
import { webSearch } from '../web_search.js';
|
||||
import { webFetch } from '../web_fetch.js';
|
||||
// v1.12 Track B.2: codecontext tools. 8 wrappers re-exported from
|
||||
// tools/codecontext/index.ts. Each calls into services/codecontext_client.ts
|
||||
// which talks to the codecontext sidecar at http://codecontext:8080.
|
||||
import {
|
||||
getCodebaseOverview,
|
||||
getFileAnalysis,
|
||||
getSymbolInfo,
|
||||
searchSymbols,
|
||||
getDependencies,
|
||||
watchChanges,
|
||||
getSemanticNeighborhoods,
|
||||
getFrameworkAnalysis,
|
||||
getBlastRadius,
|
||||
getHotFiles,
|
||||
getRoutes,
|
||||
getMiddleware,
|
||||
} from './codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
// POST /api/chats/:id/grant_read_access endpoint in routes/messages.ts.
|
||||
import { requestReadAccess } from '../request_read_access.js';
|
||||
// v2.6.x: read-only tool that reads a tab's transcript by its session-scoped
|
||||
// tab number. Needs DB/session context (ToolExecCtx 4th arg).
|
||||
import { readTabByNumber } from '../read_tab_by_number.js';
|
||||
|
||||
// v1.13.3: alpha-sorted by tool.name at module load. llama.cpp's prompt
|
||||
// cache hits on byte-identical prefixes; the tool list lives near the top
|
||||
// of the system prompt, so any order drift would invalidate every cached
|
||||
// turn. Single source of truth for ordering lives here — toolJsonSchemas()
|
||||
// and TOOLS_BY_NAME inherit it.
|
||||
// v1.14.1-mcp-poc: changed from ReadonlyArray to let-bound mutable array
|
||||
// so appendMcpTools() can push MCP-discovered tools at startup.
|
||||
export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
viewFile as ToolDef<unknown>,
|
||||
viewTruncatedOutput as ToolDef<unknown>,
|
||||
listDir as ToolDef<unknown>,
|
||||
grep as ToolDef<unknown>,
|
||||
findFiles as ToolDef<unknown>,
|
||||
gitStatus as ToolDef<unknown>,
|
||||
skillFind as ToolDef<unknown>,
|
||||
skillUse as ToolDef<unknown>,
|
||||
skillResource as ToolDef<unknown>,
|
||||
askUserInput as ToolDef<unknown>,
|
||||
// v1.11.8: web tools. Gated per-chat via session.web_search_enabled
|
||||
// (with project default fallback) — see effectiveTools filter in
|
||||
// services/inference.ts.
|
||||
webSearch as ToolDef<unknown>,
|
||||
webFetch as ToolDef<unknown>,
|
||||
// v1.12 Track B.2: codecontext tools. Backed by the codecontext sidecar
|
||||
// container. All read-only. target_dir is resolved server-side from the
|
||||
// project root in codecontext_client.ts (the LLM never supplies it).
|
||||
getCodebaseOverview as ToolDef<unknown>,
|
||||
getFileAnalysis as ToolDef<unknown>,
|
||||
getSymbolInfo as ToolDef<unknown>,
|
||||
searchSymbols as ToolDef<unknown>,
|
||||
getDependencies as ToolDef<unknown>,
|
||||
watchChanges as ToolDef<unknown>,
|
||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||
getFrameworkAnalysis as ToolDef<unknown>,
|
||||
// v1.16: codesight-merge tools. Backed by the same codecontext sidecar.
|
||||
getBlastRadius as ToolDef<unknown>,
|
||||
getHotFiles as ToolDef<unknown>,
|
||||
getRoutes as ToolDef<unknown>,
|
||||
getMiddleware as ToolDef<unknown>,
|
||||
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||
// state change is appending to sessions.allowed_read_paths via the
|
||||
// grant endpoint, gated by user consent.
|
||||
requestReadAccess as ToolDef<unknown>,
|
||||
// v2.6.x: read a tab's transcript by its session-scoped tab number.
|
||||
// Read-only; uses the ToolExecCtx 4th arg for DB/session access.
|
||||
readTabByNumber as ToolDef<unknown>,
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
export let TOOLS_BY_NAME: Record<string, ToolDef<unknown>> = Object.fromEntries(
|
||||
ALL_TOOLS.map((t) => [t.name, t])
|
||||
);
|
||||
|
||||
// v1.14.1-mcp-poc: append MCP-discovered tools at startup. Called once
|
||||
// from index.ts after mcpClient.initialize(). Re-sorts ALL_TOOLS and
|
||||
// rebuilds TOOLS_BY_NAME. MCP tools are all read-only by construction
|
||||
// (the read-only guard in mcp-client.ts rejects readOnlyHint: false).
|
||||
export function appendMcpTools(mcpTools: ToolDef<unknown>[]): void {
|
||||
if (mcpTools.length === 0) return;
|
||||
ALL_TOOLS = [...ALL_TOOLS, ...mcpTools].sort((a, b) => a.name.localeCompare(b.name));
|
||||
TOOLS_BY_NAME = Object.fromEntries(ALL_TOOLS.map((t) => [t.name, t]));
|
||||
}
|
||||
|
||||
export function toolJsonSchemas(): ToolJsonSchema[] {
|
||||
return ALL_TOOLS.map((t) => t.jsonSchema);
|
||||
}
|
||||
59
apps/server/src/services/tools/tiers.ts
Normal file
59
apps/server/src/services/tools/tiers.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { ALL_TOOLS, TOOLS_BY_NAME } from './registry.js';
|
||||
|
||||
// v1.13.15-tools: tiered tool loading. BOOCODE_TOOLS env var (`core` |
|
||||
// `standard` | `all`) filters the agent's tool whitelist before LLM dispatch.
|
||||
// Daily-driver token win on qwen3.6-35b-a3b — the 35B-A3B MoE benefits from
|
||||
// any prompt-cache stability win (fewer tools = shorter, more stable tool
|
||||
// schemas in the system prompt). Pattern lift from eyaltoledano/claude-task-
|
||||
// master (MIT + Commons Clause — pattern only, no code lift).
|
||||
//
|
||||
// The env var is a CEILING. It only narrows; never expands an agent's
|
||||
// declared whitelist. Default behavior (var unset) is unchanged: all tools.
|
||||
export const CORE_TOOL_NAMES = [
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
] as const;
|
||||
|
||||
export const STANDARD_TOOL_NAMES = [
|
||||
...CORE_TOOL_NAMES,
|
||||
'web_search',
|
||||
'web_fetch',
|
||||
'git_status',
|
||||
'get_codebase_overview',
|
||||
'get_file_analysis',
|
||||
'get_symbol_info',
|
||||
'search_symbols',
|
||||
'get_dependencies',
|
||||
'watch_changes',
|
||||
'get_semantic_neighborhoods',
|
||||
'get_framework_analysis',
|
||||
] as const;
|
||||
|
||||
// Module-load validation: every name in CORE / STANDARD must exist in
|
||||
// TOOLS_BY_NAME. Catches typos and stale tier definitions before they reach
|
||||
// production; server boot fails loudly rather than silently filtering valid
|
||||
// tools out of agent whitelists.
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
if (!TOOLS_BY_NAME[name]) {
|
||||
throw new Error(`CORE_TOOL_NAMES references unknown tool: '${name}'`);
|
||||
}
|
||||
}
|
||||
for (const name of STANDARD_TOOL_NAMES) {
|
||||
if (!TOOLS_BY_NAME[name]) {
|
||||
throw new Error(`STANDARD_TOOL_NAMES references unknown tool: '${name}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveToolTier(tier: string | undefined): readonly string[] {
|
||||
switch ((tier ?? 'all').toLowerCase()) {
|
||||
case 'core':
|
||||
return CORE_TOOL_NAMES;
|
||||
case 'standard':
|
||||
return STANDARD_TOOL_NAMES;
|
||||
case 'all':
|
||||
default:
|
||||
return ALL_TOOLS.map((t) => t.name);
|
||||
}
|
||||
}
|
||||
43
apps/server/src/services/tools/types.ts
Normal file
43
apps/server/src/services/tools/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// v2.6.x: optional DB/session context threaded into a tool's execute(). Only
|
||||
// tools that need to read session-scoped DB state (e.g. read_tab_by_number)
|
||||
// use it; every other tool ignores the 4th arg. Kept optional so existing
|
||||
// 3-arg execute() implementations stay assignable (apps/coder consumes this
|
||||
// type from the compiled dist — the optional param keeps it backward-compatible).
|
||||
export interface ToolExecCtx {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: z.ZodType<TInput>;
|
||||
jsonSchema: ToolJsonSchema;
|
||||
// v1.13.17-cross-repo-reads: extraRoots is the session's
|
||||
// allowed_read_paths, threaded through executeToolCall in tool-phase.ts.
|
||||
// Only the filesystem tools (view_file, list_dir, grep, find_files,
|
||||
// view_truncated_output) forward it to pathGuard; other tools accept the
|
||||
// arg and ignore it. The execute signature stays compatible with
|
||||
// pre-v1.13.17 callsites because the parameter is optional.
|
||||
// v2.6.x: optional 4th param toolCtx carries DB/session context for tools
|
||||
// that read session-scoped state (read_tab_by_number). Optional so 3-arg
|
||||
// implementations remain assignable.
|
||||
execute(
|
||||
input: TInput,
|
||||
projectRoot: string,
|
||||
extraRoots?: readonly string[],
|
||||
toolCtx?: ToolExecCtx,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user