UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

400 lines (399 loc) • 19.7 kB
import { Type } from "typebox"; import { clipWithFilePointer, OUTPUT_BUDGET_MIN_CHARS } from "../../core/tool-errors.js"; import { normalizeAbsPathLexically, sha256, similarNameSuggestion, OVERSIZE_READ_ESCAPE_HINT, PARTIAL_VIEW_READ_ESCAPE_HINT, } from "./safety.js"; import { decodeTextBytes, normalizeFileText } from "./encoding.js"; import { shellQuote } from "./search.js"; import { isNotebookPath } from "./notebook.js"; import { homeViewsOf } from "./read-deny-home.js"; import { sharpImageDownsampler } from "../../core/mcp.js"; import { MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES } from "../../core/image-admission.js"; import { deliverEngineNotice } from "../../core/types.js"; import { resolveEnvBudget } from "../../core/env-budget.js"; import { openSystemReminder } from "../../core/reminder-mint.js"; export const MAX_READ_BYTES = 256 * 1024; export const SLICED_READ_MAX_BYTES = 64 * 1024 * 1024; export const MAX_EDIT_BYTES = 1024 * 1024 * 1024; export function formatByteSize(n) { const kb = n / 1024; if (kb < 1) return `${n} bytes`; if (kb < 1024) return `${kb.toFixed(1).replace(/\.0$/, "")}KB`; const mb = kb / 1024; if (mb < 1024) return `${mb.toFixed(1).replace(/\.0$/, "")}MB`; return `${(mb / 1024).toFixed(1).replace(/\.0$/, "")}GB`; } export function decodeEditBytes(bytes, path) { try { return { ok: true, value: decodeTextBytes(bytes) }; } catch { return { ok: false, message: tooLargeToEditMessage(path, bytes.byteLength) }; } } export function tooLargeToEditMessage(path, bytes) { return `Error (Edit): "${path}" is too large to edit (${formatByteSize(bytes)}). Maximum editable file size is ${formatByteSize(MAX_EDIT_BYTES)}.`; } export function truncatedUtf16BodyMessage(tool, path) { return `Error (${tool}): "${path}" has a truncated UTF-16 body; repair/convert it with bash first.`; } export const BASH_READONLY_CONFINEMENT_NOTE = "bash_readonly is confined to the workspace roots; it has no approval path, so the call is refused rather than escalated."; export function persistedTextOf(encoded) { return decodeTextBytes(typeof encoded === "string" ? Buffer.from(encoded, "utf8") : encoded).text; } export function applyRecordedEdit(text, edit) { if (edit.oldString === "") return edit.newString; return edit.replaceAll ? text.split(edit.oldString).join(edit.newString) : text.replace(edit.oldString, () => edit.newString); } export async function notReadRefusalText(env, toolName, key, v, signal, fallbackHint) { const base = `Error (${toolName}): ${v.message}`; if (v.partialView === true && !isNotebookPath(key)) return `${base} ${PARTIAL_VIEW_READ_ESCAPE_HINT}`; const info = await env.fileInfo(key, signal); if (info.ok && info.value.kind !== "directory" && info.value.size > MAX_READ_BYTES && !isNotebookPath(key)) return `${base} ${OVERSIZE_READ_ESCAPE_HINT}`; return fallbackHint === undefined ? base : `${base} ${fallbackHint}`; } export const NO_DOWNSAMPLER_IMAGE_CAP_HINT = `Install the optional "sharp" dependency (declared in this package's optionalDependencies) to read images up to ${MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES} bytes — it downsamples them to fit the API image limit.`; let autoDownsampler; export function resolveAutoDownsampler() { return (autoDownsampler ??= sharpImageDownsampler()); } export const MAX_READ_OUTPUT_CHARS = 100_000; export const BASH_DEFAULT_TIMEOUT_SEC = 120; export const BASH_MAX_TIMEOUT_SEC = 600; export const BASH_DEFAULT_TIMEOUT_MS = BASH_DEFAULT_TIMEOUT_SEC * 1000; export const BASH_MAX_TIMEOUT_MS = BASH_MAX_TIMEOUT_SEC * 1000; export const MIN_BASH_TIMEOUT_MS = 1000; export const BASH_TIMEOUT_PLATEAU_END_MS = 1500; function bashTimeoutDefect(ms) { if (!Number.isFinite(ms)) return "must be a finite number of milliseconds"; if (ms <= 0) return `must be a positive number of milliseconds (received ${ms})`; if (ms < MIN_BASH_TIMEOUT_MS) { return (`is in MILLISECONDS and ${ms} is under one second — if ${ms} seconds was meant, the value is ` + `${ms * 1000}. Values below ${MIN_BASH_TIMEOUT_MS}ms all collapse to the same one-second budget`); } if (ms > MIN_BASH_TIMEOUT_MS && ms < BASH_TIMEOUT_PLATEAU_END_MS) { return (`is ${ms}ms, which rounds down to the same one-second budget as ${MIN_BASH_TIMEOUT_MS}ms — the ` + `shell's granularity is whole seconds, so write ${MIN_BASH_TIMEOUT_MS} for one second or at least ` + `${BASH_TIMEOUT_PLATEAU_END_MS} for two`); } return undefined; } function validTimeoutMs(n) { if (n === undefined || !Number.isFinite(n)) return undefined; const floored = Math.floor(n); return bashTimeoutDefect(floored) === undefined ? floored : undefined; } export function envErrorDetail(message) { const trimmed = (message ?? "").trim(); return trimmed === "" ? "the execution environment reported no reason" : trimmed; } let announcedTimeoutConfigBySink = new WeakMap(); const announcedTimeoutConfigConsole = new Set(); function timeoutConfigLedger(onNotice) { if (typeof onNotice !== "function") return announcedTimeoutConfigConsole; let lines = announcedTimeoutConfigBySink.get(onNotice); if (lines === undefined) { lines = new Set(); announcedTimeoutConfigBySink.set(onNotice, lines); } return lines; } function emitTimeoutDiscardNotice(message, detail, onNotice) { const ledger = timeoutConfigLedger(onNotice); if (ledger.has(message)) return; ledger.add(message); deliverEngineNotice(onNotice, { code: "config.env_timeout_discarded", message, detail }); } function announceDiscardedTimeout(knob, raw, usedMs, onNotice) { if (typeof raw === "string" && raw === "") return; const n = typeof raw === "number" ? raw : Number(raw); let line; if (!Number.isFinite(n) || n <= 0) { line = `${knob}=${String(raw)} was ignored — not a positive finite millisecond count. Using ${usedMs}ms instead.`; } else { const defect = bashTimeoutDefect(Math.floor(n)); if (defect === undefined) return; line = `${knob}=${String(raw)} was ignored — it ${defect}. Using ${usedMs}ms instead.`; } emitTimeoutDiscardNotice(line, { knob, raw, usedMs }, onNotice); } export function bashTimeoutParamDescription(caps, withDefault) { const bounds = `${withDefault ? `default ${caps.defaultMs}, ` : ""}min ${MIN_BASH_TIMEOUT_MS}, max ${caps.maxMs}`; const escape = caps.maxMs >= BASH_TIMEOUT_PLATEAU_END_MS ? ` — pass exactly ${MIN_BASH_TIMEOUT_MS} for one second, or ${BASH_TIMEOUT_PLATEAU_END_MS}+ for longer` : ` — ${MIN_BASH_TIMEOUT_MS} is the only value this deployment accepts below its ${caps.maxMs}ms ceiling`; return (`Timeout in milliseconds (${bounds}). The shell's granularity is whole seconds, so ${MIN_BASH_TIMEOUT_MS} is the ` + `smallest accepted value and anything between ${MIN_BASH_TIMEOUT_MS} and ${BASH_TIMEOUT_PLATEAU_END_MS} is REJECTED ` + `(it would round back down to ${MIN_BASH_TIMEOUT_MS})${escape}. Requests above the max are capped to it.`); } export function bashTimeoutArgRefusal(timeoutMs, caps) { if (timeoutMs === undefined) return undefined; const defect = bashTimeoutDefect(timeoutMs); if (defect === undefined) return undefined; return (`Error (Bash): \`timeout\` ${defect}. The command was NOT run. Omit \`timeout\` to use the default ` + `budget of ${caps.defaultMs}ms (maximum ${caps.maxMs}ms).`); } export function resolveBashTimeoutCaps(opts) { const defaultMs = validTimeoutMs(opts?.bashDefaultTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_DEFAULT_TIMEOUT_MS)) ?? BASH_DEFAULT_TIMEOUT_MS; const maxMs = Math.max(validTimeoutMs(opts?.bashMaxTimeoutMs) ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)) ?? BASH_MAX_TIMEOUT_MS, defaultMs); if (opts?.bashDefaultTimeoutMs !== undefined) announceDiscardedTimeout("bashDefaultTimeoutMs", opts.bashDefaultTimeoutMs, defaultMs, opts?.onNotice); if (process.env.BASH_DEFAULT_TIMEOUT_MS !== undefined) announceDiscardedTimeout("BASH_DEFAULT_TIMEOUT_MS", process.env.BASH_DEFAULT_TIMEOUT_MS, defaultMs, opts?.onNotice); if (opts?.bashMaxTimeoutMs !== undefined) announceDiscardedTimeout("bashMaxTimeoutMs", opts.bashMaxTimeoutMs, maxMs, opts?.onNotice); if (process.env.BASH_MAX_TIMEOUT_MS !== undefined) announceDiscardedTimeout("BASH_MAX_TIMEOUT_MS", process.env.BASH_MAX_TIMEOUT_MS, maxMs, opts?.onNotice); const optsCap = validTimeoutMs(opts?.bashMaxTimeoutMs); const requestedCap = optsCap ?? validTimeoutMs(Number(process.env.BASH_MAX_TIMEOUT_MS)); if (requestedCap !== undefined && requestedCap < defaultMs) { const knob = optsCap !== undefined ? "bashMaxTimeoutMs" : "BASH_MAX_TIMEOUT_MS"; const line = `${knob}=${requestedCap} is below the resolved default budget (${defaultMs}ms) — the ceiling was raised to ${maxMs}ms (the default always fits under the cap).`; emitTimeoutDiscardNotice(line, { knob, raw: requestedCap, usedMs: maxMs }, opts?.onNotice); } return { defaultMs, maxMs }; } export function __resetBashTimeoutAnnouncements() { announcedTimeoutConfigBySink = new WeakMap(); announcedTimeoutConfigConsole.clear(); } export function bashTimeoutCapsSec(caps) { return { defaultSec: Math.max(1, Math.round(caps.defaultMs / 1000)), maxSec: Math.max(1, Math.round(caps.maxMs / 1000)) }; } const BASH_DEFAULT_MAX_OUTPUT_CHARS = 30_000; const BASH_MAX_OUTPUT_CHARS_CEILING = 150_000; export function bashMaxOutputChars() { return resolveEnvBudget(process.env.BASH_MAX_OUTPUT_LENGTH, { name: "BASH_MAX_OUTPUT_LENGTH", unit: "characters", default: BASH_DEFAULT_MAX_OUTPUT_CHARS, min: OUTPUT_BUDGET_MIN_CHARS, max: BASH_MAX_OUTPUT_CHARS_CEILING }); } export const FILE_PATH_PARAMS = { file_path: Type.String({ description: "File path (within the configured root)." }), }; const WRITE_CONTENT_ALIASES = ["file_text", "file_content"]; export function coerceWriteArguments(args) { if (args === null || typeof args !== "object" || Array.isArray(args)) return args; const a = args; let repaired; if (!Object.hasOwn(a, "file_path") && typeof a["path"] === "string") { repaired = { ...a, file_path: a["path"] }; delete repaired["path"]; } const spelled = WRITE_CONTENT_ALIASES.filter((k) => Object.hasOwn(a, k)); const alias = spelled[0]; if (alias !== undefined && spelled.length === 1 && !Object.hasOwn(a, "content") && typeof a[alias] === "string") { repaired = { ...(repaired ?? a), content: a[alias] }; delete repaired[alias]; } return repaired ?? args; } export function clipShellOutput(s) { return clipWithFilePointer(s, bashMaxOutputChars()); } export async function writeShellOverflowFile(env, stdout, stderr) { const tf = await env.createTempFile({ prefix: SHELL_OVERFLOW_FILE_PREFIX, suffix: SHELL_OVERFLOW_FILE_SUFFIX }); if (!tf.ok) return undefined; const body = stderr.length > 0 ? `${stdout}${stdout.length > 0 && !stdout.endsWith("\n") ? "\n" : ""}--- stderr ---\n${stderr}` : stdout; const w = await env.writeFile(tf.value, body); if (!w.ok) return undefined; const canon = await env.canonicalPath(tf.value); return canon.ok ? canon.value : tf.value; } const SHELL_OVERFLOW_FILE_PREFIX = "bash-output-"; const SHELL_OVERFLOW_FILE_SUFFIX = ".log"; function toPosixPathKey(p) { return p.replace(/\\/g, "/").replace(/(.)\/+$/, "$1"); } function isShellOverflowFileName(base) { if (!base.startsWith(SHELL_OVERFLOW_FILE_PREFIX) || !base.endsWith(SHELL_OVERFLOW_FILE_SUFFIX)) return false; const middle = base.slice(SHELL_OVERFLOW_FILE_PREFIX.length, base.length - SHELL_OVERFLOW_FILE_SUFFIX.length); return /^[0-9A-Za-z][0-9A-Za-z._-]*$/.test(middle); } export function createShellOverflowSpoolFence(env) { let tempArea; const learnTempArea = async () => { const probe = await env.createTempDir(); if (!probe.ok) return undefined; const canon = await env.canonicalPath(probe.value); const resolved = toPosixPathKey(canon.ok ? canon.value : probe.value); await env.remove(probe.value, { recursive: true, force: true }); const cut = resolved.lastIndexOf("/"); return cut > 0 ? resolved.slice(0, cut) : undefined; }; return async (path) => { const target = normalizeAbsPathLexically(toPosixPathKey(path)); const cut = target.lastIndexOf("/"); if (cut <= 0 || !isShellOverflowFileName(target.slice(cut + 1))) return false; tempArea ??= learnTempArea(); const area = await tempArea; if (area === undefined) return false; const parent = target.slice(0, cut); if (parent === area) return true; const up = parent.lastIndexOf("/"); return up > 0 && parent.slice(0, up) === area; }; } export function shellRecoveryHint(path, readOnly) { const quoted = shellQuote(path); const example = readOnly ? `tail -c 50000 ${quoted}` : `sed -n 'START,ENDp' ${quoted}`; return `\n(captured output preserved at ${path} — inspect with bash, e.g. \`${example}\`)`; } export const FILE_STATE_TRAILER = " (file state is current in your context — no need to Read it back)"; export const CWD_SENTINEL = "__cc_cwd_9f2c1b__"; const CWD_SENTINEL_SUFFIX_CHARS = 16; function cwdMarkerAt(text, open) { const after = open + CWD_SENTINEL.length; const suffix = /^[0-9a-f]{16}/.exec(text.slice(after, after + CWD_SENTINEL_SUFFIX_CHARS)); return suffix === null ? undefined : CWD_SENTINEL + suffix[0]; } export function stripCwdSentinel(stderr, marker) { let out = ""; let rest = stderr; let cwd; let from = 0; for (;;) { const open = rest.indexOf(marker ?? CWD_SENTINEL, from); if (open === -1) break; const span = marker ?? cwdMarkerAt(rest, open); if (span === undefined) { from = open + CWD_SENTINEL.length; continue; } const close = rest.indexOf(span, open + span.length); if (close === -1) break; out += rest.slice(0, open); cwd = rest.slice(open + span.length, close); rest = rest.slice(close + span.length); from = 0; } return { stderr: out + rest, cwd }; } export function msTimeoutToSec(timeoutMs, caps = resolveBashTimeoutCaps()) { const { defaultSec, maxSec } = bashTimeoutCapsSec(caps); if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) return defaultSec; return Math.min(maxSec, Math.max(1, Math.round(timeoutMs / 1000))); } export function ipynbRedirect(toolName, path) { if (path.toLowerCase().endsWith(".ipynb")) { return `Error (${toolName}): "${path}" is a Jupyter notebook; editing it as raw text corrupts the notebook JSON. Use the NotebookEdit tool (notebook_path + cell_id + new_source) instead.`; } return undefined; } export function countLines(s) { if (s.length === 0) return 0; const n = s.split("\n").length; return s.endsWith("\n") ? n - 1 : n; } export function seededFileUnchangedReminder(filePath, mark) { return `${openSystemReminder(mark)}This file is already in your context (see "Contents of ${filePath}" above) and has not changed on disk. Use that content instead of re-reading.</system-reminder>`; } export function isReadDedupStubResult(resultText) { return (resultText.includes("unchanged since you last read it") || resultText.includes("has not changed on disk. Use that content instead of re-reading")); } export function seedReadFileStateFromContext(state, key, content) { state.set(key, { hash: sha256(normalizeFileText(content)), totalLines: countLines(content), truncated: false, lastReadAt: Date.now(), seededFromContext: true }); } export function seedReadFileStateFromTranscript(state, key, content, lastReadAt) { const text = normalizeFileText(content); state.set(key, { hash: sha256(text), totalLines: countLines(text), truncated: false, lastReadAt }); } export async function inheritSessionReadFileState(target, inherited, judgeKey) { for (const [key, entry] of inherited) { if (entry.seededFromContext === true) continue; const judged = await judgeKey(key); if (judged !== undefined) target.set(judged, entry); } } export function applyContextShrinkToReadFileState(state, shrink) { if (shrink.kind === "tool-results-cleared") { if (shrink.clearedToolCallIds.length === 0) return; const cleared = new Set(shrink.clearedToolCallIds); for (const [k, v] of [...state]) { if (v.transmittedByToolCallId !== undefined && cleared.has(v.transmittedByToolCallId)) state.delete(k); } return; } const { attachedComplete, preserveKeys = [] } = shrink; const preserve = new Set(preserveKeys); for (const [k, v] of [...state]) { if (v.seededFromContext !== true && !preserve.has(k)) state.delete(k); } for (const f of attachedComplete) { const totalLines = countLines(f.content); const displayLines = f.content.length === 0 ? 0 : f.content.split("\n").length; state.set(f.path, { hash: sha256(f.content), totalLines, truncated: false, view: { start: 1, end: displayLines }, lastReadAt: Date.now() }); } } export async function enoentMessage(env, key, cwd, signal, readDeny) { let msg = `File does not exist. Note: your current working directory is ${cwd}.`; const sep = Math.max(key.lastIndexOf("/"), key.lastIndexOf("\\")); const views = readDeny !== undefined ? homeViewsOf(env, readDeny, signal) : undefined; const denied = async (path, leaf) => { if (readDeny === undefined || views === undefined) return false; if (readDeny.matchPath(path) != null) return true; const home = await views.of(path, leaf !== undefined ? { leaf } : undefined); return home.unread !== undefined || (home.view !== undefined && readDeny.matchPath(home.view) != null); }; if (sep > 0) { const parent = key.slice(0, sep); const name = key.slice(sep + 1); if (await denied(parent)) return msg; const cwdSepped = cwd.replace(/[\\/]+$/, ""); const cwdCandidate = `${cwdSepped}${cwd.includes("\\") ? "\\" : "/"}${name}`; if (cwdCandidate !== key && !(await denied(cwdCandidate))) { const hit = await env.exists(cwdCandidate, signal).catch(() => undefined); if (hit?.ok && hit.value) return `${msg} Did you mean ${cwdCandidate}?`; } const listing = await env.listDir(parent, signal).catch(() => undefined); if (listing?.ok) { const names = []; for (const e of listing.value) { if (e.kind === "directory") continue; const n2 = e.path.slice(Math.max(e.path.lastIndexOf("/"), e.path.lastIndexOf("\\")) + 1); if (!(await denied(`${parent}/${n2}`, "file"))) names.push(n2); } const suggestion = similarNameSuggestion(names, name); if (suggestion) msg += ` Did you mean ${suggestion}?`; } } return msg; }