UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

171 lines (170 loc) • 5.95 kB
import { mkdirSync, readFileSync } from "node:fs"; import { assertRetentionPolicy } from "../core/retention-policy.js"; import { atomicWriteFile } from "../stores/file/fs-atomic.js"; import { dirname } from "node:path"; import { normalizeAgentName } from "../core/task-registry.js"; import { createSafeNotifier } from "../core/safe-notify.js"; export function entryAccessible(e, access) { if (e.scope === undefined || access.scope === undefined || access.scope !== e.scope) return false; if (e.owner === undefined) return false; if (access.owner === e.owner) return true; if (e.sessionScoped === true && access.sessionId !== undefined && access.sessionId === e.owner) return true; return false; } function latestOf(pool) { let best; for (const e of pool) if (best === undefined || e.createdAt >= best.createdAt) best = e; return best; } function resolveIn(entries, name, access) { const key = normalizeAgentName(name); if (key === "") return undefined; return latestOf(entries.filter((e) => normalizeAgentName(e.name) === key && entryAccessible(e, access))); } function freshOnly(entries, maxAgeMs, now = Date.now()) { if (maxAgeMs === undefined) return entries; return entries.filter((e) => now - e.createdAt <= maxAgeMs); } function capped(entries, opts) { const max = opts?.maxEntries; if (max === undefined || entries.length <= max) return entries; const notifier = createSafeNotifier(opts?.onNotifyError !== undefined ? { onError: opts.onNotifyError } : undefined); const byAge = [...entries].sort((a, b) => a.createdAt - b.createdAt); const dropCount = Math.max(0, entries.length - Math.max(0, max)); const dropped = new Set(byAge.slice(0, dropCount)); for (const e of dropped) { notifier.notify(() => opts?.onEvicted?.(e), "rosterStore.onEvicted"); } return entries.filter((e) => !dropped.has(e)); } export class MemoryRosterStore { entries = []; maxAgeMs; gc; constructor(opts) { assertRetentionPolicy("RosterStore", opts); this.maxAgeMs = opts?.maxAgeMs; this.gc = opts; } record(entry) { const key = upsertKey(entry); this.entries = freshOnly(this.entries, this.maxAgeMs).filter((e) => upsertKey(e) !== key); this.entries.push({ ...entry }); this.entries = capped(this.entries, this.gc); } resolve(name, access) { return resolveIn(freshOnly(this.entries, this.maxAgeMs), name, access); } list() { return freshOnly(this.entries, this.maxAgeMs).map((e) => ({ ...e })); } releaseAgent(agentId) { this.entries = this.entries.filter((e) => e.agentId !== agentId); } } function upsertKey(e) { return `${normalizeAgentName(e.name)}\u0000${e.owner ?? ""}\u0000${e.scope ?? ""}`; } export class FileRosterStore { path; maxAgeMs; gc; onCorruptRead; constructor(path, opts) { assertRetentionPolicy("RosterStore", opts); this.path = path; this.maxAgeMs = opts?.maxAgeMs; this.gc = opts; this.onCorruptRead = opts?.onCorruptRead; } discloseCorrupt(reason) { try { this.onCorruptRead?.({ kind: "file", path: this.path, reason }); } catch { } } read(mode) { let text; try { text = readFileSync(this.path, "utf8"); } catch (e) { if (e.code === "ENOENT") return []; if (mode === "lenient") { this.discloseCorrupt(`read failed (non-ENOENT): ${e.message}`); return []; } throw e; } if (text.trim() === "") return []; try { const rawU = JSON.parse(text); if (typeof rawU !== "object" || rawU === null) throw new Error("roster file: not a JSON object"); const raw = rawU; if (!Array.isArray(raw.entries)) throw new Error("roster file: entries is not an array"); const kept = raw.entries.filter((e) => typeof e === "object" && e !== null && typeof e.name === "string" && typeof e.agentId === "string" && typeof e.createdAt === "number"); const dropped = raw.entries.length - kept.length; if (dropped > 0) { this.discloseCorrupt(`${dropped} schema-invalid roster rows dropped (of ${raw.entries.length}); a subsequent write persists the filtered document`); } return kept; } catch (e) { if (mode === "lenient") { this.discloseCorrupt(`corrupt roster document: ${e.message}`); return []; } throw e; } } write(entries) { mkdirSync(dirname(this.path), { recursive: true }); atomicWriteFile(dirname(this.path), this.path, JSON.stringify({ version: 1, entries }, null, 2)); } record(entry) { const key = upsertKey(entry); const entries = freshOnly(this.read("strict"), this.maxAgeMs).filter((e) => upsertKey(e) !== key); entries.push({ ...entry }); this.write(capped(entries, this.gc)); } resolve(name, access) { return resolveIn(freshOnly(this.read("lenient"), this.maxAgeMs), name, access); } list() { return freshOnly(this.read("lenient"), this.maxAgeMs); } releaseAgent(agentId) { this.write(this.read("strict").filter((e) => e.agentId !== agentId)); } } export function recordRosterSpawn(roster, entry, onError) { if (!roster) return; const disclose = (err) => { try { onError?.(err); } catch { } }; try { void Promise.resolve(roster.record(entry)).catch(disclose); } catch (err) { disclose(err); } }