mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 14:40:52 -07:00
741 lines
22 KiB
TypeScript
741 lines
22 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import type { PartListUnion, PartUnion } from '@google/genai';
|
|
import type { AnyToolInvocation, Config } from '@google/gemini-cli-core';
|
|
import {
|
|
debugLogger,
|
|
getErrorMessage,
|
|
isNodeError,
|
|
unescapePath,
|
|
ReadManyFilesTool,
|
|
REFERENCE_CONTENT_START,
|
|
REFERENCE_CONTENT_END,
|
|
} from '@google/gemini-cli-core';
|
|
import { Buffer } from 'node:buffer';
|
|
import type { HistoryItem, IndividualToolCallDisplay } from '../types.js';
|
|
import { ToolCallStatus } from '../types.js';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
|
|
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
|
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
|
|
|
interface HandleAtCommandParams {
|
|
query: string;
|
|
config: Config;
|
|
addItem: UseHistoryManagerReturn['addItem'];
|
|
onDebugMessage: (message: string) => void;
|
|
messageId: number;
|
|
signal: AbortSignal;
|
|
}
|
|
|
|
interface HandleAtCommandResult {
|
|
processedQuery: PartListUnion | null;
|
|
error?: string;
|
|
}
|
|
|
|
interface AtCommandPart {
|
|
type: 'text' | 'atPath';
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* Parses a query string to find all '@<path>' commands and text segments.
|
|
* Handles \ escaped spaces within paths.
|
|
*/
|
|
function parseAllAtCommands(query: string): AtCommandPart[] {
|
|
const parts: AtCommandPart[] = [];
|
|
let currentIndex = 0;
|
|
|
|
while (currentIndex < query.length) {
|
|
let atIndex = -1;
|
|
let nextSearchIndex = currentIndex;
|
|
// Find next unescaped '@'
|
|
while (nextSearchIndex < query.length) {
|
|
if (
|
|
query[nextSearchIndex] === '@' &&
|
|
(nextSearchIndex === 0 || query[nextSearchIndex - 1] !== '\\')
|
|
) {
|
|
atIndex = nextSearchIndex;
|
|
break;
|
|
}
|
|
nextSearchIndex++;
|
|
}
|
|
|
|
if (atIndex === -1) {
|
|
// No more @
|
|
if (currentIndex < query.length) {
|
|
parts.push({ type: 'text', content: query.substring(currentIndex) });
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Add text before @
|
|
if (atIndex > currentIndex) {
|
|
parts.push({
|
|
type: 'text',
|
|
content: query.substring(currentIndex, atIndex),
|
|
});
|
|
}
|
|
|
|
// Parse @path
|
|
let pathEndIndex = atIndex + 1;
|
|
let inEscape = false;
|
|
while (pathEndIndex < query.length) {
|
|
const char = query[pathEndIndex];
|
|
if (inEscape) {
|
|
inEscape = false;
|
|
} else if (char === '\\') {
|
|
inEscape = true;
|
|
} else if (/[,\s;!?()[\]{}]/.test(char)) {
|
|
// Path ends at first whitespace or punctuation not escaped
|
|
break;
|
|
} else if (char === '.') {
|
|
// For . we need to be more careful - only terminate if followed by whitespace or end of string
|
|
// This allows file extensions like .txt, .js but terminates at sentence endings like "file.txt. Next sentence"
|
|
const nextChar =
|
|
pathEndIndex + 1 < query.length ? query[pathEndIndex + 1] : '';
|
|
if (nextChar === '' || /\s/.test(nextChar)) {
|
|
break;
|
|
}
|
|
}
|
|
pathEndIndex++;
|
|
}
|
|
const rawAtPath = query.substring(atIndex, pathEndIndex);
|
|
// unescapePath expects the @ symbol to be present, and will handle it.
|
|
const atPath = unescapePath(rawAtPath);
|
|
parts.push({ type: 'atPath', content: atPath });
|
|
currentIndex = pathEndIndex;
|
|
}
|
|
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
|
return parts.filter(
|
|
(part) => !(part.type === 'text' && part.content.trim() === ''),
|
|
);
|
|
}
|
|
|
|
function categorizeAtCommands(
|
|
commandParts: AtCommandPart[],
|
|
config: Config,
|
|
): {
|
|
agentParts: AtCommandPart[];
|
|
resourceParts: AtCommandPart[];
|
|
fileParts: AtCommandPart[];
|
|
} {
|
|
const agentParts: AtCommandPart[] = [];
|
|
const resourceParts: AtCommandPart[] = [];
|
|
const fileParts: AtCommandPart[] = [];
|
|
|
|
const agentRegistry = config.getAgentRegistry?.();
|
|
const resourceRegistry = config.getResourceRegistry();
|
|
|
|
for (const part of commandParts) {
|
|
if (part.type !== 'atPath' || part.content === '@') {
|
|
continue;
|
|
}
|
|
|
|
const name = part.content.substring(1);
|
|
|
|
if (agentRegistry?.getDefinition(name)) {
|
|
agentParts.push(part);
|
|
} else if (resourceRegistry.findResourceByUri(name)) {
|
|
resourceParts.push(part);
|
|
} else {
|
|
fileParts.push(part);
|
|
}
|
|
}
|
|
|
|
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 respectFileIgnore = config.getFileFilteringOptions();
|
|
const toolRegistry = config.getToolRegistry();
|
|
const globTool = toolRegistry.getTool('glob');
|
|
|
|
const resolvedFiles: ResolvedFile[] = [];
|
|
const ignoredFiles: IgnoredFile[] = [];
|
|
|
|
for (const part of fileParts) {
|
|
const originalAtPath = part.content;
|
|
const pathName = originalAtPath.substring(1);
|
|
|
|
if (!pathName) {
|
|
continue;
|
|
}
|
|
|
|
const resolvedPathName = path.isAbsolute(pathName)
|
|
? pathName
|
|
: path.resolve(config.getTargetDir(), pathName);
|
|
|
|
if (!config.isPathAllowed(resolvedPathName)) {
|
|
onDebugMessage(
|
|
`Path ${pathName} is not in the workspace and will be skipped.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const gitIgnored =
|
|
respectFileIgnore.respectGitIgnore &&
|
|
fileDiscovery.shouldIgnoreFile(pathName, {
|
|
respectGitIgnore: true,
|
|
respectGeminiIgnore: false,
|
|
});
|
|
const geminiIgnored =
|
|
respectFileIgnore.respectGeminiIgnore &&
|
|
fileDiscovery.shouldIgnoreFile(pathName, {
|
|
respectGitIgnore: false,
|
|
respectGeminiIgnore: true,
|
|
});
|
|
|
|
if (gitIgnored || geminiIgnored) {
|
|
const reason =
|
|
gitIgnored && geminiIgnored ? 'both' : gitIgnored ? 'git' : 'gemini';
|
|
ignoredFiles.push({ path: pathName, reason });
|
|
const reasonText =
|
|
reason === 'both'
|
|
? 'ignored by both git and gemini'
|
|
: reason === 'git'
|
|
? 'git-ignored'
|
|
: 'gemini-ignored';
|
|
onDebugMessage(`Path ${pathName} is ${reasonText} and will be skipped.`);
|
|
continue;
|
|
}
|
|
|
|
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
|
try {
|
|
const absolutePath = path.isAbsolute(pathName)
|
|
? pathName
|
|
: path.resolve(dir, pathName);
|
|
const stats = await fs.stat(absolutePath);
|
|
|
|
const relativePath = path.isAbsolute(pathName)
|
|
? path.relative(dir, absolutePath)
|
|
: pathName;
|
|
|
|
if (stats.isDirectory()) {
|
|
const pathSpec = path.join(relativePath, '**');
|
|
resolvedFiles.push({
|
|
part,
|
|
pathSpec,
|
|
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
|
absolutePath,
|
|
});
|
|
onDebugMessage(
|
|
`Path ${pathName} resolved to directory, using glob: ${pathSpec}`,
|
|
);
|
|
} else {
|
|
resolvedFiles.push({
|
|
part,
|
|
pathSpec: relativePath,
|
|
displayLabel: path.isAbsolute(pathName) ? relativePath : pathName,
|
|
absolutePath,
|
|
});
|
|
onDebugMessage(
|
|
`Path ${pathName} resolved to file: ${absolutePath}, using relative path: ${relativePath}`,
|
|
);
|
|
}
|
|
break;
|
|
} catch (error) {
|
|
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
if (config.getEnableRecursiveFileSearch() && globTool) {
|
|
onDebugMessage(
|
|
`Path ${pathName} not found directly, attempting glob search.`,
|
|
);
|
|
try {
|
|
const globResult = await globTool.buildAndExecute(
|
|
{
|
|
pattern: `**/*${pathName}*`,
|
|
path: dir,
|
|
},
|
|
signal,
|
|
);
|
|
if (
|
|
globResult.llmContent &&
|
|
typeof globResult.llmContent === 'string' &&
|
|
!globResult.llmContent.startsWith('No files found') &&
|
|
!globResult.llmContent.startsWith('Error:')
|
|
) {
|
|
const lines = globResult.llmContent.split('\n');
|
|
if (lines.length > 1 && lines[1]) {
|
|
const firstMatchAbsolute = lines[1].trim();
|
|
const pathSpec = path.relative(dir, firstMatchAbsolute);
|
|
resolvedFiles.push({
|
|
part,
|
|
pathSpec,
|
|
displayLabel: path.isAbsolute(pathName)
|
|
? pathSpec
|
|
: pathName,
|
|
});
|
|
onDebugMessage(
|
|
`Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${pathSpec}`,
|
|
);
|
|
break;
|
|
} else {
|
|
onDebugMessage(
|
|
`Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`,
|
|
);
|
|
}
|
|
} else {
|
|
onDebugMessage(
|
|
`Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`,
|
|
);
|
|
}
|
|
} catch (globError) {
|
|
debugLogger.warn(
|
|
`Error during glob search for ${pathName}: ${getErrorMessage(globError)}`,
|
|
);
|
|
onDebugMessage(
|
|
`Error during glob search for ${pathName}. Path ${pathName} will be skipped.`,
|
|
);
|
|
}
|
|
} else {
|
|
onDebugMessage(
|
|
`Glob tool not found. Path ${pathName} will be skipped.`,
|
|
);
|
|
}
|
|
} else {
|
|
debugLogger.warn(
|
|
`Error stating path ${pathName}: ${getErrorMessage(error)}`,
|
|
);
|
|
onDebugMessage(
|
|
`Error stating path ${pathName}. Path ${pathName} will be skipped.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { resolvedFiles, ignoredFiles };
|
|
}
|
|
|
|
/**
|
|
* 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++) {
|
|
const part = commandParts[i];
|
|
let content = part.content;
|
|
|
|
if (part.type === 'atPath') {
|
|
const resolved = replacementMap.get(part);
|
|
content = resolved ? `@${resolved}` : part.content;
|
|
|
|
if (i > 0 && result.length > 0 && !result.endsWith(' ')) {
|
|
result += ' ';
|
|
}
|
|
}
|
|
|
|
result += content;
|
|
}
|
|
return result.trim();
|
|
}
|
|
|
|
/**
|
|
* Reads content from MCP resources.
|
|
*/
|
|
async function readMcpResources(
|
|
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[] = [];
|
|
|
|
const resourcePromises = resourceParts.map(async (part) => {
|
|
const uri = part.content.substring(1);
|
|
const resource = resourceRegistry.findResourceByUri(uri);
|
|
if (!resource) {
|
|
// Should not happen as it was categorized as a resource
|
|
return { success: false, parts: [], uri };
|
|
}
|
|
|
|
const client = mcpClientManager?.getClient(resource.serverName);
|
|
try {
|
|
if (!client) {
|
|
throw new Error(
|
|
`MCP client for server '${resource.serverName}' is not available or not connected.`,
|
|
);
|
|
}
|
|
const response = await client.readResource(resource.uri);
|
|
const resourceParts = convertResourceContentsToParts(response);
|
|
return {
|
|
success: true,
|
|
parts: resourceParts,
|
|
uri: resource.uri,
|
|
display: {
|
|
callId: `mcp-resource-${resource.serverName}-${resource.uri}`,
|
|
name: `resources/read (${resource.serverName})`,
|
|
description: resource.uri,
|
|
status: ToolCallStatus.Success,
|
|
resultDisplay: `Successfully read resource ${resource.uri}`,
|
|
confirmationDetails: undefined,
|
|
} as IndividualToolCallDisplay,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
parts: [],
|
|
uri: resource.uri,
|
|
display: {
|
|
callId: `mcp-resource-${resource.serverName}-${resource.uri}`,
|
|
name: `resources/read (${resource.serverName})`,
|
|
description: resource.uri,
|
|
status: ToolCallStatus.Error,
|
|
resultDisplay: `Error reading resource ${resource.uri}: ${getErrorMessage(error)}`,
|
|
confirmationDetails: undefined,
|
|
} as IndividualToolCallDisplay,
|
|
};
|
|
}
|
|
});
|
|
|
|
const resourceResults = await Promise.all(resourcePromises);
|
|
let hasError = false;
|
|
|
|
for (const result of resourceResults) {
|
|
if (result.display) {
|
|
displays.push(result.display);
|
|
}
|
|
if (result.success) {
|
|
parts.push({ text: `\nContent from @${result.uri}:\n` });
|
|
parts.push(...result.parts);
|
|
} else {
|
|
hasError = true;
|
|
}
|
|
}
|
|
|
|
if (hasError) {
|
|
const firstError = displays.find((d) => d.status === ToolCallStatus.Error);
|
|
return {
|
|
parts: [],
|
|
displays,
|
|
error: `Exiting due to an error processing the @ command: ${firstError?.resultDisplay}`,
|
|
};
|
|
}
|
|
|
|
return { parts, displays };
|
|
}
|
|
|
|
/**
|
|
* Reads content from local files using the ReadManyFilesTool.
|
|
*/
|
|
async function readLocalFiles(
|
|
resolvedFiles: ResolvedFile[],
|
|
config: Config,
|
|
signal: AbortSignal,
|
|
userMessageTimestamp: number,
|
|
): Promise<{
|
|
parts: PartUnion[];
|
|
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 = {
|
|
include: pathSpecsToRead,
|
|
file_filtering_options: {
|
|
respect_git_ignore: respectFileIgnore.respectGitIgnore,
|
|
respect_gemini_ignore: respectFileIgnore.respectGeminiIgnore,
|
|
},
|
|
};
|
|
|
|
let invocation: AnyToolInvocation | undefined = undefined;
|
|
try {
|
|
invocation = readManyFilesTool.build(toolArgs);
|
|
const result = await invocation.execute(signal);
|
|
const display: IndividualToolCallDisplay = {
|
|
callId: `client-read-${userMessageTimestamp}`,
|
|
name: readManyFilesTool.displayName,
|
|
description: invocation.getDescription(),
|
|
status: ToolCallStatus.Success,
|
|
resultDisplay:
|
|
result.returnDisplay ||
|
|
`Successfully read: ${fileLabelsForDisplay.join(', ')}`,
|
|
confirmationDetails: undefined,
|
|
};
|
|
|
|
const parts: PartUnion[] = [];
|
|
if (Array.isArray(result.llmContent)) {
|
|
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
|
|
for (const part of result.llmContent) {
|
|
if (typeof part === 'string') {
|
|
const match = fileContentRegex.exec(part);
|
|
if (match) {
|
|
const filePathSpecInContent = match[1];
|
|
const fileActualContent = match[2].trim();
|
|
|
|
// Find the display label for this path
|
|
const resolvedFile = resolvedFiles.find(
|
|
(rf) =>
|
|
rf.absolutePath === filePathSpecInContent ||
|
|
rf.pathSpec === filePathSpecInContent,
|
|
);
|
|
|
|
let displayPath = resolvedFile?.displayLabel;
|
|
|
|
if (!displayPath) {
|
|
// Fallback: if no mapping found, try to convert absolute path to relative
|
|
for (const dir of config.getWorkspaceContext().getDirectories()) {
|
|
if (filePathSpecInContent.startsWith(dir)) {
|
|
displayPath = path.relative(dir, filePathSpecInContent);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
displayPath = displayPath || filePathSpecInContent;
|
|
|
|
parts.push({
|
|
text: `\nContent from @${displayPath}:\n`,
|
|
});
|
|
parts.push({ text: fileActualContent });
|
|
} else {
|
|
parts.push({ text: part });
|
|
}
|
|
} else {
|
|
parts.push(part);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { parts, display };
|
|
} catch (error: unknown) {
|
|
const errorDisplay: IndividualToolCallDisplay = {
|
|
callId: `client-read-${userMessageTimestamp}`,
|
|
name: readManyFilesTool.displayName,
|
|
description:
|
|
invocation?.getDescription() ??
|
|
'Error attempting to execute tool to read files',
|
|
status: ToolCallStatus.Error,
|
|
resultDisplay: `Error reading files (${fileLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
|
|
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(
|
|
{
|
|
type: 'tool_group',
|
|
tools: allDisplays,
|
|
} as Omit<HistoryItem, 'id'>,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
|
|
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: {
|
|
contents?: Array<{
|
|
text?: string;
|
|
blob?: string;
|
|
mimeType?: string;
|
|
resource?: {
|
|
text?: string;
|
|
blob?: string;
|
|
mimeType?: string;
|
|
};
|
|
}>;
|
|
}): PartUnion[] {
|
|
return (response.contents ?? []).flatMap((content) => {
|
|
const candidate = content.resource ?? content;
|
|
if (candidate.text) {
|
|
return [{ text: candidate.text }];
|
|
}
|
|
if (candidate.blob) {
|
|
const sizeBytes = Buffer.from(candidate.blob, 'base64').length;
|
|
const mimeType = candidate.mimeType ?? 'application/octet-stream';
|
|
return [
|
|
{
|
|
text: `[Binary resource content ${mimeType}, ${sizeBytes} bytes]`,
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
});
|
|
}
|