UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

343 lines (342 loc) • 18.5 kB
import { Type } from "typebox"; import { errorResult } from "../tools.js"; import { CONNECTING_MESSAGE, GENERIC_FAILURE_MESSAGE, NO_STORES_MESSAGE, SHARED_STORE_FRAME, UNAVAILABLE_MESSAGE, datePrefixOf, echoUntrusted, foldReaderError, listPrefixArgument, normalizeEntries, normalizeMemoryPath, normalizeReadValue, normalizeSnapshot, pageDocuments, relativePathRefusal, sanitizeSharedContent, validateDocumentPath, validatePathPrefix, } from "./normalize.js"; import { SHARED_MEMORY_LIST_PAGE_SIZE, SHARED_MEMORY_LIST_TOOL_NAME, SHARED_MEMORY_READ_CAP_BYTES, SHARED_MEMORY_READ_TOOL_NAME, } from "./types.js"; import { toolFace } from "../tool-catalog-entries.js"; const LIST_HINT = "List shared memory stores and their documents — check here before telling the user you do not have something."; const LIST_DESCRIPTION = [ LIST_HINT, "", `List the memory documents in a memory store, sorted by path — each line gives a document's path, size, ` + `and last-updated date, but no content (use ${SHARED_MEMORY_READ_TOOL_NAME} for that). Pass store (the store's id) ` + `to choose the store, path_prefix to list one directory, and the cursor from a previous call to continue a ` + `long listing. Call with no arguments at all to list the memory stores connected to this session — their ids, ` + `a one-line description, and whether each is writable or read-only; that set can change during the session, ` + `so re-check it whenever you are unsure which store to use.`, "", "When store is omitted this call lists the stores themselves, and path_prefix and cursor are ignored.", "", `Call ${SHARED_MEMORY_LIST_TOOL_NAME} early when context about the project or the work in it would help — and ` + `always before telling the user you do not have something. If a listed document looks relevant, ` + `${SHARED_MEMORY_READ_TOOL_NAME} it.`, ].join("\n"); const READ_HINT = "Read one shared memory document by store id and path — treat it as a past snapshot, not the answer."; const READ_DESCRIPTION = [ READ_HINT, "", "Read one memory document from a memory store by its store id and path.", "", "Read your memory often so corrections stick. Treat memories as past snapshots to verify against current sources, not the definitive answer.", "", "## When to access memories", "- When memories seem relevant, or the user references prior work with them or others in their organization.", "- You MUST access memory when the user explicitly asks you to check, recall, or remember.", `- Call ${SHARED_MEMORY_LIST_TOOL_NAME} early when context about the project would help, and always before telling ` + `the user you do not have something; if a listed document looks relevant, ${SHARED_MEMORY_READ_TOOL_NAME} it.`, "- If the user says to *ignore* or *not use* memory: Do not apply remembered facts, cite, compare against, or mention memory content.", "- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.", "", "## Before recommending from memory", "", "A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:", "", "- If the memory names a file path: check the file exists.", "- If the memory names a function or flag: grep for it.", "- If the user is about to act on your recommendation (not just asking about history), verify first.", "", '"The memory says X exists" is not the same as "X exists now."', "", "A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.", ].join("\n"); function renderStoreLine(info) { return `${info.id} (${info.description}, ${info.writable ? "writable" : "read-only"})`; } function renderEntryLine(entry) { const size = entry.sizeBytes === undefined ? "" : `${entry.sizeBytes} bytes`; const date = datePrefixOf(entry.updatedAt); const updated = date === undefined ? "" : `updated ${date}`; const meta = [size, updated].filter((s) => s !== "").join(", "); const path = JSON.stringify(entry.path); return meta === "" ? path : `${path} (${meta})`; } function renderDocumentPage(page, remaining, providerHasMore = false) { if (page.length === 0) return "(empty)"; const footer = remaining > 0 ? `\n… ${remaining} more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.` : providerHasMore ? `\n… more — call again with cursor=${JSON.stringify(page.at(-1)?.path ?? "")} to continue.` : ""; return `${SHARED_STORE_FRAME}\n\n${page.map(renderEntryLine).join("\n")}${footer}`; } function renderDocument(content, updatedAt) { return `${SHARED_STORE_FRAME}\n[updated: ${datePrefixOf(updatedAt) ?? "unknown"}]\n${sanitizeSharedContent(content)}`; } function diagnosticsOf(droppedStores, droppedEntries) { return { ...(droppedStores > 0 ? { droppedStores } : {}), ...(droppedEntries !== undefined && droppedEntries > 0 ? { droppedEntries } : {}), }; } const FAILED_ERROR = { reason: "error", message: GENERIC_FAILURE_MESSAGE }; export function createSharedMemoryTools(opts) { const { provider, context } = opts; const takeSnapshot = async (signal) => { try { return normalizeSnapshot(await provider.snapshot({ ...context }, { signal })); } catch (err) { if (signal?.aborted === true) throw err; return { kind: "malformed" }; } }; const resolveStore = async (storeId, signal) => { const snapshot = await takeSnapshot(signal); if (snapshot.kind === "malformed") return { kind: "failed", refusal: FAILED_ERROR, diagnostics: {} }; if (snapshot.kind === "unavailable") { return { kind: "refused", refusal: { reason: "unavailable", message: snapshot.message ?? UNAVAILABLE_MESSAGE }, diagnostics: {}, }; } if (snapshot.kind === "connecting") { return { kind: "refused", refusal: { reason: "unbound", message: CONNECTING_MESSAGE }, diagnostics: {} }; } const diagnostics = diagnosticsOf(snapshot.droppedStores); if (snapshot.stores.length === 0) { return { kind: "refused", refusal: { reason: "unbound", message: NO_STORES_MESSAGE }, diagnostics }; } const store = snapshot.stores.find((s) => s.info.id === storeId); if (store === undefined) { const connected = snapshot.stores.map((s) => s.info.id).join(", "); return { kind: "refused", refusal: { reason: "unknown_store", message: `No memory store with id "${echoUntrusted(storeId)}" is connected to this session. ` + `Connected stores: ${connected}. Call ${SHARED_MEMORY_LIST_TOOL_NAME} with no arguments to list them.`, }, diagnostics, }; } return { kind: "resolved", store, diagnostics }; }; const listTool = { name: SHARED_MEMORY_LIST_TOOL_NAME, ...toolFace("memory-list"), description: LIST_DESCRIPTION, defer: true, parameters: Type.Object({ store: Type.Optional(Type.String({ description: "Id of the connected memory store to list. Omit to list the memory stores connected to this session (id, description, and writable or read-only).", })), path_prefix: Type.Optional(Type.String({ description: "Optional directory prefix to list only documents under it (e.g. /project/<project-id>/ lists only that project). Matching is directory-aligned (/x is the same as /x/). Omit to list the whole store.", })), cursor: Type.Optional(Type.String({ description: "Path of the last entry from a previous call. Returns entries after this path." })), }, { additionalProperties: false }), execute: async (args, ctx) => { const { store, path_prefix: pathPrefix, cursor } = args; const signal = ctx.signal; if (store === undefined) { const snapshot = await takeSnapshot(signal); if (snapshot.kind === "malformed") { return refusedList(FAILED_ERROR, "failed", {}, undefined); } if (snapshot.kind === "unavailable") { return refusedList({ reason: "unavailable", message: snapshot.message ?? UNAVAILABLE_MESSAGE }, "refused", {}, undefined); } if (snapshot.kind === "connecting") { const details = { outcome: "ok", mode: "stores", stores: [], connecting: true }; return { content: CONNECTING_MESSAGE, details }; } const diagnostics = diagnosticsOf(snapshot.droppedStores); const stores = snapshot.stores.map((s) => s.info); const details = { ...diagnostics, outcome: "ok", mode: "stores", stores }; if (stores.length === 0) return { content: NO_STORES_MESSAGE, details }; return { content: `Connected memory stores — pass an id as store to list or read one:\n${stores.map(renderStoreLine).join("\n")}`, details, }; } const resolution = await resolveStore(store, signal); if (resolution.kind !== "resolved") { return refusedList(resolution.refusal, resolution.kind, resolution.diagnostics, store); } let normalizedPrefix; if (pathPrefix !== undefined) { normalizedPrefix = normalizeMemoryPath(pathPrefix); const refusal = relativePathRefusal(pathPrefix) ?? validatePathPrefix(normalizedPrefix); if (refusal) return refusedList(refusal, "refused", resolution.diagnostics, store); } const collected = []; let droppedEntries = 0; let providerHasMore = false; let providerCursor; let windowCursor = cursor; const MAX_WINDOW_ROUNDS = 8; for (let round = 0;; round++) { let raw; try { raw = await resolution.store.list(normalizedPrefix === undefined ? undefined : listPrefixArgument(normalizedPrefix), { signal, ...(windowCursor !== undefined ? { cursor: windowCursor } : {}), limit: SHARED_MEMORY_LIST_PAGE_SIZE + 1, }); } catch (err) { if (signal?.aborted === true) throw err; return refusedList(foldReaderError(err), "failed", resolution.diagnostics, store); } let windowEntries = raw; let nextCursor; let isPaged = false; try { if (raw !== null && typeof raw === "object" && !Array.isArray(raw) && Array.isArray(raw.entries)) { isPaged = true; windowEntries = raw.entries; const nc = raw.nextCursor; nextCursor = typeof nc === "string" ? nc : undefined; } } catch (err) { if (signal?.aborted === true) throw err; return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store); } const normalized = normalizeEntries(windowEntries); if (normalized.kind === "malformed") return refusedList(FAILED_ERROR, "failed", resolution.diagnostics, store); collected.push(...normalized.entries); droppedEntries += normalized.droppedEntries; const progressed = isPaged && nextCursor !== undefined && (windowCursor === undefined || nextCursor > windowCursor); if (!progressed) { providerHasMore = false; break; } providerCursor = nextCursor; providerHasMore = true; const candidates = pageDocuments(collected, { ...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}), ...(cursor !== undefined ? { cursor } : {}), }); if (candidates.page.length >= SHARED_MEMORY_LIST_PAGE_SIZE || candidates.remaining > 0) break; if (round + 1 >= MAX_WINDOW_ROUNDS) break; windowCursor = nextCursor; } const { page, remaining } = pageDocuments(collected, { ...(normalizedPrefix !== undefined ? { normalizedPrefix } : {}), ...(cursor !== undefined ? { cursor } : {}), }); const hasMoreBeyondWindow = providerHasMore; const details = { ...diagnosticsOf(resolution.diagnostics.droppedStores ?? 0, droppedEntries), outcome: "ok", mode: "documents", store, entries: page, remaining, ...(hasMoreBeyondWindow ? { providerHasMore: true } : {}), }; if (page.length === 0 && hasMoreBeyondWindow && providerCursor !== undefined) { return { content: `${SHARED_STORE_FRAME}\n\n(no matching documents in this range)\n… more — call again with cursor=${JSON.stringify(providerCursor)} to continue.`, details, }; } return { content: renderDocumentPage(page, remaining, hasMoreBeyondWindow), details }; }, }; const readTool = { name: SHARED_MEMORY_READ_TOOL_NAME, ...toolFace("memory-read"), description: READ_DESCRIPTION, defer: true, parameters: Type.Object({ store: Type.String({ description: `Id of the connected memory store to read from (call ${SHARED_MEMORY_LIST_TOOL_NAME} with no arguments to see the connected stores).`, }), path: Type.String({ description: "Path of the memory document to read (e.g. /project/<project-id>/MEMORY.md).", }), }, { additionalProperties: false }), execute: async (args, ctx) => { const { store, path } = args; const signal = ctx.signal; const resolution = await resolveStore(store, signal); if (resolution.kind !== "resolved") { return refusedRead(resolution.refusal, resolution.kind, resolution.diagnostics, store, path); } const normalizedPath = normalizeMemoryPath(path); const refusal = relativePathRefusal(path) ?? validateDocumentPath(normalizedPath); if (refusal) return refusedRead(refusal, "refused", resolution.diagnostics, store, normalizedPath); let raw; try { raw = await resolution.store.read(normalizedPath, { signal }); } catch (err) { if (signal?.aborted === true) throw err; return refusedRead(foldReaderError(err), "failed", resolution.diagnostics, store, normalizedPath); } const normalized = normalizeReadValue(raw); if (normalized.kind === "malformed") { return refusedRead(FAILED_ERROR, "failed", resolution.diagnostics, store, normalizedPath); } if (normalized.value === null) { const details = { ...resolution.diagnostics, outcome: "not_found", store, path: normalizedPath, }; return errorResult(`${SHARED_MEMORY_READ_TOOL_NAME} failed: not found`, details); } const bytes = Buffer.byteLength(normalized.value.content, "utf8"); if (bytes > SHARED_MEMORY_READ_CAP_BYTES) { return refusedRead({ reason: "too_large", message: `"${normalizedPath}" is ${bytes} bytes, over the ${SHARED_MEMORY_READ_CAP_BYTES}-byte read cap, so its content is not returned.`, }, "refused", resolution.diagnostics, store, normalizedPath); } const details = { ...resolution.diagnostics, outcome: "ok", store, path: normalizedPath, bytes, ...(normalized.value.updatedAt !== undefined ? { updatedAt: normalized.value.updatedAt } : {}), }; return { content: renderDocument(normalized.value.content, normalized.value.updatedAt), details }; }, }; return [listTool, readTool]; } function refusedList(refusal, outcome, diagnostics, store) { const details = { ...diagnostics, outcome, reason: refusal.reason, ...(store !== undefined ? { store } : {}), }; return errorResult(refusal.message, details); } function refusedRead(refusal, outcome, diagnostics, store, path) { const details = { ...diagnostics, outcome, reason: refusal.reason, ...(store !== undefined ? { store } : {}), ...(path !== undefined ? { path } : {}), }; return errorResult(refusal.message, details); }