@sema-agent/core
Version:
Stateless, task-oriented AI agent core
240 lines (239 loc) • 16.2 kB
JavaScript
import { Type } from "typebox";
import { defineTool, errorResult } from "../../core/tools.js";
import { clipSpelling, pathFamilyOf, resolveKey, violationText, violationDetails, WIN32_NAMESPACE_REFUSAL_TEXT } from "./safety.js";
import { runGrepDetailed, runGlobDetailed, invalidGlobTokens } from "./search.js";
import { seatForCall } from "./read-deny.js";
import { toolFace } from "../../core/tool-catalog-entries.js";
import { pathTargetBaseOf, splitAbsoluteGlobPattern } from "../../core/effective-path-target.js";
import { pathTargetValue } from "../../core/tool-registry.js";
export function grepDetailFields(text, mode, offset, structuredRows) {
const structuredIdentityComplete = structuredRows !== undefined && structuredRows.every((r) => r.path !== undefined || r.text === "--");
const structuredPaths = structuredIdentityComplete && structuredRows !== undefined ? [...new Set(structuredRows.filter((r) => r.path !== undefined).map((r) => r.path))] : undefined;
const rows = text.startsWith("No matches.")
? []
: text
.split("\n")
.filter((l) => l.length > 0 &&
l !== "No matches." &&
!l.startsWith("…[") &&
!l.startsWith("[offset") &&
!l.startsWith("[note") &&
!l.startsWith("[!]") &&
!l.startsWith("<<<UNTRUSTED ") &&
!l.startsWith("<<<END UNTRUSTED "));
const contentPathOf = (l) => {
const m = /^(.*?):(\d+):/.exec(l);
return m ? m[1] : l;
};
const byteTruncated = text.includes("…[output truncated at ");
const truncatedFlag = byteTruncated ? { truncated: true } : {};
const capMarker = /^…\[capped at (\d+) of (\d+)\]$/m.exec(text);
const cappedAt = capMarker ? Number(capMarker[1]) : undefined;
const capTotal = capMarker ? Number(capMarker[2]) : undefined;
const appliedOffset = typeof offset === "number" && offset > 0 ? { appliedOffset: offset } : {};
const appliedLimit = cappedAt !== undefined ? { appliedLimit: cappedAt } : {};
let detailFields;
if (mode === "files_with_matches") {
const fileNames = structuredPaths ?? rows;
detailFields = { filenames: fileNames, numFiles: fileNames.length, totalFiles: capTotal ?? fileNames.length, ...appliedLimit, ...appliedOffset };
}
else if (mode === "count") {
const filenames = structuredPaths ?? [...new Set(rows.map((l) => (l.includes(":") ? l.slice(0, l.lastIndexOf(":")) : l)))];
let numMatches = 0;
let malformed = false;
for (const l of rows) {
const m = /:(\d+)$/.exec(l);
if (m)
numMatches += Number(m[1]);
else
malformed = true;
}
detailFields = {
filenames,
numFiles: filenames.length,
content: rows.join("\n"),
...(malformed || byteTruncated ? {} : { numMatches }),
...truncatedFlag,
...appliedLimit,
...appliedOffset,
};
}
else {
const filenames = structuredPaths ?? [...new Set(rows.map(contentPathOf))];
const joined = rows.join("\n");
const GREP_CONTENT_PREVIEW_CHARS = 16_000;
detailFields = {
filenames,
numFiles: filenames.length,
content: joined.length > GREP_CONTENT_PREVIEW_CHARS ? `${joined.slice(0, GREP_CONTENT_PREVIEW_CHARS)}\n…[truncated — full text in the tool output]` : joined,
numLines: rows.length,
...(byteTruncated ? {} : { totalLines: capTotal ?? rows.length }),
...truncatedFlag,
...appliedLimit,
...appliedOffset,
};
}
return detailFields;
}
export function createGrepTool(env, rootCanonical, rootsSeat, denySeat, readFace, cwdRef, readAdmission) {
const declaredTarget = toolFace("grep").pathTarget;
return defineTool({
name: "Grep",
...toolFace("grep"),
description: "Content search built on ripgrep. Prefer this over `grep`/`rg` via Bash — results integrate with the permission UI and file links.\n" +
"\n" +
'- Full regex syntax (e.g. "log.*Error", "function\\s+\\w+"). Ripgrep, not grep — escape literal braces (`interface\\{\\}`).\n' +
'- Filter with `glob` (e.g. "**/*.tsx") or `type` (e.g. "js", "py", "rust").\n' +
'- `output_mode`: "content" (matching lines), "files_with_matches" (paths only, default), or "count".\n' +
"- `multiline: true` for patterns that span lines.\n" +
"- Uses ripgrep when available (respects .gitignore, skips binary files and VCS directories), otherwise a JS fallback that skips node_modules/build/… and parses .gitignore. Hidden files/directories ARE searched.\n" +
"- Use Agent tool (if available) for open-ended searches requiring multiple rounds",
parameters: Type.Object({
pattern: Type.String({ description: "The regular expression pattern to search for in file contents" }),
path: Type.Optional(Type.String({ description: "File or directory to search in (rg PATH). Relative paths resolve against the root; defaults to the whole root." })),
glob: Type.Optional(Type.String({ description: 'Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}")' })),
type: Type.Optional(Type.String({ description: 'File type to search (rg --type): "js", "py", "rust", "go", "java", etc. More efficient than glob for standard file types.' })),
output_mode: Type.Optional(Type.Union([Type.Literal("content"), Type.Literal("files_with_matches"), Type.Literal("count")], {
description: '"content" (matching lines), "files_with_matches" (paths only), or "count" (match counts). Defaults to "files_with_matches".',
})),
context: Type.Optional(Type.Number({ description: "Lines of context around each match (content mode). CC alias: -C." })),
"-C": Type.Optional(Type.Number({ description: "Number of lines to show before and after each match (content mode only)." })),
"-A": Type.Optional(Type.Number({ description: "Number of lines to show after each match (content mode only)." })),
"-B": Type.Optional(Type.Number({ description: "Number of lines to show before each match (content mode only)." })),
ignore_case: Type.Optional(Type.Boolean({ description: "Case-insensitive match. CC alias: -i." })),
"-i": Type.Optional(Type.Boolean({ description: "Case insensitive search" })),
"-o": Type.Optional(Type.Boolean({
description: 'Print only the matched (non-empty) parts of each matching line, one match per output line (rg -o / --only-matching). Requires output_mode: "content", ignored otherwise. Defaults to false.',
})),
"-n": Type.Optional(Type.Boolean({ description: "Show line numbers (accepted for CC compatibility; content mode always includes line numbers)." })),
multiline: Type.Optional(Type.Boolean({ description: "Match across line boundaries." })),
head_limit: Type.Optional(Type.Number({
description: 'Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). Defaults to 250 when unspecified. Pass 0 for unlimited (use sparingly — large result sets waste context).',
})),
offset: Type.Optional(Type.Number({ description: "Skip the first N output lines (pagination partner of head_limit)." })),
}),
execute: async (args, ctx) => {
const readDeny = seatForCall(denySeat, ctx.toolCallId);
const additionalRoots = seatForCall(rootsSeat, ctx.toolCallId);
const retired = args;
for (const [name, repair] of [
["regex", "the `pattern` is ALWAYS evaluated as a regular expression (ripgrep semantics) — drop the key; to match text literally, escape the regex metacharacters in `pattern`"],
["max_results", "cap the result count with `head_limit` (0 = unlimited), paginating with `offset`"],
]) {
if (retired[name] !== undefined) {
return errorResult(`Error (Grep): \`${name}\` is not a Grep parameter (retired) — ${repair}. Nothing was searched.`);
}
}
const a = args;
for (const [name, value] of [
["head_limit", a.head_limit],
["offset", a.offset],
]) {
if (value !== undefined && (!Number.isInteger(value) || value < 0)) {
return errorResult(`Error (Grep): ${name} must be a whole number of 0 or more, got ${value}.${name === "head_limit" ? " Pass 0 for unlimited." : ""}`);
}
}
if (typeof a.glob === "string" && a.glob.length > 0) {
const badGlobs = invalidGlobTokens(a.glob);
if (badGlobs.length > 0) {
return errorResult(`Error (Grep): rejected the glob ${badGlobs.map((g) => JSON.stringify(g)).join(", ")} without searching — ` +
`an unterminated \`[\` class or \`{\` group cannot be compiled as a glob, and matching it literally would ` +
`silently narrow the search to a file with that exact name (an empty result would NOT mean "no matches"). ` +
`Close the bracket/brace, or drop \`glob\` and filter with \`path\`/\`type\` instead.`);
}
}
const baseCwd = pathTargetBaseOf(declaredTarget, { root: rootCanonical, cwd: cwdRef?.current });
const scopeSpelling = declaredTarget === undefined ? undefined : pathTargetValue(a, declaredTarget);
let scoped = scopeSpelling;
if (scopeSpelling !== undefined) {
const r = await resolveKey(env, rootCanonical, scopeSpelling, ctx.signal, baseCwd, additionalRoots, readAdmission && ((key) => readAdmission(ctx.toolCallId, key)), readDeny, readFace);
if (!r.ok)
return errorResult(violationText("Grep", r.violation), violationDetails(r.violation));
scoped = r.key;
const info = await env.fileInfo(scoped, ctx.signal);
if (!info.ok) {
const ex = await env.exists(scoped, ctx.signal);
if (!(ex.ok && !ex.value)) {
return errorResult(`Error (Grep): cannot determine the type of "${scopeSpelling.slice(0, 300)}" (stat failed: ${info.error.message.slice(0, 200)}); refusing to search it — if it is a FIFO/socket/device, reading it would hang. Retry if this was transient.`);
}
}
}
const grepRun = await runGrepDetailed(env, rootCanonical, {
...a,
path: scoped,
head_limit: a.head_limit,
context: a.context ?? a["-C"],
context_after: a.context_after ?? a["-A"],
context_before: a.context_before ?? a["-B"],
ignore_case: a.ignore_case ?? a["-i"],
only_matching: a.only_matching ?? a["-o"],
}, ctx.signal, readDeny);
const text = grepRun.text;
if (text.startsWith("Error (grep)") || text.startsWith("Error (Grep)"))
return errorResult(text);
const mode = a.output_mode ?? "files_with_matches";
const detailFields = grepDetailFields(text, mode, a.offset, grepRun.rows);
return {
content: text,
details: { type: "grep", mode, ...detailFields, ...(grepRun.degraded ?? {}), ...(grepRun.withheld !== undefined ? { withheld: grepRun.withheld } : {}) },
};
},
});
}
export function createGlobTool(env, rootCanonical, rootsSeat, denySeat, readFace, cwdRef, readAdmission) {
const declaredTarget = toolFace("glob").pathTarget;
return defineTool({
name: "Glob",
...toolFace("glob"),
description: 'Fast file pattern matching. Supports glob patterns like "**/*.js" or "src/**/*.ts". Returns matching file paths sorted by modification time.\n' +
"\n" +
"- On environments that do not report modification times, results fall back to alphabetical order\n" +
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)",
descriptionClassic: "- Fast file pattern matching tool that works with any codebase size\n" +
'- Supports glob patterns like "**/*.js" or "src/**/*.ts"\n' +
"- Returns matching file paths sorted by modification time\n" +
"- On environments that do not report modification times, results fall back to alphabetical order\n" +
"- Paths are RELATIVE to the root; ignored trees (node_modules/build/.gitignore) are skipped unless your pattern names them explicitly (e.g. `dist/**`)\n" +
"- Use this tool when you need to find files by name patterns; use `path` to scope to a sub-directory\n" +
"- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead (if available)",
parameters: Type.Object({
pattern: Type.String({ description: "Glob pattern (`*`, `**`, `?`)." }),
path: Type.Optional(Type.String({
description: 'Scope the search to this sub-directory (relative to root). IMPORTANT: Omit this field to search the whole root. DO NOT enter "undefined" or "null" - simply omit it for the default behavior. Must be a valid directory path if provided.',
})),
max_results: Type.Optional(Type.Number({ description: "Cap results (default 500)." })),
}),
execute: async (args, ctx) => {
const readDeny = seatForCall(denySeat, ctx.toolCallId);
const additionalRoots = seatForCall(rootsSeat, ctx.toolCallId);
let pattern = args.pattern;
let path = declaredTarget === undefined ? undefined : pathTargetValue(args, declaredTarget);
const { max_results } = args;
if (path === undefined) {
const split = splitAbsoluteGlobPattern(pattern, pathFamilyOf({ root: rootCanonical }));
if (split !== undefined && "refused" in split) {
return errorResult(`Error (Glob): pattern "${clipSpelling(pattern)}" ${WIN32_NAMESPACE_REFUSAL_TEXT[split.refused]}; refused (${split.refused}).`);
}
if (split) {
path = split.baseDir;
pattern = split.relativePattern;
}
}
const baseCwd = pathTargetBaseOf(declaredTarget, { root: rootCanonical, cwd: cwdRef?.current });
let scoped = path;
if (path !== undefined) {
const r = await resolveKey(env, rootCanonical, path, ctx.signal, baseCwd, additionalRoots, readAdmission && ((key) => readAdmission(ctx.toolCallId, key)), readDeny, readFace);
if (!r.ok)
return errorResult(violationText("Glob", r.violation), violationDetails(r.violation));
scoped = r.key;
}
const r2 = await runGlobDetailed(env, rootCanonical, pattern, { path: scoped, max: max_results, ...(readDeny !== undefined ? { deny: readDeny } : {}) }, ctx.signal);
if (r2.error !== undefined)
return errorResult(r2.error);
return {
content: r2.text,
details: { type: "glob", filenames: r2.filenames, numFiles: r2.numFiles, truncated: r2.truncated, durationMs: r2.durationMs, totalMatches: r2.totalMatches, countIsComplete: r2.countIsComplete, ...(r2.withheld !== undefined ? { withheld: r2.withheld } : {}) },
};
},
});
}