UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

235 lines (234 loc) • 15.8 kB
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 { 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, additionalRoots, readDeny, 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 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, additionalRoots, readDeny, 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) => { 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 } : {}) }, }; }, }); }