@sema-agent/core
Version:
Stateless, task-oriented AI agent core
224 lines (223 loc) • 11.4 kB
JavaScript
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 };
},
});
}