@sema-agent/core
Version:
Stateless, task-oriented AI agent core
383 lines (382 loc) • 18.9 kB
JavaScript
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 { 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 MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024;
export const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES = 64 * 1024 * 1024;
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)." }),
};
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 view = await views.of(path, leaf !== undefined ? { leaf } : undefined);
return view !== undefined && readDeny.matchPath(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;
}