// The built-in tools, in OpenAI function-tool shape. read_file is safe; the rest are gated. import { spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { scrubbedEnv } from "./secret-env.ts"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { glob as fsGlob } from "node:fs/promises"; import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; import type * as PtyType from "node-pty"; import { green, renderEditDiff } from "./render.ts"; import * as checkpoint from "./checkpoint.ts"; import { renderTodos, setTodos, type Todo } from "./todos.ts"; import { isTrusted, loadSettings } from "./settings.ts"; import { getDiagnostics } from "./lsp.ts"; import { buildPptx, type PptxSlideSpec } from "./pptx.ts"; import { buildDocx, type DocxBlock } from "./docx.ts"; import { browserAction, tabAction, type BrowserVerb } from "./browser.ts"; import { renderUiux, uiuxSearch } from "./uiux.ts"; // Every tool result is appended to the transcript and resent on each subsequent step, so an // oversized result is paid for again and again. 12k chars (~3k tokens) is ample for a file slice or // a listing; the model can re-read with offset/limit when it genuinely needs more. const MAX_OUTPUT = Number(process.env.ADA_MAX_TOOL_OUTPUT) || 12_000; export interface ToolResult { output: string; // text returned to the model isError?: boolean; display?: string; // optional rich, user-facing render (e.g. a colored diff) images?: string[]; // data URLs shown to the model alongside the text (e.g. a `look` screenshot) } /** The slice of Agent the self-awareness tools (live.ts) need. Defined here, not in agent.ts, so * tool modules can depend on it without importing the Agent class and creating a cycle; Agent * satisfies it structurally. */ export interface AgentHandle { contextTokens(): number; compactLimit(): number; compactNow(): Promise; usageRaw(): { model: string; promptTokens: number; completionTokens: number; cost: number | null }; lastText(): string; } /** What the calling agent knows about itself, handed to a tool at call time. * * An argument rather than a module-level "current session", deliberately: several chats stream at * once now, so an ambient value would be whichever turn set it last, not the one asking. Optional * because most tools neither need nor read it, and an agent outside a serve session has no id. */ export interface ToolCtx { sessionId?: string; /** The live-run registry id of the turn making this call (see live.ts). */ runId?: string; /** The calling agent itself, for tools that introspect it (context_status, goal, compact_now). */ agent?: AgentHandle; } export interface Tool { name: string; description: string; parameters: Record; // JSON Schema needsApproval: boolean; /** Big schema, rarely wanted: advertised only when the conversation asks for it (see * wantsLazyTools in agent.ts). Every tool schema is resent on every request, so keeping the * document/image generators out of a "hi" saves ~1k tokens a call. */ lazy?: boolean; /** Never advertised to an ordinary agent — only to one that names it via `only` (see browse.ts). * For tools whose loop is expensive enough to be worth running on a cheaper model in a sub-agent. */ hidden?: boolean; run(args: Record, ctx?: ToolCtx): Promise; } /** Keep both ends, not just the head. For the output that actually overflows — `npm install`, a test * run, a build — the head is progress noise and the verdict is in the last few lines. Dropping the * tail costs the same tokens and hides the answer, so the model spends another call finding it. */ export function clip(s: string, max = MAX_OUTPUT): string { if (s.length <= max) return s; const head = Math.floor(max / 3); const tail = max - head; return `${s.slice(0, head)}\n… [truncated ${s.length - max} chars] …\n${s.slice(-tail)}`; } // ponytail: every truncation spills — head+tail stays inline, the dropped middle stays retrievable function truncate(s: string): string { return spillIfHuge(s); } /** One choice offered by ask_user. `description` is the line under the label that says what picking * it actually means; it is optional to the model, so it is often "". */ export type AskOption = { label: string; description: string }; /** * Options arrive from the model as bare strings or as {label, description} — the schema allows both * because a one-word choice has nothing to explain, and models emit strings regardless of what the * schema says. Normalise here so no front-end has to care which it got. * * Anything without a label is dropped rather than rendered as an empty row you cannot pick. */ export function askOptions(raw: unknown): AskOption[] | undefined { if (!Array.isArray(raw)) return undefined; const out = raw .map((o) => o && typeof o === "object" ? { label: String((o as Record).label ?? ""), description: String((o as Record).description ?? "") } : { label: String(o), description: "" }, ) .filter((o) => o.label); return out.length ? out : undefined; } // The front-end (CLI/TUI) installs an asker so the ask_user tool can prompt the user mid-task. type Asker = (question: string, options?: AskOption[]) => Promise; let asker: Asker | null = null; export function setAsker(fn: Asker | null): void { asker = fn; } /** Strip HTML to readable text (no dependency) — good enough for "read this page". */ export function htmlToText(html: string): string { return html .replace(//gi, " ") .replace(//gi, " ") .replace(//g, " ") .replace(/]*>/gi, "\n- ") .replace(/<\/(?:p|div|section|article|tr|h[1-6])>/gi, "\n") .replace(/<(?:br|h[1-6])[^>]*>/gi, "\n") .replace(/<[^>]+>/g, " ") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/&#x?39;|'/gi, "'") .replace(/[ \t]+/g, " ") .replace(/\n{3,}/g, "\n\n") .trim(); } // Auto-format a just-written file with a discovered project formatter (best-effort). // Trust-gated (same gate as extensions/MCP) so a repo can't auto-run a trojan local formatter. const FORMATTERS: { exts: string[]; bin: string; args: (f: string) => string[] }[] = [ { exts: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".jsonc", ".css", ".scss", ".less", ".html", ".md", ".mdx", ".yaml", ".yml", ".vue", ".svelte", ".graphql"], bin: "prettier", args: (f) => ["--write", f] }, { exts: [".go"], bin: "gofmt", args: (f) => ["-w", f] }, { exts: [".rs"], bin: "rustfmt", args: (f) => [f] }, { exts: [".py"], bin: "ruff", args: (f) => ["format", "-q", f] }, { exts: [".sh", ".bash"], bin: "shfmt", args: (f) => ["-w", f] }, ]; const binCache = new Map(); function findBin(bin: string): string | null { const cached = binCache.get(bin); if (cached !== undefined) return cached; let found: string | null = null; const local = resolve(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? `${bin}.cmd` : bin); if (existsSync(local)) found = local; else { const probe = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf8" }); if (probe.status === 0 && (probe.stdout ?? "").trim()) found = bin; } binCache.set(bin, found); return found; } /** Format `abs` in place with a discovered formatter. No-op (returns false) if untrusted, disabled, * or no formatter is available for the extension. Never throws. */ export function formatFile(abs: string): boolean { if (process.env.ADA_NO_FORMAT || !isTrusted(process.cwd())) return false; const ext = extname(abs).toLowerCase(); const fmt = FORMATTERS.find((f) => f.exts.includes(ext) && findBin(f.bin)); if (!fmt) return false; try { return spawnSync(findBin(fmt.bin)!, fmt.args(abs), { timeout: 10_000, encoding: "utf8", shell: process.platform === "win32", env: scrubbedEnv() }).status === 0; } catch { return false; } } // node-pty gives the bash tool a real terminal. It's a required dependency; if the native build is // ever broken on a platform, fall back to spawnSync so bash still works. Loaded LAZILY on the first // bash call: merely loading the native module on Windows sets up async handles whose teardown races // process.exit and prints "Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)" — commands that // never spawn a PTY (--version, catalog, --list-models, …) shouldn't pay that. let ptyMod: typeof PtyType | null | undefined; function getPty(): typeof PtyType | null { if (ptyMod === undefined) { try { ptyMod = createRequire(import.meta.url)("node-pty") as typeof PtyType; } catch { ptyMod = null; } } return ptyMod; } // Built via new RegExp (string escapes) so no literal ESC/BEL bytes live in the source. const ANSI = new RegExp("[\\u001B\\u009B][\\[\\]()#;?]*(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007|(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])", "g"); function stripAnsi(s: string): string { return s.replace(ANSI, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } /** Run a command in a PTY (real terminal); resolves with combined output + exit code. */ function runPty(command: string, timeoutMs = 120_000): Promise<{ output: string; code: number | null }> { return new Promise((res) => { const win = process.platform === "win32"; const shell = win ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "/bin/bash"; const shellArgs = win ? ["/c", command] : ["-lc", command]; const p = getPty()!.spawn(shell, shellArgs, { name: "xterm-256color", cols: 120, rows: 30, cwd: process.cwd(), env: scrubbedEnv() }); let out = ""; const cap = 10 * 1024 * 1024; p.onData((d) => { if (out.length < cap) out += d; }); let done = false; const finish = (code: number | null): void => { if (done) return; done = true; clearTimeout(timer); res({ output: out, code }); }; const timer = setTimeout(() => { try { p.kill(); } catch { /* already gone */ } finish(null); }, timeoutMs); p.onExit(({ exitCode }) => finish(exitCode)); }); } /** Block localhost / private / metadata hosts (basic SSRF guard for web_fetch). */ function isBlockedHost(host: string): boolean { const h = host.toLowerCase().replace(/^\[|\]$/g, ""); if (h === "localhost" || h.endsWith(".localhost") || h === "::1" || h === "0.0.0.0") return true; const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (m) { const a = Number(m[1]); const b = Number(m[2]); if (a === 0 || a === 10 || a === 127 || (a === 192 && b === 168) || (a === 172 && b >= 16 && b <= 31) || (a === 169 && b === 254)) return true; } return false; } const IMG_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico"]); // Serialize mutations to the same path so concurrent writes never interleave. const fileLocks = new Map>(); function withFileLock(abs: string, fn: () => Promise): Promise { const prev = fileLocks.get(abs) ?? Promise.resolve(); const next = prev.then(fn, fn); fileLocks.set(abs, next.catch(() => undefined)); return next; } // Huge output is spilled to .ada/tmp and replaced by a head + pointer, instead of lost to truncation. export function spillIfHuge(text: string): string { if (text.length <= MAX_OUTPUT) return text; try { const dir = join(process.cwd(), ".ada", "tmp"); mkdirSync(dir, { recursive: true }); const f = join(dir, `out-${Date.now()}-${Math.floor(Math.random() * 1e6)}.txt`); writeFileSync(f, text, "utf8"); // ponytail: sweep yesterday's spills on the way past — no scheduler, no lifecycle hook const cutoff = Date.now() - 24 * 60 * 60 * 1000; for (const old of readdirSync(dir)) { if (Number(old.match(/^out-(\d+)-/)?.[1] ?? Infinity) < cutoff) rmSync(join(dir, old), { force: true }); } return `${clip(text)}\n[full output: ${relative(process.cwd(), f)}]`; } catch { return clip(text); // not truncate() — that routes back here } } function globMatch(rel: string, pattern: string): boolean { const p = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "::").replace(/\*/g, "[^/]*").replace(/::/g, ".*"); try { return new RegExp(`^${p}$`).test(rel); } catch { return false; } } /** A write/edit target is protected if it matches (or contains) a glob in settings.protectedPaths. */ function isProtected(abs: string): boolean { const pats = loadSettings(true).protectedPaths; if (!pats || !pats.length) return false; const rel = relative(process.cwd(), abs).replace(/\\/g, "/"); return pats.some((g) => rel.includes(g) || abs.includes(g) || globMatch(rel, g)); } // Note the /dev/ rule excludes the standard sinks (>/dev/null, 2>/dev/null, /dev/stdout, …) — those // are everyday redirects, not device-overwrites like `> /dev/sda`. const DESTRUCTIVE = /\brm\s+-[a-z]*[rf]|\brmdir\b|\bdd\b|mkfs|>\s*\/dev\/(?!(?:null|stdout|stderr|tty|zero|u?random)(?:$|[\s>;&|])|fd\/)|:\(\)\s*\{|git\s+push\b[^\n]*--force|git\s+reset\s+--hard|\bshutdown\b|\breboot\b|\bkillall\b|chmod\s+-R|chown\s+-R/i; /** True for shell commands dangerous enough to always confirm, even in auto-approve. */ export function isDestructive(command: string): boolean { return DESTRUCTIVE.test(command); } /** Anything the page would have to fetch from the network to render correctly. A page that needs a * CDN isn't a file you can send someone — it's a file that breaks offline, behind a proxy, or the * day the CDN moves. Images degrade (alt text shows); scripts and stylesheets do not, so those are * the ones worth refusing over. */ function externalRefs(html: string): { fatal: string[]; soft: string[] } { const fatal: string[] = []; const soft: string[] = []; const push = (list: string[], what: string): void => { if (!list.includes(what) && list.length < 6) list.push(what); }; for (const m of html.matchAll(/]*\bsrc\s*=\s*["'](https?:)?\/\/([^"']+)/gi)) push(fatal, `