blob: 7cf2211f3b12002d6325c287a1a7c1ae85ec93c4 [file]
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
import { basename, extname, join, resolve } from 'node:path';
import { z } from 'zod';
import { isPathInside, toRelative } from './path-containment.js';
import type { MakaTool } from './tool-runtime.js';
export const EXPLORE_AGENT_TOOL_NAME = 'ExploreAgent';
const DEFAULT_MAX_FILES = 30;
const DEFAULT_MAX_MATCHES = 60;
const MAX_ROOTS = 5;
const MAX_QUERIES = 8;
const MAX_IGNORE_PATHS = 20;
const MAX_DISCOVERED_FILES = 250;
const MAX_FILE_BYTES = 512 * 1024;
const MAX_TOTAL_BYTES = 2 * 1024 * 1024;
const MATCH_CONTEXT_CHARS = 220;
const PROJECT_MANIFEST_FILES = new Set([
'package.json',
'pnpm-workspace.yaml',
'turbo.json',
'vite.config.ts',
'vite.config.js',
'tsconfig.json',
'Cargo.toml',
'go.mod',
'pyproject.toml',
'Package.swift',
]);
const DOCUMENTATION_FILES = new Set([
'README.md',
'AGENTS.md',
'CLAUDE.md',
'GEMINI.md',
'CONTRIBUTING.md',
'ARCHITECTURE.md',
]);
const ENTRYPOINT_NAMES = new Set([
'main.ts',
'main.tsx',
'main.js',
'main.jsx',
'index.ts',
'index.tsx',
'index.js',
'index.jsx',
'server.ts',
'server.js',
'app.ts',
'app.tsx',
'app.js',
'app.jsx',
]);
const IGNORED_DIRS = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'dist',
'build',
'coverage',
'.next',
'.turbo',
'.vite',
'.cache',
'.svelte-kit',
'DerivedData',
]);
const TEXT_EXTENSIONS = new Set([
'',
'.c',
'.cc',
'.conf',
'.cpp',
'.css',
'.csv',
'.env',
'.go',
'.h',
'.hpp',
'.html',
'.java',
'.js',
'.json',
'.jsx',
'.lock',
'.log',
'.mjs',
'.md',
'.mdx',
'.py',
'.rb',
'.rs',
'.sh',
'.sql',
'.swift',
'.toml',
'.ts',
'.tsx',
'.txt',
'.vue',
'.xml',
'.yaml',
'.yml',
]);
const SENSITIVE_TEXT_FILE_NAMES = new Set([
'.env',
'.env.local',
'.env.development',
'.env.production',
'.env.test',
'.npmrc',
'.pypirc',
'.netrc',
'credentials.json',
'secrets.json',
'id_rsa',
'id_dsa',
'id_ecdsa',
'id_ed25519',
]);
export interface ExploreAgentResult {
kind: 'explore_agent';
ok: boolean;
partial: boolean;
terminalStatus: ExploreAgentTerminalStatus;
mode: 'read_only';
objective: string;
roots: string[];
queries: string[];
ignoredPaths: string[];
stoppingCondition: string;
limitReasons: ExploreAgentLimitReason[];
filesDiscovered: number;
filesInspected: number;
filesSkipped: number;
sensitiveFilesSkipped: number;
bytesRead: number;
startedAt: number;
completedAt: number;
durationMs: number;
progress: string[];
recentEvents: ExploreAgentEvent[];
evidence: Array<{
type: 'match' | 'candidate';
path: string;
line?: number;
label: string;
score?: number;
}>;
summary: string;
report: string;
candidateFiles: Array<{ path: string; score: number; reasons: string[] }>;
matches: Array<{ path: string; line: number; query: string; snippet: string }>;
notes: string[];
reason?: 'invalid_objective' | 'invalid_root' | 'no_readable_roots' | 'aborted';
message?: string;
}
type ExploreAgentTerminalStatus =
| 'completed'
| 'completed_empty'
| 'failed'
| 'canceled'
| 'canceled_partial';
type ExploreAgentLimitReason = 'candidate_budget' | 'file_budget' | 'match_budget' | 'byte_budget';
export interface ExploreAgentEvent {
type:
| 'started'
| 'scope_resolved'
| 'scan'
| 'read'
| 'checkpoint'
| 'completed'
| 'failed'
| 'aborted'
| 'progress';
at: number;
message: string;
}
interface ProgressState {
messages: string[];
recentEvents: ExploreAgentEvent[];
}
export function buildExploreAgentTool(): MakaTool<
{
objective: string;
roots?: string[];
queries?: string[];
ignorePaths?: string[];
stoppingCondition?: string;
maxFiles?: number;
maxMatches?: number;
},
ExploreAgentResult
> {
return {
name: EXPLORE_AGENT_TOOL_NAME,
displayName: '只读探索',
description:
'Run a bounded read-only local exploration worker for a self-contained research question. ' +
'It inspects filenames and text snippets under the session cwd only, returns candidate files and source-grounded matches, ' +
'and never writes files, starts services, installs packages, or uses the network. Use it when a separate investigation saves main-thread work. ' +
'Do not use it for one known file, a specific symbol, package scripts, test setup, config, or 1-3 obvious files; inspect those directly.',
parameters: z.object({
objective: z
.string()
.min(4)
.max(600)
.describe('Specific research objective for the read-only worker.'),
roots: z
.array(z.string().min(1).max(240))
.max(MAX_ROOTS)
.optional()
.describe('Optional relative roots under the session cwd. Defaults to the session cwd.'),
queries: z
.array(z.string().min(1).max(120))
.max(MAX_QUERIES)
.optional()
.describe('Optional search terms. If omitted, terms are derived from the objective.'),
ignorePaths: z
.array(z.string().min(1).max(240))
.max(MAX_IGNORE_PATHS)
.optional()
.describe(
'Optional relative files or directories to skip, such as generated output, vendors, or build artifacts.',
),
stoppingCondition: z
.string()
.min(1)
.max(240)
.optional()
.describe(
'Optional plain-language condition that tells the worker when this investigation is sufficiently answered.',
),
maxFiles: z.number().int().min(1).max(80).optional(),
maxMatches: z.number().int().min(1).max(120).optional(),
}),
categoryHint: 'subagent',
impl: async (
{ objective, roots, queries, ignorePaths, stoppingCondition, maxFiles, maxMatches },
{ cwd, abortSignal, emitOutput },
) => {
return runReadOnlyExplore({
cwd,
objective,
roots,
queries,
ignorePaths,
stoppingCondition,
maxFiles,
maxMatches,
abortSignal,
onProgress: (message) => emitOutput('stdout', `${message}\n`),
});
},
};
}
export async function runReadOnlyExplore(input: {
cwd: string;
objective: string;
roots?: string[];
queries?: string[];
ignorePaths?: string[];
stoppingCondition?: string;
maxFiles?: number;
maxMatches?: number;
abortSignal?: AbortSignal;
onProgress?: (message: string) => void;
}): Promise<ExploreAgentResult> {
const startedAt = Date.now();
const objective = normalizeText(input.objective).slice(0, 600);
if (objective.length < 4) {
return failure(
'invalid_objective',
objective,
[],
[],
[],
'',
'只读探索需要一个明确的研究目标。',
[],
startedAt,
);
}
const roots = normalizeRoots(input.roots);
const queryTerms = normalizeQueries(input.queries, objective);
const ignoredPaths = normalizeIgnorePaths(input.ignorePaths);
const stoppingCondition = normalizeText(input.stoppingCondition).slice(0, 240);
let workspaceRoot: string;
try {
workspaceRoot = await realpath(input.cwd);
} catch {
return failure(
'invalid_root',
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
'会话工作目录不可读取。',
[],
startedAt,
);
}
const maxFiles = clampInteger(input.maxFiles, 1, 80, DEFAULT_MAX_FILES);
const maxMatches = clampInteger(input.maxMatches, 1, 120, DEFAULT_MAX_MATCHES);
const discoveryBudget = Math.min(MAX_DISCOVERED_FILES, Math.max(maxFiles * 4, maxFiles));
const progress = createProgressReporter(input.onProgress);
if (input.abortSignal?.aborted) {
return abortFailure(
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
progress,
startedAt,
);
}
progress.report(
'started',
`只读探索:准备范围(${roots.length} 个 root,${queryTerms.length} 个查询词)`,
);
const resolvedRoots: Array<{ abs: string; rel: string }> = [];
for (const root of roots) {
if (input.abortSignal?.aborted) {
return abortFailure(
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
progress,
startedAt,
);
}
const resolved = resolve(workspaceRoot, root);
if (!isPathInside(workspaceRoot, resolved)) {
return failure(
'invalid_root',
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
`root 必须位于会话工作目录内:${root}`,
progress,
startedAt,
);
}
try {
const actual = await realpath(resolved);
if (!isPathInside(workspaceRoot, actual)) {
return failure(
'invalid_root',
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
`root 不能穿过符号链接离开工作目录:${root}`,
progress,
startedAt,
);
}
const rootStat = await stat(actual);
if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
resolvedRoots.push({ abs: actual, rel: toRelative(workspaceRoot, actual) });
} catch {
// Missing roots are reported through notes instead of failing the whole worker.
}
}
if (resolvedRoots.length === 0) {
return failure(
'no_readable_roots',
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
'没有可读取的研究范围。',
progress,
startedAt,
);
}
progress.report(
'scope_resolved',
`只读探索:确认 ${resolvedRoots.length} 个可读范围:${resolvedRoots.map((root) => root.rel).join(', ')}`,
);
const files: string[] = [];
const notes: string[] = [
'只读探索边界:不写文件、不联网、不启动进程。',
`搜索预算:最多读取 ${maxFiles} 个文件、返回 ${maxMatches} 处命中、读取 ${Math.round(MAX_TOTAL_BYTES / 1024)} KiB 文本。`,
];
if (ignoredPaths.length > 0) {
notes.push(`已按请求忽略:${ignoredPaths.join(', ')}`);
}
if (stoppingCondition) {
notes.push(`停止条件:${stoppingCondition}`);
}
let filesSkipped = 0;
let sensitiveFilesSkipped = 0;
const limitReasons: ExploreAgentLimitReason[] = [];
for (const root of resolvedRoots) {
if (input.abortSignal?.aborted) {
return abortFailure(
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
progress,
startedAt,
);
}
const before = files.length;
const skippedBefore = filesSkipped;
const sensitiveBefore = sensitiveFilesSkipped;
const listed = await listTextFiles(
root.abs,
workspaceRoot,
discoveryBudget - files.length,
ignoredPaths,
input.abortSignal,
);
if (listed.aborted) {
return abortFailure(
objective,
roots,
queryTerms,
ignoredPaths,
stoppingCondition,
progress,
startedAt,
);
}
files.push(...listed.files);
filesSkipped += listed.skipped;
sensitiveFilesSkipped += listed.sensitiveSkipped;
if (listed.truncated) {
addLimitReason(limitReasons, 'candidate_budget');
notes.push(`范围 ${root.rel} 已到达候选文件预算,后续文件未继续扫描。`);
}
if (files.length === before) notes.push(`范围 ${root.rel} 在预算内没有可读取文本文件。`);
const found = files.length - before;
const skipped = filesSkipped - skippedBefore;
const sensitive = sensitiveFilesSkipped - sensitiveBefore;
progress.report(
'scan',
`只读探索:扫描 ${root.rel},找到 ${found} 个文本候选,跳过 ${skipped} 项${sensitive > 0 ? `(含 ${sensitive} 个敏感文件)` : ''}`,
);
if (files.length >= discoveryBudget) break;
}
files.sort((left, right) => {
const leftRel = toRelative(workspaceRoot, left);
const rightRel = toRelative(workspaceRoot, right);
const leftScore = scorePath(leftRel, queryTerms).score;
const rightScore = scorePath(rightRel, queryTerms).score;
return rightScore - leftScore || leftRel.localeCompare(rightRel);
});
if (
files.some((file) =>
scorePath(toRelative(workspaceRoot, file), queryTerms).reasons.some((reason) =>
reason.startsWith('project '),
),
)
) {
notes.push('广泛研究会优先读取项目配置、文档、入口和测试线索。');
}
const filesToInspect = files.slice(0, maxFiles);
if (files.length > filesToInspect.length) {
addLimitReason(limitReasons, 'file_budget');
notes.push(
`已发现 ${files.length} 个文本候选;按查询命中和项目结构分读取前 ${filesToInspect.length} 个。`,
);
}
const candidates = new Map<string, { path: string; score: number; reasons: Set<string> }>();
const matches: ExploreAgentResult['matches'] = [];
let bytesRead = 0;
let inspected = 0;
progress.report('read', `只读探索:开始读取 ${filesToInspect.length} 个候选文件`);
for (const file of filesToInspect) {
if (input.abortSignal?.aborted) {
return partialAbortFailure({
objective,
roots: resolvedRoots.map((root) => root.rel),
queryTerms,
ignoredPaths,
stoppingCondition,
limitReasons,
filesDiscovered: files.length,
filesInspected: inspected,
filesSkipped,
sensitiveFilesSkipped,
bytesRead,
candidates,
matches,
notes,
progress,
startedAt,
});
}
const rel = toRelative(workspaceRoot, file);
const filenameScore = scorePath(rel, queryTerms);
if (filenameScore.score > 0) {
candidates.set(rel, {
path: rel,
score: filenameScore.score,
reasons: new Set(filenameScore.reasons),
});
}
let fileStat;
try {
fileStat = await stat(file);
} catch {
filesSkipped++;
continue;
}
if (fileStat.size > MAX_FILE_BYTES || bytesRead >= MAX_TOTAL_BYTES) {
filesSkipped++;
continue;
}
let text: string;
try {
text = await readFile(file, 'utf8');
} catch {
filesSkipped++;
continue;
}
if (input.abortSignal?.aborted) {
return partialAbortFailure({
objective,
roots: resolvedRoots.map((root) => root.rel),
queryTerms,
ignoredPaths,
stoppingCondition,
limitReasons,
filesDiscovered: files.length,
filesInspected: inspected,
filesSkipped,
sensitiveFilesSkipped,
bytesRead,
candidates,
matches,
notes,
progress,
startedAt,
});
}
if (looksBinary(text)) {
filesSkipped++;
continue;
}
inspected++;
bytesRead += Buffer.byteLength(text, 'utf8');
const fileMatches = findMatches(rel, text, queryTerms, maxMatches - matches.length);
if (fileMatches.length > 0) {
matches.push(...fileMatches);
const current = candidates.get(rel) ?? { path: rel, score: 0, reasons: new Set<string>() };
current.score += fileMatches.length * 3;
current.reasons.add('content match');
candidates.set(rel, current);
}
if (matches.length >= maxMatches || bytesRead >= MAX_TOTAL_BYTES) break;
if (inspected > 0 && inspected % 10 === 0) {
progress.report(
'checkpoint',
`只读探索:已读取 ${inspected} 个文件,命中 ${matches.length} 处`,
);
}
}
const candidateFiles = Array.from(candidates.values())
.map((candidate) => ({
path: candidate.path,
score: candidate.score,
reasons: Array.from(candidate.reasons).sort(),
}))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, 20);
const evidence = buildEvidenceAnchors(matches, candidateFiles);
const terminalStatus: ExploreAgentTerminalStatus =
evidence.length > 0 ? 'completed' : 'completed_empty';
if (matches.length === 0) notes.push('没有找到内容命中;候选文件可作为下一步阅读清单。');
if (sensitiveFilesSkipped > 0)
notes.push(`已跳过 ${sensitiveFilesSkipped} 个疑似本地凭据/密钥文件,只报告数量不读取内容。`);
if (matches.length >= maxMatches) {
addLimitReason(limitReasons, 'match_budget');
notes.push(`命中预算已用尽;只返回前 ${maxMatches} 处内容命中。`);
}
if (bytesRead >= MAX_TOTAL_BYTES) {
addLimitReason(limitReasons, 'byte_budget');
notes.push('总读取预算已用尽,部分候选文件未继续读取。');
}
progress.report(
'completed',
`只读探索:完成,读取 ${inspected} 个文件,命中 ${matches.length} 处,候选 ${candidateFiles.length} 个`,
);
const completedAt = Date.now();
const durationMs = Math.max(0, completedAt - startedAt);
const summary = buildResultSummary({
filesDiscovered: files.length,
filesInspected: inspected,
matches: matches.length,
evidence: evidence.length,
candidateFiles: candidateFiles.length,
durationMs,
});
const report = buildResearchReport({
statusLine: presentExploreAgentTerminalStatus(terminalStatus),
objective,
roots: resolvedRoots.map((root) => root.rel),
queryTerms,
stoppingCondition,
limitReasons,
filesDiscovered: files.length,
filesInspected: inspected,
filesSkipped,
sensitiveFilesSkipped,
bytesRead,
evidence,
candidateFiles,
matches,
notes,
durationMs,
});
return {
kind: 'explore_agent',
ok: true,
partial: false,
terminalStatus,
mode: 'read_only',
objective,
roots: resolvedRoots.map((root) => root.rel),
queries: queryTerms,
ignoredPaths,
stoppingCondition,
limitReasons,
filesDiscovered: files.length,
filesInspected: inspected,
filesSkipped,
sensitiveFilesSkipped,
bytesRead,
startedAt,
completedAt,
durationMs,
progress: progress.messages,
recentEvents: progress.recentEvents,
evidence,
summary,
report,
candidateFiles,
matches,
notes,
};
}
function createProgressReporter(onProgress: ((message: string) => void) | undefined): {
messages: string[];
recentEvents: ExploreAgentEvent[];
report(type: ExploreAgentEvent['type'], message: string): void;
} {
let emitted = 0;
const messages: string[] = [];
const recentEvents: ExploreAgentEvent[] = [];
return {
messages,
recentEvents,
report(type, message) {
appendExploreEvent(recentEvents, { type, at: Date.now(), message });
if (emitted < 12) {
emitted++;
messages.push(message);
}
onProgress?.(message);
},
};
}
function appendExploreEvent(events: ExploreAgentEvent[], event: ExploreAgentEvent): void {
events.push(event);
while (events.length > 20) {
const evictIndex = events.findIndex((item) => !isLifecycleExploreEvent(item.type));
events.splice(evictIndex >= 0 ? evictIndex : 0, 1);
}
}
function addLimitReason(reasons: ExploreAgentLimitReason[], reason: ExploreAgentLimitReason): void {
if (!reasons.includes(reason)) reasons.push(reason);
}
function isLifecycleExploreEvent(type: ExploreAgentEvent['type']): boolean {
return type === 'started' || type === 'completed' || type === 'failed' || type === 'aborted';
}
async function listTextFiles(
root: string,
workspaceRoot: string,
budget: number,
ignoredPaths: string[],
abortSignal?: AbortSignal,
): Promise<{
files: string[];
skipped: number;
sensitiveSkipped: number;
aborted: boolean;
truncated: boolean;
}> {
const files: string[] = [];
let skipped = 0;
let sensitiveSkipped = 0;
let aborted = false;
let truncated = false;
async function walk(abs: string): Promise<void> {
if (abortSignal?.aborted) {
aborted = true;
return;
}
if (files.length >= budget) {
truncated = true;
return;
}
let entryStat;
try {
entryStat = await lstat(abs);
} catch {
skipped++;
return;
}
if (entryStat.isSymbolicLink()) {
skipped++;
return;
}
const rel = toRelative(workspaceRoot, abs);
if (rel !== '.' && isIgnoredPath(rel, ignoredPaths)) {
skipped++;
return;
}
if (entryStat.isFile()) {
if (isSensitiveTextFile(abs)) {
skipped++;
sensitiveSkipped++;
} else if (isLikelyTextFile(abs)) {
files.push(abs);
} else {
skipped++;
}
return;
}
if (!entryStat.isDirectory()) {
skipped++;
return;
}
if (abs !== root && shouldSkipDir(abs)) {
skipped++;
return;
}
let entries;
try {
entries = await readdir(abs, { withFileTypes: true });
} catch {
skipped++;
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (abortSignal?.aborted) {
aborted = true;
return;
}
if (files.length >= budget) {
truncated = true;
return;
}
const child = join(abs, entry.name);
if (!isPathInside(workspaceRoot, child)) {
skipped++;
continue;
}
await walk(child);
}
}
await walk(root);
return { files, skipped, sensitiveSkipped, aborted, truncated };
}
function normalizeRoots(roots: string[] | undefined): string[] {
const normalized = (roots && roots.length > 0 ? roots : ['.'])
.map((root) => root.trim())
.filter(Boolean)
.slice(0, MAX_ROOTS);
return normalized.length > 0 ? normalized : ['.'];
}
function normalizeQueries(queries: string[] | undefined, objective: string): string[] {
const explicit = (queries ?? []).map(normalizeText).filter((query) => query.length > 0);
const source = explicit.length > 0 ? explicit : deriveQueries(objective);
return Array.from(new Set(source.map((query) => query.slice(0, 120)))).slice(0, MAX_QUERIES);
}
function normalizeIgnorePaths(paths: string[] | undefined): string[] {
const normalized: string[] = [];
for (const raw of paths ?? []) {
const value = raw
.trim()
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/\/+$/g, '');
if (!value || value === '.' || value === '..' || value.startsWith('/') || value.includes('\0'))
continue;
const segments = value.split('/').filter(Boolean);
if (segments.length === 0 || segments.some((segment) => segment === '.' || segment === '..'))
continue;
normalized.push(segments.join('/').slice(0, 160));
if (normalized.length >= MAX_IGNORE_PATHS) break;
}
return Array.from(new Set(normalized));
}
function isIgnoredPath(path: string, ignoredPaths: string[]): boolean {
return ignoredPaths.some((ignored) => path === ignored || path.startsWith(`${ignored}/`));
}
function deriveQueries(objective: string): string[] {
const words = objective
.split(/[^\p{L}\p{N}_-]+/u)
.map((word) => word.trim())
.filter((word) => word.length >= 3 && !COMMON_WORDS.has(word.toLowerCase()));
return words.length > 0 ? words.slice(0, MAX_QUERIES) : [objective.slice(0, 80)];
}
const COMMON_WORDS = new Set([
'the',
'and',
'for',
'with',
'this',
'that',
'from',
'project',
'research',
'please',
]);
function normalizeText(value: unknown): string {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
return typeof value === 'number' && Number.isInteger(value)
? Math.max(min, Math.min(max, value))
: fallback;
}
function shouldSkipDir(abs: string): boolean {
return IGNORED_DIRS.has(basename(abs));
}
function isLikelyTextFile(abs: string): boolean {
return TEXT_EXTENSIONS.has(extname(abs).toLowerCase());
}
function isSensitiveTextFile(abs: string): boolean {
const base = basename(abs).toLowerCase();
if (SENSITIVE_TEXT_FILE_NAMES.has(base)) return true;
if (base.startsWith('.env.')) return true;
if (/\.(pem|key|p12|pfx|crt|cer)$/i.test(base)) return true;
return false;
}
function scorePath(path: string, queries: string[]): { score: number; reasons: string[] } {
const lowerPath = path.toLowerCase();
const base = basename(path);
const lowerBase = base.toLowerCase();
const reasons: string[] = [];
let score = 0;
if (PROJECT_MANIFEST_FILES.has(base)) {
score += 12;
reasons.push('project manifest');
}
if (DOCUMENTATION_FILES.has(base)) {
score += 10;
reasons.push('project documentation');
}
if (ENTRYPOINT_NAMES.has(lowerBase)) {
score += 8;
reasons.push('project entrypoint');
}
if (
/\b(__tests__|tests?|specs?|e2e)\b/i.test(path) ||
/\.(test|spec)\.[cm]?[jt]sx?$/i.test(path)
) {
score += 6;
reasons.push('project test surface');
}
if (/\b(src|app|packages|apps)\b/i.test(path)) {
score += 2;
reasons.push('project source surface');
}
for (const query of queries) {
const lowerQuery = query.toLowerCase();
if (lowerPath.includes(lowerQuery)) {
score += 5;
reasons.push(`path contains "${query}"`);
}
}
return { score, reasons };
}
function findMatches(
path: string,
text: string,
queries: string[],
remaining: number,
): ExploreAgentResult['matches'] {
if (remaining <= 0) return [];
const matches: ExploreAgentResult['matches'] = [];
const lines = text.split(/\r?\n/);
const lowerQueries = queries.map((query) => ({ raw: query, lower: query.toLowerCase() }));
for (let index = 0; index < lines.length; index++) {
const lowerLine = lines[index]!.toLowerCase();
const query = lowerQueries.find((item) => lowerLine.includes(item.lower));
if (!query) continue;
matches.push({
path,
line: index + 1,
query: query.raw,
snippet: capSnippet(lines[index]!),
});
if (matches.length >= remaining) break;
}
return matches;
}
function buildEvidenceAnchors(
matches: ExploreAgentResult['matches'],
candidateFiles: ExploreAgentResult['candidateFiles'],
): ExploreAgentResult['evidence'] {
const anchors: ExploreAgentResult['evidence'] = [];
const seen = new Set<string>();
for (const match of matches) {
if (anchors.length >= 10) break;
const key = `${match.path}:${match.line}`;
if (seen.has(key)) continue;
seen.add(key);
anchors.push({
type: 'match',
path: match.path,
line: match.line,
label: `内容命中:${match.query}`,
});
}
for (const candidate of candidateFiles) {
if (anchors.length >= 10) break;
if (seen.has(candidate.path)) continue;
seen.add(candidate.path);
anchors.push({
type: 'candidate',
path: candidate.path,
label: evidenceLabelForCandidate(candidate.reasons),
score: candidate.score,
});
}
return anchors;
}
function buildResearchReport(input: {
statusLine?: string;
objective: string;
roots: string[];
queryTerms: string[];
stoppingCondition: string;
limitReasons: ExploreAgentLimitReason[];
filesDiscovered: number;
filesInspected: number;
filesSkipped: number;
sensitiveFilesSkipped: number;
bytesRead: number;
evidence: ExploreAgentResult['evidence'];
candidateFiles: ExploreAgentResult['candidateFiles'];
matches: ExploreAgentResult['matches'];
notes: string[];
durationMs: number;
}): string {
const lines = [
...(input.statusLine ? [`状态:${input.statusLine}`] : []),
`目标:${input.objective}`,
`范围:${input.roots.length > 0 ? input.roots.join(', ') : '.'}`,
`查询:${input.queryTerms.length > 0 ? input.queryTerms.join(', ') : '未指定'}`,
...(input.stoppingCondition ? [`停止条件:${input.stoppingCondition}`] : []),
...(input.limitReasons.length > 0
? [`预算边界:${input.limitReasons.map(presentExploreAgentLimitReason).join('、')}`]
: []),
`发现/读取:${input.filesDiscovered} / ${input.filesInspected} 个文件,跳过 ${input.filesSkipped} 个${input.sensitiveFilesSkipped > 0 ? `(含敏感 ${input.sensitiveFilesSkipped} 个)` : ''},${formatReportBytes(input.bytesRead)},耗时 ${formatReportDuration(input.durationMs)}`,
];
if (input.evidence.length > 0) {
lines.push('', '证据锚点:');
for (const item of input.evidence.slice(0, 8)) {
lines.push(
`- ${item.path}${typeof item.line === 'number' ? `:${item.line}` : ''} — ${item.label}`,
);
}
}
if (input.matches.length > 0) {
lines.push('', '命中片段:');
for (const match of input.matches.slice(0, 5)) {
lines.push(`- ${match.path}:${match.line} [${match.query}] ${match.snippet}`);
}
}
if (input.candidateFiles.length > 0) {
lines.push('', '下一步阅读:');
for (const candidate of input.candidateFiles.slice(0, 5)) {
lines.push(`- ${candidate.path}(分数 ${candidate.score})`);
}
}
if (input.notes.length > 0) {
lines.push('', '说明:');
for (const note of input.notes.slice(0, 5)) {
lines.push(`- ${note}`);
}
}
return capReport(lines.join('\n'));
}
function presentExploreAgentTerminalStatus(status: ExploreAgentTerminalStatus): string {
switch (status) {
case 'completed':
return '完成,已找到可交接证据。';
case 'completed_empty':
return '完成,但没有找到可交接证据。';
case 'failed':
return '失败,未产生结果。';
case 'canceled':
return '已取消,未产生结果。';
case 'canceled_partial':
return '已取消,以下为取消前部分结果。';
}
}
function buildResultSummary(input: {
filesDiscovered: number;
filesInspected: number;
matches: number;
evidence: number;
candidateFiles: number;
durationMs: number;
}): string {
return [
`发现 ${input.filesDiscovered} 个候选`,
`读取 ${input.filesInspected} 个文件`,
`命中 ${input.matches} 处`,
`证据 ${input.evidence} 个`,
`候选 ${input.candidateFiles} 个`,
`耗时 ${formatReportDuration(input.durationMs)}`,
].join(' · ');
}
function presentExploreAgentLimitReason(reason: ExploreAgentLimitReason): string {
switch (reason) {
case 'candidate_budget':
return '候选文件预算已满';
case 'file_budget':
return '读取文件预算已满';
case 'match_budget':
return '命中预算已满';
case 'byte_budget':
return '读取字节预算已满';
}
}
function formatReportBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
return `${Math.round(bytes / 1024)} KiB`;
}
function formatReportDuration(ms: number): string {
if (ms < 1000) return `${ms} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function capReport(report: string): string {
return Array.from(report).slice(0, 6000).join('');
}
function evidenceLabelForCandidate(reasons: string[]): string {
if (reasons.includes('project manifest')) return '项目配置锚点';
if (reasons.includes('project documentation')) return '项目文档锚点';
if (reasons.includes('project entrypoint')) return '入口文件锚点';
if (reasons.includes('project test surface')) return '测试线索锚点';
if (reasons.includes('project source surface')) return '源码线索锚点';
if (reasons.includes('content match')) return '内容命中锚点';
return '候选阅读锚点';
}
function capSnippet(line: string): string {
const cleaned = line.replace(/\s+/g, ' ').trim();
return Array.from(cleaned).slice(0, MATCH_CONTEXT_CHARS).join('');
}
function looksBinary(text: string): boolean {
return text.includes('\u0000');
}
function failure(
reason: NonNullable<ExploreAgentResult['reason']>,
objective: string,
roots: string[],
queries: string[],
ignoredPaths: string[],
stoppingCondition: string,
message: string,
progress: string[] | ProgressState = [],
startedAt = Date.now(),
): ExploreAgentResult {
const completedAt = Date.now();
const progressState = normalizeProgressState(progress, startedAt);
appendExploreEvent(progressState.recentEvents, {
type: reason === 'aborted' ? 'aborted' : 'failed',
at: completedAt,
message,
});
return {
kind: 'explore_agent',
ok: false,
partial: false,
terminalStatus: reason === 'aborted' ? 'canceled' : 'failed',
mode: 'read_only',
objective,
roots,
queries,
ignoredPaths,
stoppingCondition,
limitReasons: [],
filesDiscovered: 0,
filesInspected: 0,
filesSkipped: 0,
sensitiveFilesSkipped: 0,
bytesRead: 0,
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
progress: progressState.messages,
recentEvents: progressState.recentEvents,
evidence: [],
summary: `未完成:${message}`,
report: '',
candidateFiles: [],
matches: [],
notes: ['只读探索边界:不写文件、不联网、不启动进程。'],
reason,
message,
};
}
function partialAbortFailure(input: {
objective: string;
roots: string[];
queryTerms: string[];
ignoredPaths: string[];
stoppingCondition: string;
limitReasons: ExploreAgentLimitReason[];
filesDiscovered: number;
filesInspected: number;
filesSkipped: number;
sensitiveFilesSkipped: number;
bytesRead: number;
candidates: Map<string, { path: string; score: number; reasons: Set<string> }>;
matches: ExploreAgentResult['matches'];
notes: string[];
progress: ProgressState;
startedAt: number;
}): ExploreAgentResult {
if (input.filesInspected <= 0 && input.matches.length === 0 && input.candidates.size === 0) {
return abortFailure(
input.objective,
input.roots,
input.queryTerms,
input.ignoredPaths,
input.stoppingCondition,
input.progress,
input.startedAt,
);
}
const completedAt = Date.now();
appendExploreEvent(input.progress.recentEvents, {
type: 'aborted',
at: completedAt,
message: '只读探索已取消,已保留取消前的部分结果。',
});
const candidateFiles = Array.from(input.candidates.values())
.map((candidate) => ({
path: candidate.path,
score: candidate.score,
reasons: Array.from(candidate.reasons).sort(),
}))
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, 20);
const evidence = buildEvidenceAnchors(input.matches, candidateFiles);
const terminalStatus: ExploreAgentTerminalStatus = 'canceled_partial';
const durationMs = Math.max(0, completedAt - input.startedAt);
const notes = [...input.notes, '只读探索已取消;以下为取消前已读取的部分结果,不代表完整结论。'];
const summary = `已取消:${buildResultSummary({
filesDiscovered: input.filesDiscovered,
filesInspected: input.filesInspected,
matches: input.matches.length,
evidence: evidence.length,
candidateFiles: candidateFiles.length,
durationMs,
})}`;
const report = buildResearchReport({
statusLine: presentExploreAgentTerminalStatus(terminalStatus),
objective: input.objective,
roots: input.roots,
queryTerms: input.queryTerms,
stoppingCondition: input.stoppingCondition,
filesDiscovered: input.filesDiscovered,
filesInspected: input.filesInspected,
filesSkipped: input.filesSkipped,
sensitiveFilesSkipped: input.sensitiveFilesSkipped,
bytesRead: input.bytesRead,
evidence,
candidateFiles,
matches: input.matches,
notes,
limitReasons: input.limitReasons,
durationMs,
});
return {
kind: 'explore_agent',
ok: false,
partial: true,
terminalStatus,
mode: 'read_only',
objective: input.objective,
roots: input.roots,
queries: input.queryTerms,
ignoredPaths: input.ignoredPaths,
stoppingCondition: input.stoppingCondition,
limitReasons: [...input.limitReasons],
filesDiscovered: input.filesDiscovered,
filesInspected: input.filesInspected,
filesSkipped: input.filesSkipped,
sensitiveFilesSkipped: input.sensitiveFilesSkipped,
bytesRead: input.bytesRead,
startedAt: input.startedAt,
completedAt,
durationMs,
progress: [...input.progress.messages],
recentEvents: [...input.progress.recentEvents],
evidence,
summary,
report,
candidateFiles,
matches: [...input.matches],
notes,
reason: 'aborted',
message: '只读探索已取消,已保留取消前的部分结果。',
};
}
function abortFailure(
objective: string,
roots: string[],
queries: string[],
ignoredPaths: string[],
stoppingCondition: string,
progress: string[] | ProgressState = [],
startedAt = Date.now(),
): ExploreAgentResult {
return failure(
'aborted',
objective,
roots,
queries,
ignoredPaths,
stoppingCondition,
'只读探索已取消。',
progress,
startedAt,
);
}
function normalizeProgressState(
progress: string[] | ProgressState,
startedAt: number,
): ProgressState {
if (!Array.isArray(progress)) {
return {
messages: [...progress.messages],
recentEvents: progress.recentEvents.map((event) => ({ ...event })),
};
}
return {
messages: [...progress],
recentEvents: progress.map((message, index) => ({
type: 'progress',
at: startedAt + index,
message,
})),
};
}