UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

224 lines (223 loc) • 11.4 kB
import { Type } from "typebox"; import { defineTool, errorResult } from "../../core/tools.js"; import { resolveKey, violationText, violationDetails } from "./safety.js"; import { buildIgnore, denyWithheldNote, walk, walkIsPartial } from "./search.js"; import { seatForCall } from "./read-deny.js"; import { toolFace } from "../../core/tool-catalog-entries.js"; import { pathTargetBaseOf } from "../../core/effective-path-target.js"; import { pathTargetValue } from "../../core/tool-registry.js"; const DEFAULT_MAX_CHARS = 16_000; const DEFAULT_MAX_FILES = 400; const FILE_SCAN_MAX_BYTES = 256 * 1024; const MAX_SYMBOLS_PER_FILE = 24; const SOURCE_EXTENSIONS = new Set([ "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "kt", "kts", "rb", "php", "swift", "scala", "ex", "exs", "c", "h", "cc", "cpp", "hpp", "cxx", "cs", "m", "mm", "dart", "lua", "sh", "bash", ]); const SYMBOL_PATTERNS = [ /^export\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/, /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/, /^export\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, /^(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, /^export\s+(?:default\s+)?(?:interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/, /^(?:interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/, /^export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/, /^(?:pub\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/, /^func\s+(?:\([^)]*\)\s*)?([A-Za-z_][\w]*)/, /^(?:pub\s+)?(?:struct|trait|impl|enum)\s+(?:for\s+)?([A-Za-z_][\w]*)/, /^def\s+([A-Za-z_][\w]*)/, /^type\s+([A-Za-z_][\w]*)\s+(?:struct|interface)/, ]; function extOf(path) { const dot = path.lastIndexOf("."); const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return dot > slash ? path.slice(dot + 1).toLowerCase() : ""; } export function extractSymbols(text) { const seen = new Set(); const out = []; let inBlock = false; let inDoc = null; for (const line of text.split("\n")) { if (inDoc !== null) { if (line.includes(inDoc)) inDoc = null; continue; } if (inBlock) { if (line.includes("*/")) inBlock = false; continue; } if (line.length === 0 || /^\s/.test(line)) continue; if (line.startsWith("/*") && !line.includes("*/")) { inBlock = true; continue; } const docOpen = /^("""|''')/.exec(line); if (docOpen && line.indexOf(docOpen[1], docOpen[1].length) === -1) { inDoc = docOpen[1]; continue; } for (const re of SYMBOL_PATTERNS) { const m = re.exec(line); if (m && m[1] && !seen.has(m[1])) { seen.add(m[1]); out.push(m[1]); break; } } } return out; } function rankEntries(a, b) { const aHas = a.symbols.length > 0 ? 0 : 1; const bHas = b.symbols.length > 0 ? 0 : 1; if (aHas !== bHas) return aHas - bHas; if (a.depth !== b.depth) return a.depth - b.depth; if (a.symbols.length !== b.symbols.length) return b.symbols.length - a.symbols.length; return a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0; } export function createRepoMapTool(env, rootCanonical, rootsSeat, denySeat, readFace, cwdRef, readAdmission) { const declaredTarget = toolFace("repo-map").pathTarget; return defineTool({ name: "RepoMap", ...toolFace("repo-map"), description: "Get a compact map of the repository: each source file with its top-level symbols (functions, " + "classes, types). Use this FIRST to orient in an unfamiliar codebase before grepping — it shows " + "what exists and where, so you can jump to the relevant files. Token-budgeted and honest about " + "truncation. Optionally scope to a sub-directory.", parameters: Type.Object({ path: Type.Optional(Type.String({ description: "Restrict the map to a directory (relative paths resolve against the root)." })), max_chars: Type.Optional(Type.Number({ description: `Token budget for the rendered map in chars (default ${DEFAULT_MAX_CHARS}; a very small value yields only a truncation note).` })), max_files: Type.Optional(Type.Number({ description: `Max files to scan (default ${DEFAULT_MAX_FILES}).` })), }), execute: async (args, ctx) => { const readDeny = seatForCall(denySeat, ctx.toolCallId); const additionalRoots = seatForCall(rootsSeat, ctx.toolCallId); const a = args; const signal = ctx.signal; const rootPrefix = rootCanonical.replace(/[\\/]+$/, "") + (rootCanonical.includes("\\") ? "\\" : "/"); let start = rootCanonical; const scopeSpelling = declaredTarget === undefined ? undefined : pathTargetValue(a, declaredTarget); if (scopeSpelling !== undefined) { const baseCwd = pathTargetBaseOf(declaredTarget, { root: rootCanonical, cwd: cwdRef?.current }); const r = await resolveKey(env, rootCanonical, scopeSpelling, signal, baseCwd, additionalRoots, readAdmission && ((key) => readAdmission(ctx.toolCallId, key)), readDeny, readFace); if (!r.ok) return errorResult(violationText("RepoMap", r.violation), violationDetails(r.violation)); start = r.key; } const maxChars = Math.max(1, Math.floor(a.max_chars ?? DEFAULT_MAX_CHARS)); const maxFiles = Math.max(1, Math.floor(a.max_files ?? DEFAULT_MAX_FILES)); const ignore = await buildIgnore(env, rootCanonical, signal); const walked = await walk(env, rootCanonical, start, ignore, signal, readDeny); const rel = (abs) => (abs.startsWith(rootPrefix) ? abs.slice(rootPrefix.length) : abs); const entries = []; const nonSource = []; let scanned = 0; let scanIncomplete = false; let scanAborted = false; let skippedUnreadableFiles = 0; for (const abs of walked.files) { if (signal?.aborted) { scanAborted = true; break; } const r = rel(abs); if (!SOURCE_EXTENSIONS.has(extOf(abs))) { nonSource.push(r); continue; } if (scanned >= maxFiles) { scanIncomplete = true; break; } scanned++; const read = await env.readTextFile(abs, signal).catch(() => undefined); if (!read || !read.ok) { if (signal?.aborted) { scanAborted = true; break; } skippedUnreadableFiles++; continue; } let text = read.value; if (text.length > FILE_SCAN_MAX_BYTES) { text = text.slice(0, FILE_SCAN_MAX_BYTES); if (!text.endsWith("\n")) text = text.slice(0, text.lastIndexOf("\n") + 1); } entries.push({ rel: r, depth: r.split(/[\\/]/).length, symbols: extractSymbols(text) }); } entries.sort(rankEntries); const lines = []; let used = 0; let rendered = 0; let budgetHit = false; for (const e of entries) { const shown = e.symbols.slice(0, MAX_SYMBOLS_PER_FILE); const more = e.symbols.length > shown.length ? ` (+${e.symbols.length - shown.length} more)` : ""; const line = shown.length > 0 ? `${e.rel}: ${shown.join(", ")}${more}` : `${e.rel}`; if (used + line.length + 1 > maxChars) { budgetHit = true; break; } lines.push(line); used += line.length + 1; rendered++; } const notes = []; if (budgetHit || rendered < entries.length) { notes.push(`…[map truncated to fit the token budget: ${rendered} of ${entries.length} source files shown; raise max_chars or scope with path]`); } if (scanIncomplete) notes.push(`…[only the first ${maxFiles} source files were scanned for symbols; raise max_files]`); if (walked.incomplete) notes.push("…[the directory walk hit its file/depth ceiling — some files may be missing]"); if (walked.aborted) notes.push("…[the directory walk was interrupted before finishing — the map is partial]"); if (walked.skippedUnreadable > 0) notes.push(`…[${walked.skippedUnreadable} director${walked.skippedUnreadable === 1 ? "y" : "ies"} could not be read — files under ${walked.skippedUnreadable === 1 ? "it are" : "them are"} missing from the map]`); if (scanAborted && !walked.aborted) notes.push("…[the symbol scan was interrupted before finishing — the map is partial]"); if (skippedUnreadableFiles > 0) notes.push(`…[${skippedUnreadableFiles} source file${skippedUnreadableFiles === 1 ? "" : "s"} could not be read — ${skippedUnreadableFiles === 1 ? "it is" : "they are"} missing from the map]`); { const denyNote = denyWithheldNote(walked).trim(); if (denyNote.length > 0) notes.push(denyNote); } if (nonSource.length > 0) { if (!budgetHit) { const NON_SOURCE_CAP = 40; const head = nonSource.slice(0, NON_SOURCE_CAP); lines.push("", "Other files:", ...head.map((f) => ` ${f}`)); if (nonSource.length > NON_SOURCE_CAP) lines.push(`…[and ${nonSource.length - NON_SOURCE_CAP} more non-source files]`); } else { notes.push(`…[${nonSource.length} non-source file(s) not shown — budget exhausted]`); } } const mapDetails = { type: "repo-map", sourceFiles: entries.length, renderedFiles: rendered, nonSourceFiles: nonSource.length, truncated: budgetHit || scanIncomplete || scanAborted || skippedUnreadableFiles > 0 || walkIsPartial(walked) || rendered < entries.length, ...(walked.denyPruned > 0 ? { withheld: { kind: "pruned_count", count: walked.denyPruned, patterns: walked.denyPatterns } } : {}), }; if (lines.length === 0) { const base = start === rootCanonical ? "Repository is empty or has no readable files." : `No readable files under "${a.path}".`; return { content: notes.length > 0 ? ["(no map entries fit — see below)", ...notes].join("\n") : base, details: mapDetails }; } return { content: [lines.join("\n"), ...notes].filter(Boolean).join("\n"), details: mapDetails }; }, }); }