@sema-agent/core
Version:
Stateless, task-oriented AI agent core
155 lines (154 loc) • 8.1 kB
JavaScript
import { shellQuote } from "../../tools/fs/search.js";
export const PROCESS_RUN_DEFAULT_TIMEOUT_MS = 30_000;
export const PROCESS_RUN_MAX_TIMEOUT_MS = 600_000;
export const MODEL_COMPLETE_DEFAULT_MAX_TOKENS = 1_024;
export const SESSION_MESSAGES_MAX = 4_096;
const refuse = (event, why) => new Error(`${event}: ${why} (host check)`);
function only(args, event, keys) {
const extra = Object.keys(args).filter((k) => !keys.includes(k));
if (extra.length > 0)
throw refuse(event, `takes ${keys.length === 0 ? "no input" : `{ ${keys.join(", ")} }`}; "${extra[0]}" is not one`);
}
function wholeMs(v, max) {
return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= max;
}
async function processRun(view, args, ctx) {
only(args, "process.run", ["argv", "init"]);
const argv = args["argv"];
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === "string"))
throw refuse("process.run", "takes argv, a non-empty list of strings");
const init = args["init"] ?? {};
if (typeof init !== "object" || init === null || Array.isArray(init))
throw refuse("process.run", "takes init, { cwd?, timeoutMs? }");
const cwd = Reflect.get(init, "cwd");
const timeoutMs = Reflect.get(init, "timeoutMs") ?? PROCESS_RUN_DEFAULT_TIMEOUT_MS;
if (Object.keys(init).some((k) => k !== "cwd" && k !== "timeoutMs"))
throw refuse("process.run", "takes init, { cwd?, timeoutMs? }");
if (cwd !== undefined && (typeof cwd !== "string" || cwd === ""))
throw refuse("process.run", "init.cwd is a non-empty path");
if (!wholeMs(timeoutMs, PROCESS_RUN_MAX_TIMEOUT_MS))
throw refuse("process.run", `init.timeoutMs is a whole number of ms, 1 to ${PROCESS_RUN_MAX_TIMEOUT_MS}`);
const command = argv.map((a) => shellQuote(String(a))).join(" ");
const r = await view.env.exec(command, { ...(cwd !== undefined ? { cwd } : {}), timeout: timeoutMs / 1000, abortSignal: ctx.signal });
if (!r.ok)
throw new Error(`process.run: ${r.error.message}`);
return Object.freeze({ stdout: r.value.stdout, stderr: r.value.stderr, exitCode: r.value.exitCode });
}
const BLOCKS_FORM = "a string or a list of blocks, each { text } and at most `cache: true`";
function blocksOf(v) {
if (!Array.isArray(v))
return undefined;
const out = [];
for (const b of v) {
if (typeof b !== "object" || b === null || typeof Reflect.get(b, "text") !== "string")
return undefined;
const cache = Reflect.get(b, "cache");
if (Object.keys(b).some((k) => k !== "text" && k !== "cache") || (cache !== undefined && typeof cache !== "boolean"))
return undefined;
out.push(Object.freeze({ text: Reflect.get(b, "text"), ...(cache === true ? { cache: true } : {}) }));
}
return out;
}
function modelCompleteEnter(args) {
const out = { ...args };
for (const [key, blocksKey] of [["prompt", "promptBlocks"], ["system", "systemBlocks"]]) {
const given = args[key];
if (typeof given === "string" || (key === "system" && given === undefined))
continue;
const blocks = blocksOf(given);
if (blocks === undefined)
throw refuse("model.complete", `takes ${key} as ${BLOCKS_FORM}`);
out[key] = blocks.map((b) => b.text).join("");
out[blocksKey] = blocks;
}
return out;
}
function alignedBlocks(text, blocks) {
const kept = [];
let at = 0;
for (const b of blocks) {
if (b.text === "" || !text.startsWith(b.text, at))
break;
kept.push(b);
at += b.text.length;
}
const rest = text.slice(at);
return rest === "" ? kept : [...kept, { text: rest }];
}
async function modelComplete(view, args, ctx) {
only(args, "model.complete", ["prompt", "promptBlocks", "system", "systemBlocks", "model", "maxTokens"]);
const prompt = args["prompt"];
const system = args["system"];
const model = args["model"];
const maxTokens = args["maxTokens"] ?? MODEL_COMPLETE_DEFAULT_MAX_TOKENS;
if (typeof prompt !== "string" || prompt === "")
throw refuse("model.complete", `takes { prompt }, ${BLOCKS_FORM}, not empty`);
if (system !== undefined && typeof system !== "string")
throw refuse("model.complete", `takes system as ${BLOCKS_FORM}`);
const promptBlocks = args["promptBlocks"] === undefined ? [] : blocksOf(args["promptBlocks"]);
const systemBlocks = args["systemBlocks"] === undefined ? [] : blocksOf(args["systemBlocks"]);
if (promptBlocks === undefined || systemBlocks === undefined)
throw refuse("model.complete", `takes promptBlocks and systemBlocks that are lists of blocks, each { text } and at most \`cache: true\``);
if (promptBlocks.some((b) => b.cache === true))
throw refuse("model.complete", "`cache: true` is taken on system blocks only (a prompt block has no cache seat); put the text to cache in system");
if (model !== undefined && (typeof model !== "string" || model === ""))
throw refuse("model.complete", "model is a model reference (a non-empty string)");
if (typeof maxTokens !== "number" || !Number.isInteger(maxTokens) || maxTokens < 1)
throw refuse("model.complete", "maxTokens is a positive whole number");
const spans = system !== undefined && systemBlocks.some((b) => b.cache === true) ? alignedBlocks(system, systemBlocks) : undefined;
const context = {
...(system !== undefined && system !== "" ? { systemPrompt: system } : {}),
...(spans !== undefined ? { systemBlocks: spans.map((b) => ({ text: b.text, cacheControlBoundary: b.cache === true })) } : {}),
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
};
const reply = await view.complete({ ...(typeof model === "string" ? { model } : {}), maxTokens, context }, ctx.signal);
view.book({ model: reply.model, ...(reply.usage !== undefined ? { usage: reply.usage } : {}) });
return reply;
}
function sleep(args, ctx) {
only(args, "clock.sleep", ["ms"]);
const ms = args["ms"];
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0)
throw refuse("clock.sleep", "takes { ms }, a non-negative number of milliseconds");
return new Promise((resolve, reject) => {
if (ctx.signal.aborted)
return reject(ctx.signal.reason);
const t = setTimeout(() => {
ctx.signal.removeEventListener("abort", stop);
resolve();
}, ms);
const stop = () => {
clearTimeout(t);
reject(ctx.signal.reason);
};
ctx.signal.addEventListener("abort", stop, { once: true });
});
}
function detachedMessage(m) {
try {
return structuredClone(m);
}
catch {
throw refuse("session.messages", "a session message could not be copied; the history is not handed out live");
}
}
const read = (event, f) => (args) => {
only(args, event, []);
return f();
};
export function engineMethodBottoms(view) {
const run = (f) => Object.freeze({ run: f });
return Object.freeze({
"process.run": run((args, ctx) => processRun(view, args, ctx)),
"model.complete": Object.freeze({ enter: modelCompleteEnter, run: (args, ctx) => modelComplete(view, args, ctx) }),
"session.id": run(read("session.id", () => view.session.id())),
"session.cwd": run(read("session.cwd", () => view.session.cwd())),
"session.root": run(read("session.root", () => view.session.root())),
"session.model": run(read("session.model", () => view.session.model())),
"session.messages": run(read("session.messages", () => Object.freeze(view.session.messages().slice(-SESSION_MESSAGES_MAX).map(detachedMessage)))),
"clock.now": run(read("clock.now", () => view.now())),
"clock.sleep": run((args, ctx) => sleep(args, ctx)),
"tool.list": run(read("tool.list", () => Object.freeze([...view.tools()]))),
"agent.list": run(read("agent.list", () => Object.freeze([...view.agents()]))),
});
}