mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 22:44:45 -07:00
Refactor atCommandProcessor (#18461)
This commit is contained in:
committed by
GitHub
parent
7a8d6f6095
commit
e844d4f45f
@@ -179,9 +179,6 @@ describe('handleAtCommand', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
processedQuery: [{ text: queryWithSpaces }],
|
processedQuery: [{ text: queryWithSpaces }],
|
||||||
});
|
});
|
||||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
|
||||||
'Lone @ detected, will be treated as text in the modified query.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should process a valid text file path', async () => {
|
it('should process a valid text file path', async () => {
|
||||||
@@ -441,9 +438,6 @@ describe('handleAtCommand', () => {
|
|||||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
||||||
`Glob search for '**/*${invalidFile}*' found no files or an error. Path ${invalidFile} will be skipped.`,
|
`Glob search for '**/*${invalidFile}*' found no files or an error. Path ${invalidFile} will be skipped.`,
|
||||||
);
|
);
|
||||||
expect(mockOnDebugMessage).toHaveBeenCalledWith(
|
|
||||||
'Lone @ detected, will be treated as text in the modified query.',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return original query if all @paths are invalid or lone @', async () => {
|
it('should return original query if all @paths are invalid or lone @', async () => {
|
||||||
|
|||||||
@@ -7,11 +7,7 @@
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import type { PartListUnion, PartUnion } from '@google/genai';
|
import type { PartListUnion, PartUnion } from '@google/genai';
|
||||||
import type {
|
import type { AnyToolInvocation, Config } from '@google/gemini-cli-core';
|
||||||
AnyToolInvocation,
|
|
||||||
Config,
|
|
||||||
DiscoveredMCPResource,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import {
|
import {
|
||||||
debugLogger,
|
debugLogger,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
@@ -122,111 +118,74 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function categorizeAtCommands(
|
||||||
* Processes user input containing one or more '@<path>' commands.
|
commandParts: AtCommandPart[],
|
||||||
* - Workspace paths are read via the 'read_many_files' tool.
|
config: Config,
|
||||||
* - MCP resource URIs are read via each server's `resources/read`.
|
): {
|
||||||
* The user query is updated with inline content blocks so the LLM receives the
|
agentParts: AtCommandPart[];
|
||||||
* referenced context directly.
|
resourceParts: AtCommandPart[];
|
||||||
*
|
fileParts: AtCommandPart[];
|
||||||
* @returns An object indicating whether the main hook should proceed with an
|
} {
|
||||||
* LLM call and the processed query parts (including file/resource content).
|
const agentParts: AtCommandPart[] = [];
|
||||||
*/
|
const resourceParts: AtCommandPart[] = [];
|
||||||
export async function handleAtCommand({
|
const fileParts: AtCommandPart[] = [];
|
||||||
query,
|
|
||||||
config,
|
const agentRegistry = config.getAgentRegistry?.();
|
||||||
addItem,
|
|
||||||
onDebugMessage,
|
|
||||||
messageId: userMessageTimestamp,
|
|
||||||
signal,
|
|
||||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
|
||||||
const resourceRegistry = config.getResourceRegistry();
|
const resourceRegistry = config.getResourceRegistry();
|
||||||
const mcpClientManager = config.getMcpClientManager();
|
|
||||||
|
|
||||||
const commandParts = parseAllAtCommands(query);
|
for (const part of commandParts) {
|
||||||
const atPathCommandParts = commandParts.filter(
|
if (part.type !== 'atPath' || part.content === '@') {
|
||||||
(part) => part.type === 'atPath',
|
continue;
|
||||||
);
|
}
|
||||||
|
|
||||||
if (atPathCommandParts.length === 0) {
|
const name = part.content.substring(1);
|
||||||
return { processedQuery: [{ text: query }] };
|
|
||||||
|
if (agentRegistry?.getDefinition(name)) {
|
||||||
|
agentParts.push(part);
|
||||||
|
} else if (resourceRegistry.findResourceByUri(name)) {
|
||||||
|
resourceParts.push(part);
|
||||||
|
} else {
|
||||||
|
fileParts.push(part);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get centralized file discovery service
|
return { agentParts, resourceParts, fileParts };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedFile {
|
||||||
|
part: AtCommandPart;
|
||||||
|
pathSpec: string;
|
||||||
|
displayLabel: string;
|
||||||
|
absolutePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IgnoredFile {
|
||||||
|
path: string;
|
||||||
|
reason: 'git' | 'gemini' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves file paths from @ commands, handling globs, recursion, and ignores.
|
||||||
|
*/
|
||||||
|
async function resolveFilePaths(
|
||||||
|
fileParts: AtCommandPart[],
|
||||||
|
config: Config,
|
||||||
|
onDebugMessage: (message: string) => void,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<{ resolvedFiles: ResolvedFile[]; ignoredFiles: IgnoredFile[] }> {
|
||||||
const fileDiscovery = config.getFileService();
|
const fileDiscovery = config.getFileService();
|
||||||
|
|
||||||
const respectFileIgnore = config.getFileFilteringOptions();
|
const respectFileIgnore = config.getFileFilteringOptions();
|
||||||
|
|
||||||
const pathSpecsToRead: string[] = [];
|
|
||||||
const resourceAttachments: DiscoveredMCPResource[] = [];
|
|
||||||
const atPathToResolvedSpecMap = new Map<string, string>();
|
|
||||||
const agentsFound: string[] = [];
|
|
||||||
const fileLabelsForDisplay: string[] = [];
|
|
||||||
const absoluteToRelativePathMap = new Map<string, string>();
|
|
||||||
const ignoredByReason: Record<string, string[]> = {
|
|
||||||
git: [],
|
|
||||||
gemini: [],
|
|
||||||
both: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const toolRegistry = config.getToolRegistry();
|
const toolRegistry = config.getToolRegistry();
|
||||||
const readManyFilesTool = new ReadManyFilesTool(
|
|
||||||
config,
|
|
||||||
config.getMessageBus(),
|
|
||||||
);
|
|
||||||
const globTool = toolRegistry.getTool('glob');
|
const globTool = toolRegistry.getTool('glob');
|
||||||
|
|
||||||
if (!readManyFilesTool) {
|
const resolvedFiles: ResolvedFile[] = [];
|
||||||
addItem(
|
const ignoredFiles: IgnoredFile[] = [];
|
||||||
{ type: 'error', text: 'Error: read_many_files tool not found.' },
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
processedQuery: null,
|
|
||||||
error: 'Error: read_many_files tool not found.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const atPathPart of atPathCommandParts) {
|
|
||||||
const originalAtPath = atPathPart.content; // e.g., "@file.txt" or "@"
|
|
||||||
|
|
||||||
if (originalAtPath === '@') {
|
|
||||||
onDebugMessage(
|
|
||||||
'Lone @ detected, will be treated as text in the modified query.',
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for (const part of fileParts) {
|
||||||
|
const originalAtPath = part.content;
|
||||||
const pathName = originalAtPath.substring(1);
|
const pathName = originalAtPath.substring(1);
|
||||||
|
|
||||||
if (!pathName) {
|
if (!pathName) {
|
||||||
// This case should ideally not be hit if parseAllAtCommands ensures content after @
|
|
||||||
// but as a safeguard:
|
|
||||||
const errMsg = `Error: Invalid @ command '${originalAtPath}'. No path specified.`;
|
|
||||||
addItem(
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
text: errMsg,
|
|
||||||
},
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
// Decide if this is a fatal error for the whole command or just skip this @ part
|
|
||||||
// For now, let's be strict and fail the command if one @path is malformed.
|
|
||||||
return { processedQuery: null, error: errMsg };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an Agent reference
|
|
||||||
const agentRegistry = config.getAgentRegistry?.();
|
|
||||||
if (agentRegistry?.getDefinition(pathName)) {
|
|
||||||
agentsFound.push(pathName);
|
|
||||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is an MCP resource reference (serverName:uri format)
|
|
||||||
const resourceMatch = resourceRegistry.findResourceByUri(pathName);
|
|
||||||
if (resourceMatch) {
|
|
||||||
resourceAttachments.push(resourceMatch);
|
|
||||||
atPathToResolvedSpecMap.set(originalAtPath, pathName);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +216,7 @@ export async function handleAtCommand({
|
|||||||
if (gitIgnored || geminiIgnored) {
|
if (gitIgnored || geminiIgnored) {
|
||||||
const reason =
|
const reason =
|
||||||
gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
|
gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
|
||||||
ignoredByReason[reason].push(pathName);
|
ignoredFiles.push({ path: pathName, reason });
|
||||||
const reasonText =
|
const reasonText =
|
||||||
reason === 'both'
|
reason === 'both'
|
||||||
? 'ignored by both git and gemini'
|
? 'ignored by both git and gemini'
|
||||||
@@ -269,33 +228,39 @@ export async function handleAtCommand({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
||||||
let currentPathSpec = pathName;
|
|
||||||
let resolvedSuccessfully = false;
|
|
||||||
let relativePath = pathName;
|
|
||||||
try {
|
try {
|
||||||
const absolutePath = path.isAbsolute(pathName)
|
const absolutePath = path.isAbsolute(pathName)
|
||||||
? pathName
|
? pathName
|
||||||
: path.resolve(dir, pathName);
|
: path.resolve(dir, pathName);
|
||||||
const stats = await fs.stat(absolutePath);
|
const stats = await fs.stat(absolutePath);
|
||||||
|
|
||||||
// Convert absolute path to relative path
|
const relativePath = path.isAbsolute(pathName)
|
||||||
relativePath = path.isAbsolute(pathName)
|
|
||||||
? path.relative(dir, absolutePath)
|
? path.relative(dir, absolutePath)
|
||||||
: pathName;
|
: pathName;
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
currentPathSpec = path.join(relativePath, '**');
|
const pathSpec = path.join(relativePath, '**');
|
||||||
|
resolvedFiles.push({
|
||||||
|
part,
|
||||||
|
pathSpec,
|
||||||
|
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||||
|
absolutePath,
|
||||||
|
});
|
||||||
onDebugMessage(
|
onDebugMessage(
|
||||||
`Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`,
|
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
currentPathSpec = relativePath;
|
resolvedFiles.push({
|
||||||
absoluteToRelativePathMap.set(absolutePath, relativePath);
|
part,
|
||||||
|
pathSpec: relativePath,
|
||||||
|
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
||||||
|
absolutePath,
|
||||||
|
});
|
||||||
onDebugMessage(
|
onDebugMessage(
|
||||||
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
|
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
resolvedSuccessfully = true;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||||
if (config.getEnableRecursiveFileSearch() && globTool) {
|
if (config.getEnableRecursiveFileSearch() && globTool) {
|
||||||
@@ -319,15 +284,18 @@ export async function handleAtCommand({
|
|||||||
const lines = globResult.llmContent.split('\n');
|
const lines = globResult.llmContent.split('\n');
|
||||||
if (lines.length > 1 && lines[1]) {
|
if (lines.length > 1 && lines[1]) {
|
||||||
const firstMatchAbsolute = lines[1].trim();
|
const firstMatchAbsolute = lines[1].trim();
|
||||||
currentPathSpec = path.relative(dir, firstMatchAbsolute);
|
const pathSpec = path.relative(dir, firstMatchAbsolute);
|
||||||
absoluteToRelativePathMap.set(
|
resolvedFiles.push({
|
||||||
firstMatchAbsolute,
|
part,
|
||||||
currentPathSpec,
|
pathSpec,
|
||||||
);
|
displayLabel: path.isAbsolute(pathName)
|
||||||
|
? pathSpec
|
||||||
|
: pathName,
|
||||||
|
});
|
||||||
onDebugMessage(
|
onDebugMessage(
|
||||||
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`,
|
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
|
||||||
);
|
);
|
||||||
resolvedSuccessfully = true;
|
break;
|
||||||
} else {
|
} else {
|
||||||
onDebugMessage(
|
onDebugMessage(
|
||||||
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
||||||
@@ -360,112 +328,67 @@ export async function handleAtCommand({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (resolvedSuccessfully) {
|
|
||||||
pathSpecsToRead.push(currentPathSpec);
|
|
||||||
atPathToResolvedSpecMap.set(originalAtPath, currentPathSpec);
|
|
||||||
const displayPath = path.isAbsolute(pathName) ? relativePath : pathName;
|
|
||||||
fileLabelsForDisplay.push(displayPath);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the initial part of the query for the LLM
|
return { resolvedFiles, ignoredFiles };
|
||||||
let initialQueryText = '';
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the user query, replacing @ commands with their resolved path specs or agent/resource names.
|
||||||
|
*/
|
||||||
|
function constructInitialQuery(
|
||||||
|
commandParts: AtCommandPart[],
|
||||||
|
resolvedFiles: ResolvedFile[],
|
||||||
|
): string {
|
||||||
|
const replacementMap = new Map<AtCommandPart, string>();
|
||||||
|
for (const rf of resolvedFiles) {
|
||||||
|
replacementMap.set(rf.part, rf.pathSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
for (let i = 0; i < commandParts.length; i++) {
|
for (let i = 0; i < commandParts.length; i++) {
|
||||||
const part = commandParts[i];
|
const part = commandParts[i];
|
||||||
if (part.type === 'text') {
|
let content = part.content;
|
||||||
initialQueryText += part.content;
|
|
||||||
} else {
|
if (part.type === 'atPath') {
|
||||||
// type === 'atPath'
|
const resolved = replacementMap.get(part);
|
||||||
const resolvedSpec = atPathToResolvedSpecMap.get(part.content);
|
content = resolved ? `@${resolved}` : part.content;
|
||||||
if (
|
|
||||||
i > 0 &&
|
if (i > 0 && result.length > 0 && !result.endsWith(' ')) {
|
||||||
initialQueryText.length > 0 &&
|
result += ' ';
|
||||||
!initialQueryText.endsWith(' ')
|
|
||||||
) {
|
|
||||||
// Add space if previous part was text and didn't end with space, or if previous was @path
|
|
||||||
const prevPart = commandParts[i - 1];
|
|
||||||
if (
|
|
||||||
prevPart.type === 'text' ||
|
|
||||||
(prevPart.type === 'atPath' &&
|
|
||||||
atPathToResolvedSpecMap.has(prevPart.content))
|
|
||||||
) {
|
|
||||||
initialQueryText += ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (resolvedSpec) {
|
|
||||||
initialQueryText += `@${resolvedSpec}`;
|
|
||||||
} else {
|
|
||||||
// If not resolved for reading (e.g. lone @ or invalid path that was skipped),
|
|
||||||
// add the original @-string back, ensuring spacing if it's not the first element.
|
|
||||||
if (
|
|
||||||
i > 0 &&
|
|
||||||
initialQueryText.length > 0 &&
|
|
||||||
!initialQueryText.endsWith(' ') &&
|
|
||||||
!part.content.startsWith(' ')
|
|
||||||
) {
|
|
||||||
initialQueryText += ' ';
|
|
||||||
}
|
|
||||||
initialQueryText += part.content;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result += content;
|
||||||
}
|
}
|
||||||
initialQueryText = initialQueryText.trim();
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
// Inform user about ignored paths
|
/**
|
||||||
const totalIgnored =
|
* Reads content from MCP resources.
|
||||||
ignoredByReason['git'].length +
|
*/
|
||||||
ignoredByReason['gemini'].length +
|
async function readMcpResources(
|
||||||
ignoredByReason['both'].length;
|
resourceParts: AtCommandPart[],
|
||||||
|
config: Config,
|
||||||
|
): Promise<{
|
||||||
|
parts: PartUnion[];
|
||||||
|
displays: IndividualToolCallDisplay[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const resourceRegistry = config.getResourceRegistry();
|
||||||
|
const mcpClientManager = config.getMcpClientManager();
|
||||||
|
const parts: PartUnion[] = [];
|
||||||
|
const displays: IndividualToolCallDisplay[] = [];
|
||||||
|
|
||||||
if (totalIgnored > 0) {
|
const resourcePromises = resourceParts.map(async (part) => {
|
||||||
const messages = [];
|
const uri = part.content.substring(1);
|
||||||
if (ignoredByReason['git'].length) {
|
const resource = resourceRegistry.findResourceByUri(uri);
|
||||||
messages.push(`Git-ignored: ${ignoredByReason['git'].join(', ')}`);
|
if (!resource) {
|
||||||
}
|
// Should not happen as it was categorized as a resource
|
||||||
if (ignoredByReason['gemini'].length) {
|
return { success: false, parts: [], uri };
|
||||||
messages.push(`Gemini-ignored: ${ignoredByReason['gemini'].join(', ')}`);
|
|
||||||
}
|
|
||||||
if (ignoredByReason['both'].length) {
|
|
||||||
messages.push(`Ignored by both: ${ignoredByReason['both'].join(', ')}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`;
|
|
||||||
debugLogger.log(message);
|
|
||||||
onDebugMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
|
|
||||||
if (
|
|
||||||
pathSpecsToRead.length === 0 &&
|
|
||||||
resourceAttachments.length === 0 &&
|
|
||||||
agentsFound.length === 0
|
|
||||||
) {
|
|
||||||
onDebugMessage('No valid file paths found in @ commands to read.');
|
|
||||||
if (initialQueryText === '@' && query.trim() === '@') {
|
|
||||||
// If the only thing was a lone @, pass original query (which might have spaces)
|
|
||||||
return { processedQuery: [{ text: query }] };
|
|
||||||
} else if (!initialQueryText && query) {
|
|
||||||
// If all @-commands were invalid and no surrounding text, pass original query
|
|
||||||
return { processedQuery: [{ text: query }] };
|
|
||||||
}
|
|
||||||
// Otherwise, proceed with the (potentially modified) query text that doesn't involve file reading
|
|
||||||
return { processedQuery: [{ text: initialQueryText || query }] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
|
|
||||||
|
|
||||||
if (agentsFound.length > 0) {
|
|
||||||
const toolsList = agentsFound.map((agent) => `'${agent}'`).join(', ');
|
|
||||||
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentsFound.join(
|
|
||||||
', ',
|
|
||||||
)}. Please use the following tool(s) to delegate the task: ${toolsList}.\n</system_note>\n`;
|
|
||||||
processedQueryParts.push({ text: agentNudge });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourcePromises = resourceAttachments.map(async (resource) => {
|
|
||||||
const uri = resource.uri;
|
|
||||||
const client = mcpClientManager?.getClient(resource.serverName);
|
const client = mcpClientManager?.getClient(resource.serverName);
|
||||||
try {
|
try {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -473,18 +396,18 @@ export async function handleAtCommand({
|
|||||||
`MCP client for server '${resource.serverName}' is not available or not connected.`,
|
`MCP client for server '${resource.serverName}' is not available or not connected.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const response = await client.readResource(uri);
|
const response = await client.readResource(resource.uri);
|
||||||
const parts = convertResourceContentsToParts(response);
|
const resourceParts = convertResourceContentsToParts(response);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
parts,
|
parts: resourceParts,
|
||||||
uri,
|
uri: resource.uri,
|
||||||
display: {
|
display: {
|
||||||
callId: `mcp-resource-${resource.serverName}-${uri}`,
|
callId: `mcp-resource-${resource.serverName}-${resource.uri}`,
|
||||||
name: `resources/read (${resource.serverName})`,
|
name: `resources/read (${resource.serverName})`,
|
||||||
description: uri,
|
description: resource.uri,
|
||||||
status: ToolCallStatus.Success,
|
status: ToolCallStatus.Success,
|
||||||
resultDisplay: `Successfully read resource ${uri}`,
|
resultDisplay: `Successfully read resource ${resource.uri}`,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
} as IndividualToolCallDisplay,
|
} as IndividualToolCallDisplay,
|
||||||
};
|
};
|
||||||
@@ -492,13 +415,13 @@ export async function handleAtCommand({
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
parts: [],
|
parts: [],
|
||||||
uri,
|
uri: resource.uri,
|
||||||
display: {
|
display: {
|
||||||
callId: `mcp-resource-${resource.serverName}-${uri}`,
|
callId: `mcp-resource-${resource.serverName}-${resource.uri}`,
|
||||||
name: `resources/read (${resource.serverName})`,
|
name: `resources/read (${resource.serverName})`,
|
||||||
description: uri,
|
description: resource.uri,
|
||||||
status: ToolCallStatus.Error,
|
status: ToolCallStatus.Error,
|
||||||
resultDisplay: `Error reading resource ${uri}: ${getErrorMessage(error)}`,
|
resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
} as IndividualToolCallDisplay,
|
} as IndividualToolCallDisplay,
|
||||||
};
|
};
|
||||||
@@ -506,77 +429,71 @@ export async function handleAtCommand({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resourceResults = await Promise.all(resourcePromises);
|
const resourceResults = await Promise.all(resourcePromises);
|
||||||
const resourceReadDisplays: IndividualToolCallDisplay[] = [];
|
let hasError = false;
|
||||||
let resourceErrorOccurred = false;
|
|
||||||
let hasAddedReferenceHeader = false;
|
|
||||||
|
|
||||||
for (const result of resourceResults) {
|
for (const result of resourceResults) {
|
||||||
resourceReadDisplays.push(result.display);
|
if (result.display) {
|
||||||
|
displays.push(result.display);
|
||||||
|
}
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (!hasAddedReferenceHeader) {
|
parts.push({ text: `\nContent from @${result.uri}:\n` });
|
||||||
processedQueryParts.push({
|
parts.push(...result.parts);
|
||||||
text: REF_CONTENT_HEADER,
|
|
||||||
});
|
|
||||||
hasAddedReferenceHeader = true;
|
|
||||||
}
|
|
||||||
processedQueryParts.push({ text: `\nContent from @${result.uri}:\n` });
|
|
||||||
processedQueryParts.push(...result.parts);
|
|
||||||
} else {
|
} else {
|
||||||
resourceErrorOccurred = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceErrorOccurred) {
|
if (hasError) {
|
||||||
addItem(
|
const firstError = displays.find((d) => d.status === ToolCallStatus.Error);
|
||||||
{ type: 'tool_group', tools: resourceReadDisplays } as Omit<
|
return {
|
||||||
HistoryItem,
|
parts: [],
|
||||||
'id'
|
displays,
|
||||||
>,
|
error: `Exiting due to an error processing the @ command: ${firstError?.resultDisplay}`,
|
||||||
userMessageTimestamp,
|
};
|
||||||
);
|
|
||||||
// Find the first error to report
|
|
||||||
const firstError = resourceReadDisplays.find(
|
|
||||||
(d) => d.status === ToolCallStatus.Error,
|
|
||||||
)!;
|
|
||||||
const errorMessages = resourceReadDisplays
|
|
||||||
.filter((d) => d.status === ToolCallStatus.Error)
|
|
||||||
.map((d) => d.resultDisplay);
|
|
||||||
debugLogger.error(errorMessages);
|
|
||||||
const errorMsg = `Exiting due to an error processing the @ command: ${firstError.resultDisplay}`;
|
|
||||||
return { processedQuery: null, error: errorMsg };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathSpecsToRead.length === 0) {
|
return { parts, displays };
|
||||||
if (resourceReadDisplays.length > 0) {
|
}
|
||||||
addItem(
|
|
||||||
{ type: 'tool_group', tools: resourceReadDisplays } as Omit<
|
/**
|
||||||
HistoryItem,
|
* Reads content from local files using the ReadManyFilesTool.
|
||||||
'id'
|
*/
|
||||||
>,
|
async function readLocalFiles(
|
||||||
userMessageTimestamp,
|
resolvedFiles: ResolvedFile[],
|
||||||
);
|
config: Config,
|
||||||
}
|
signal: AbortSignal,
|
||||||
if (hasAddedReferenceHeader) {
|
userMessageTimestamp: number,
|
||||||
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
|
): Promise<{
|
||||||
}
|
parts: PartUnion[];
|
||||||
return { processedQuery: processedQueryParts };
|
display?: IndividualToolCallDisplay;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
if (resolvedFiles.length === 0) {
|
||||||
|
return { parts: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readManyFilesTool = new ReadManyFilesTool(
|
||||||
|
config,
|
||||||
|
config.getMessageBus(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathSpecsToRead = resolvedFiles.map((rf) => rf.pathSpec);
|
||||||
|
const fileLabelsForDisplay = resolvedFiles.map((rf) => rf.displayLabel);
|
||||||
|
const respectFileIgnore = config.getFileFilteringOptions();
|
||||||
|
|
||||||
const toolArgs = {
|
const toolArgs = {
|
||||||
include: pathSpecsToRead,
|
include: pathSpecsToRead,
|
||||||
file_filtering_options: {
|
file_filtering_options: {
|
||||||
respect_git_ignore: respectFileIgnore.respectGitIgnore,
|
respect_git_ignore: respectFileIgnore.respectGitIgnore,
|
||||||
respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
|
respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
|
||||||
},
|
},
|
||||||
// Use configuration setting
|
|
||||||
};
|
};
|
||||||
let readManyFilesDisplay: IndividualToolCallDisplay | undefined;
|
|
||||||
|
|
||||||
let invocation: AnyToolInvocation | undefined = undefined;
|
let invocation: AnyToolInvocation | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
invocation = readManyFilesTool.build(toolArgs);
|
invocation = readManyFilesTool.build(toolArgs);
|
||||||
const result = await invocation.execute(signal);
|
const result = await invocation.execute(signal);
|
||||||
readManyFilesDisplay = {
|
const display: IndividualToolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
description: invocation.getDescription(),
|
description: invocation.getDescription(),
|
||||||
@@ -587,14 +504,9 @@ export async function handleAtCommand({
|
|||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parts: PartUnion[] = [];
|
||||||
if (Array.isArray(result.llmContent)) {
|
if (Array.isArray(result.llmContent)) {
|
||||||
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
||||||
if (!hasAddedReferenceHeader) {
|
|
||||||
processedQueryParts.push({
|
|
||||||
text: REF_CONTENT_HEADER,
|
|
||||||
});
|
|
||||||
hasAddedReferenceHeader = true;
|
|
||||||
}
|
|
||||||
for (const part of result.llmContent) {
|
for (const part of result.llmContent) {
|
||||||
if (typeof part === 'string') {
|
if (typeof part === 'string') {
|
||||||
const match = fileContentRegex.exec(part);
|
const match = fileContentRegex.exec(part);
|
||||||
@@ -602,12 +514,17 @@ export async function handleAtCommand({
|
|||||||
const filePathSpecInContent = match[1];
|
const filePathSpecInContent = match[1];
|
||||||
const fileActualContent = match[2].trim();
|
const fileActualContent = match[2].trim();
|
||||||
|
|
||||||
let displayPath = absoluteToRelativePathMap.get(
|
// Find the display label for this path
|
||||||
filePathSpecInContent,
|
const resolvedFile = resolvedFiles.find(
|
||||||
|
(rf) =>
|
||||||
|
rf.absolutePath === filePathSpecInContent ||
|
||||||
|
rf.pathSpec === filePathSpecInContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fallback: if no mapping found, try to convert absolute path to relative
|
let displayPath = resolvedFile?.displayLabel;
|
||||||
|
|
||||||
if (!displayPath) {
|
if (!displayPath) {
|
||||||
|
// Fallback: if no mapping found, try to convert absolute path to relative
|
||||||
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
||||||
if (filePathSpecInContent.startsWith(dir)) {
|
if (filePathSpecInContent.startsWith(dir)) {
|
||||||
displayPath = path.relative(dir, filePathSpecInContent);
|
displayPath = path.relative(dir, filePathSpecInContent);
|
||||||
@@ -618,39 +535,22 @@ export async function handleAtCommand({
|
|||||||
|
|
||||||
displayPath = displayPath || filePathSpecInContent;
|
displayPath = displayPath || filePathSpecInContent;
|
||||||
|
|
||||||
processedQueryParts.push({
|
parts.push({
|
||||||
text: `\nContent from @${displayPath}:\n`,
|
text: `\nContent from @${displayPath}:\n`,
|
||||||
});
|
});
|
||||||
processedQueryParts.push({ text: fileActualContent });
|
parts.push({ text: fileActualContent });
|
||||||
} else {
|
} else {
|
||||||
processedQueryParts.push({ text: part });
|
parts.push({ text: part });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// part is a Part object.
|
parts.push(part);
|
||||||
processedQueryParts.push(part);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
onDebugMessage(
|
|
||||||
'read_many_files tool returned no content or empty content.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceReadDisplays.length > 0 || readManyFilesDisplay) {
|
return { parts, display };
|
||||||
addItem(
|
|
||||||
{
|
|
||||||
type: 'tool_group',
|
|
||||||
tools: [
|
|
||||||
...resourceReadDisplays,
|
|
||||||
...(readManyFilesDisplay ? [readManyFilesDisplay] : []),
|
|
||||||
],
|
|
||||||
} as Omit<HistoryItem, 'id'>,
|
|
||||||
userMessageTimestamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { processedQuery: processedQueryParts };
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
readManyFilesDisplay = {
|
const errorDisplay: IndividualToolCallDisplay = {
|
||||||
callId: `client-read-${userMessageTimestamp}`,
|
callId: `client-read-${userMessageTimestamp}`,
|
||||||
name: readManyFilesTool.displayName,
|
name: readManyFilesTool.displayName,
|
||||||
description:
|
description:
|
||||||
@@ -660,18 +560,153 @@ export async function handleAtCommand({
|
|||||||
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
||||||
confirmationDetails: undefined,
|
confirmationDetails: undefined,
|
||||||
};
|
};
|
||||||
|
return {
|
||||||
|
parts: [],
|
||||||
|
display: errorDisplay,
|
||||||
|
error: `Exiting due to an error processing the @ command: ${errorDisplay.resultDisplay}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports ignored files to the debug log and debug message callback.
|
||||||
|
*/
|
||||||
|
function reportIgnoredFiles(
|
||||||
|
ignoredFiles: IgnoredFile[],
|
||||||
|
onDebugMessage: (message: string) => void,
|
||||||
|
): void {
|
||||||
|
const totalIgnored = ignoredFiles.length;
|
||||||
|
if (totalIgnored === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ignoredByReason: Record<string, string[]> = {
|
||||||
|
git: [],
|
||||||
|
gemini: [],
|
||||||
|
both: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const file of ignoredFiles) {
|
||||||
|
ignoredByReason[file.reason].push(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
if (ignoredByReason['git'].length) {
|
||||||
|
messages.push(`Git-ignored: ${ignoredByReason['git'].join(', ')}`);
|
||||||
|
}
|
||||||
|
if (ignoredByReason['gemini'].length) {
|
||||||
|
messages.push(`Gemini-ignored: ${ignoredByReason['gemini'].join(', ')}`);
|
||||||
|
}
|
||||||
|
if (ignoredByReason['both'].length) {
|
||||||
|
messages.push(`Ignored by both: ${ignoredByReason['both'].join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `Ignored ${totalIgnored} files:\n${messages.join('\n')}`;
|
||||||
|
debugLogger.log(message);
|
||||||
|
onDebugMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes user input containing one or more '@<path>' commands.
|
||||||
|
* - Workspace paths are read via the 'read_many_files' tool.
|
||||||
|
* - MCP resource URIs are read via each server's `resources/read`.
|
||||||
|
* The user query is updated with inline content blocks so the LLM receives the
|
||||||
|
* referenced context directly.
|
||||||
|
*
|
||||||
|
* @returns An object indicating whether the main hook should proceed with an
|
||||||
|
* LLM call and the processed query parts (including file/resource content).
|
||||||
|
*/
|
||||||
|
export async function handleAtCommand({
|
||||||
|
query,
|
||||||
|
config,
|
||||||
|
addItem,
|
||||||
|
onDebugMessage,
|
||||||
|
messageId: userMessageTimestamp,
|
||||||
|
signal,
|
||||||
|
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||||
|
const commandParts = parseAllAtCommands(query);
|
||||||
|
|
||||||
|
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
|
||||||
|
commandParts,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolvedFiles, ignoredFiles } = await resolveFilePaths(
|
||||||
|
fileParts,
|
||||||
|
config,
|
||||||
|
onDebugMessage,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
reportIgnoredFiles(ignoredFiles, onDebugMessage);
|
||||||
|
|
||||||
|
if (
|
||||||
|
resolvedFiles.length === 0 &&
|
||||||
|
resourceParts.length === 0 &&
|
||||||
|
agentParts.length === 0
|
||||||
|
) {
|
||||||
|
onDebugMessage(
|
||||||
|
'No valid file paths, resources, or agents found in @ commands.',
|
||||||
|
);
|
||||||
|
return { processedQuery: [{ text: query }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialQueryText = constructInitialQuery(commandParts, resolvedFiles);
|
||||||
|
|
||||||
|
const processedQueryParts: PartListUnion = [{ text: initialQueryText }];
|
||||||
|
|
||||||
|
if (agentParts.length > 0) {
|
||||||
|
const agentNames = agentParts.map((p) => p.content.substring(1));
|
||||||
|
const toolsList = agentNames.map((agent) => `'${agent}'`).join(', ');
|
||||||
|
const agentNudge = `\n<system_note>\nThe user has explicitly selected the following agent(s): ${agentNames.join(
|
||||||
|
', ',
|
||||||
|
)}. Please use the following tool(s) to delegate the task: ${toolsList}.\n</system_note>\n`;
|
||||||
|
processedQueryParts.push({ text: agentNudge });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [mcpResult, fileResult] = await Promise.all([
|
||||||
|
readMcpResources(resourceParts, config),
|
||||||
|
readLocalFiles(resolvedFiles, config, signal, userMessageTimestamp),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasContent = mcpResult.parts.length > 0 || fileResult.parts.length > 0;
|
||||||
|
if (hasContent) {
|
||||||
|
processedQueryParts.push({ text: REF_CONTENT_HEADER });
|
||||||
|
processedQueryParts.push(...mcpResult.parts);
|
||||||
|
processedQueryParts.push(...fileResult.parts);
|
||||||
|
|
||||||
|
// Only add footer if we didn't read local files (because ReadManyFilesTool adds it)
|
||||||
|
// AND we read MCP resources (so we need to close the block).
|
||||||
|
if (fileResult.parts.length === 0 && mcpResult.parts.length > 0) {
|
||||||
|
processedQueryParts.push({ text: REF_CONTENT_FOOTER });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDisplays = [
|
||||||
|
...mcpResult.displays,
|
||||||
|
...(fileResult.display ? [fileResult.display] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (allDisplays.length > 0) {
|
||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: 'tool_group',
|
type: 'tool_group',
|
||||||
tools: [...resourceReadDisplays, readManyFilesDisplay],
|
tools: allDisplays,
|
||||||
} as Omit<HistoryItem, 'id'>,
|
} as Omit<HistoryItem, 'id'>,
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
return {
|
|
||||||
processedQuery: null,
|
|
||||||
error: `Exiting due to an error processing the @ command: ${readManyFilesDisplay.resultDisplay}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mcpResult.error) {
|
||||||
|
debugLogger.error(mcpResult.error);
|
||||||
|
return { processedQuery: null, error: mcpResult.error };
|
||||||
|
}
|
||||||
|
if (fileResult.error) {
|
||||||
|
debugLogger.error(fileResult.error);
|
||||||
|
return { processedQuery: null, error: fileResult.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processedQuery: processedQueryParts };
|
||||||
}
|
}
|
||||||
|
|
||||||
function convertResourceContentsToParts(response: {
|
function convertResourceContentsToParts(response: {
|
||||||
@@ -686,20 +721,20 @@ function convertResourceContentsToParts(response: {
|
|||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}): PartUnion[] {
|
}): PartUnion[] {
|
||||||
const parts: PartUnion[] = [];
|
return (response.contents ?? []).flatMap((content) => {
|
||||||
for (const content of response.contents ?? []) {
|
|
||||||
const candidate = content.resource ?? content;
|
const candidate = content.resource ?? content;
|
||||||
if (candidate.text) {
|
if (candidate.text) {
|
||||||
parts.push({ text: candidate.text });
|
return [{ text: candidate.text }];
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if (candidate.blob) {
|
if (candidate.blob) {
|
||||||
const sizeBytes = Buffer.from(candidate.blob, 'base64').length;
|
const sizeBytes = Buffer.from(candidate.blob, 'base64').length;
|
||||||
const mimeType = candidate.mimeType ?? 'application/octet-stream';
|
const mimeType = candidate.mimeType ?? 'application/octet-stream';
|
||||||
parts.push({
|
return [
|
||||||
text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`,
|
{
|
||||||
});
|
text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
return [];
|
||||||
return parts;
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user