UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

155 lines (154 loc) • 8.1 kB
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()]))), }); }