UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

338 lines • 18.6 kB
/** * Memory: what the assistant has concluded about a person or an organisation, carried across turns * and across threads. * * HOW A READER TELLS IT FROM RAG. Retrieval answers "what do the documents say"; memory answers * "what did I decide about you". A passage is content a person authored and can correct at its * source, and it is cited. A memory has no source to go and fix: it is the agent's own inference, * which is why every record carries an {@link MemoryOrigin} (who wrote it, in which conversation), * why the block tells the model to treat it as fallible, and why `forget` is a REQUIRED method on * the provider rather than an optional one. A wrong document is a content problem. A wrong memory is * the assistant being confidently wrong about someone with nobody aware it is there, so the whole * design is arranged around making it visible and removable. * * WHAT IT COSTS THE PROMPT. One line per memory, capped by `maxMemories`, and each line capped by * `maxFactChars` at WRITE time — so the block a turn can carry is the product of two numbers an * operator sets, rather than however much the model felt like writing down. Unlike a skill, a memory * has no body/catalog split: a fact that cannot be stated in a line is not a memory, it is a * document, and documents are retrieval's job. * * WHAT IT IS NOT: recall over a TRANSCRIPT. Searching what was said earlier is retrieval, and this * library already has `Retriever`/`Reranker` for that. * * RECALL OVER THE MEMORIES THEMSELVES IS A DIFFERENT QUESTION, and it is answered here. The prompt * budget must be bounded; the store has no reason to be. A person accumulates preferences over * months and an organisation publishes facts for everyone in it, so the applicable set outgrows * `maxMemories` quickly — and once it does, WHICH of them the block carries is a decision somebody * has to make. Making it by scope starves the widest scopes first, which is precisely backwards: the * facts that apply to the most people are the ones nobody sees. So a provider MAY supply * {@link MemoryProvider.search}, and where it does, the block is filled by relevance to the turn * (see {@link resolveMemoryDigest}) with scope still a hard filter and never a ranking signal. * `maxMemories` then means "how many matter right now" rather than "how many a person may have". */ import { actorScope, defaultScopeResolver, } from './skills.js'; /** * How many memories the block carries. The same ceiling shape as `maxSkills`, and for the same * reason: a prompt that grows with how much the agent has written down is a prompt whose cost nobody * set. A budget on the PROMPT and never on the store — with a provider that can * {@link MemoryProvider.search}, it is how many matter right now rather than how many a person may * have. */ export const DEFAULT_MAX_MEMORIES = 20; /** * How long one memory may be, enforced when it is WRITTEN rather than when it is rendered. Enforcing * it at render time would make the prompt disagree with the store; enforcing it at write time makes * the block's whole ceiling a multiplication an operator can do — `maxMemories × maxFactChars` — and * pushes back on the model at the moment it is writing an essay instead of a fact. */ export const DEFAULT_MAX_FACT_CHARS = 240; export function resolveMemoryDigest({ records, scopes, maxMemories = DEFAULT_MAX_MEMORIES, ranked = false, }) { const rank = new Map(scopes.map((scope, index) => [scope, index])); const byKey = new Map(); for (const entry of records) { // A scope the resolver did not return is one this actor has no claim on, whatever the provider // thinks. Dropping it here is what makes over-returning a performance mistake, not a leak. if (!rank.has(entry.scope)) { continue; } const existing = byKey.get(entry.key); if (existing === undefined) { byKey.set(entry.key, [entry]); } else { existing.push(entry); } } const resolved = []; for (const candidates of byKey.values()) { const ordered = [...candidates].sort((a, b) => (rank.get(a.scope) ?? 0) - (rank.get(b.scope) ?? 0)); const winner = ordered[0]; if (winner === undefined) { continue; } const overrides = ordered.slice(1).map((beaten) => ({ scope: beaten.scope, text: beaten.text, author: beaten.origin.author, })); // A pin belongs to the QUESTION, not to the record that currently answers it: pinning "which // fiscal year" and then letting a personal override of it fall out of the block would make // always-on mean "always-on until someone disagrees with it". const pinned = ordered.some((candidate) => candidate.pinned === true); resolved.push({ ...winner, ...(pinned ? { pinned: true } : {}), ...(overrides.length > 0 ? { overrides } : {}), }); } // Three orders, answering three different questions. // // ALWAYS-ON FIRST, whatever else is true: a pinned fact is in the block whether or not the turn is // about it. It spends the same budget as everything else, so the ceiling stays a product of two // numbers an operator set rather than growing with how much got pinned. // // Then SELECTION among the rest — by relevance where the candidates were ranked, else by narrowest // scope and newest within it. That second rule is right only while nothing is being starved, which // is the case a host running no index is in: `list` returns the whole applicable set, so a ceiling // that bites is dropping what the deployment can best afford to lose. Once the set outgrows the // block it is exactly wrong, because the scopes it drops first are the ones shared by the most // people — one person's twentieth note would end every chance their organisation's facts had. // // RENDERING is by scope then key, stable between turns: ordering the block by recency or by score // would reshuffle it on every write and throw away the provider's prompt cache. Pinned entries // lead, because they are the part of the block that does not move from turn to turn. const byPrecedence = (a, b) => (rank.get(a.scope) ?? 0) - (rank.get(b.scope) ?? 0) || compare(b.updatedAt, a.updatedAt) || compare(a.key, b.key); const selected = resolved .map((entry, index) => ({ entry, index })) .sort((a, b) => pinRank(a.entry) - pinRank(b.entry) || (ranked && a.entry.pinned !== true ? a.index - b.index : byPrecedence(a.entry, b.entry))) .slice(0, maxMemories) .map((candidate) => candidate.entry); const pinnedOmitted = resolved.filter((entry) => entry.pinned === true).length - selected.filter((entry) => entry.pinned === true).length; selected.sort((a, b) => pinRank(a) - pinRank(b) || (rank.get(a.scope) ?? 0) - (rank.get(b.scope) ?? 0) || compare(a.key, b.key)); return { entries: selected, omitted: Math.max(0, resolved.length - maxMemories), pinnedOmitted, }; } function pinRank(entry) { return entry.pinned === true ? 0 : 1; } function compare(a, b) { return a < b ? -1 : a > b ? 1 : 0; } /** * Resolve the scopes and the digest for one turn. Called INSIDE the loop's `memory:digest` step, and * that placement is the whole of its determinism: a search is the most re-derivable thing in this * library — the index moves, a neighbour is written, embeddings are recomputed — so its result has * to be part of the checkpoint every replay reads back, never something a replaying process asks * again. */ export async function offerMemories({ config, ctx, query, }) { const scopes = await (config.scopes ?? defaultScopeResolver).resolve(ctx); const maxMemories = config.maxMemories ?? DEFAULT_MAX_MEMORIES; // A search keyed on nothing ranks on noise, so a turn with no text to search with reads the scope // plainly rather than making a worse selection out of it. What carries a fact through a turn like // that is `pinned`, not a cleverer query. const trimmed = query?.trim() ?? ''; // Bound for the same reason `writeMemory` binds its own: a provider is normally a class, and a // method read off it has lost the receiver its body needs. This one runs on EVERY turn, so it // breaks a host before it ever reaches a write. const search = trimmed.length > 0 ? config.provider.search?.bind(config.provider) : undefined; const records = search !== undefined ? await search({ scopes, query: trimmed, limit: maxMemories, ctx }) : await config.provider.list({ scopes, ctx }); return { scopes, recalled: search !== undefined, ...resolveMemoryDigest({ records, scopes, maxMemories, ranked: search !== undefined }), }; } const ASSERTED_FRAMING = 'Stated by people. A person wrote each of these deliberately — the user about themselves, or someone administering their organisation. Treat them as you would any other instruction you were given. If the user says something that contradicts one, say which note it conflicts with and at what scope it is held, rather than quietly setting it aside.'; const CONCLUDED_FRAMING = 'Concluded by you. These are your own notes from earlier turns, not documents anyone wrote: they may be wrong or out of date, so prefer what the user says now, and say where a note came from if you act on it.'; const PARTIAL_FRAMING = 'You are being shown a selection, not everything on file. Other notes exist that are not in this list, so never read something’s absence from it as evidence that it was never recorded.'; function memorySection(framing, entries) { if (entries.length === 0) { return []; } return [ framing, ...entries.flatMap((entry) => [ `- [${entry.scope}] ${entry.key}: ${entry.text}`, ...(entry.overrides ?? []).map((beaten) => ` ↳ [${beaten.scope}] ${beaten.author === 'human' ? 'a person stated' : 'instead has'}: ${beaten.text}`), ]), ]; } export function buildMemoryBlock({ entries, writable, partial }) { const writeLine = writable ? [ `When you learn something durable about this user, record it with the \`${REMEMBER_TOOL_NAME}\` tool; when one of these turns out to be wrong, record the corrected fact under the same key. What you record that way is one of your own notes, not something stated by a person.`, ] : []; const body = [ 'What is on file about this user and their organisation, carried over from earlier conversations. Each line is a scope, a key and the fact. A narrower scope overrides a wider one; where the wider value is shown underneath, tell the user their setting differs from it rather than silently choosing one.', ...(partial ? [PARTIAL_FRAMING] : []), ...writeLine, // People before inferences: the model meets what it was told before what it worked out. ...memorySection(ASSERTED_FRAMING, entries.filter((entry) => entry.origin.author === 'human')), ...memorySection(CONCLUDED_FRAMING, entries.filter((entry) => entry.origin.author !== 'human')), ]; return `<memory>\n${body.join('\n')}\n</memory>`; } /** The reserved name of the built-in memory-writing tool. */ export const REMEMBER_TOOL_NAME = 'remember'; const REMEMBER_JSON_SCHEMA = { type: 'object', additionalProperties: false, required: ['key', 'fact'], properties: { key: { type: 'string', description: 'A short, stable handle for what this fact is ABOUT (e.g. "fiscal-year", "preferred-units"). Reusing an existing key replaces that fact rather than adding a second one.', }, fact: { type: 'string', description: 'The fact itself, in one sentence, stated so it still makes sense in a conversation months from now.', }, }, }; function issue(path, message) { return { message, path }; } /** * The `remember` tool's input schema. Hand-written for the same reason `askInputSchema` and * `skillInputSchema` are: core depends on no validator and has to publish a JSON Schema a provider * can constrain generation against. * * THERE IS NO SCOPE PARAMETER, and that is the enforcement rather than a simplification. An agent * may only ever write the actor it is running for (see {@link memoryWriteVerdict}), so a scope * argument could only ever be a request that gets refused — and a refusable request is one a model * will keep making, and one a future reader will be tempted to make grantable. */ export const rememberInputSchema = { '~standard': { version: 1, vendor: 'adonis-agora-agent', validate: (value) => { if (typeof value !== 'object' || value === null) { return { issues: [issue([], 'must be an object')] }; } const candidate = value; if (typeof candidate.key !== 'string' || candidate.key.length === 0) { return { issues: [issue(['key'], 'must be a non-empty string')] }; } if (typeof candidate.fact !== 'string' || candidate.fact.length === 0) { return { issues: [issue(['fact'], 'must be a non-empty string')] }; } return { value: { key: candidate.key, fact: candidate.fact } }; }, jsonSchema: { input: () => REMEMBER_JSON_SCHEMA }, }, }; export const REMEMBER_TOOL_DESCRIPTION = 'Record one durable fact about this user under a key, so later conversations start knowing it. For things that will still be true another day — how they work, what their organisation requires, a correction they made. Not for what this conversation is about, and not for anything you were not told or could not reasonably infer. The user can read and delete everything you record here.'; /** * The `remember` tool as the model sees it. NOT a `ToolSpec` and never registered, exactly like * `ask` and `skill`: it has no handler, because the loop serves it against the digest the journal * holds. Keeping it out of the `ToolRegistry` is also what keeps its kind off a process-local * lookup — see `claimToolCall`. */ export function rememberToolDefinition() { return { name: REMEMBER_TOOL_NAME, kind: 'memory', description: REMEMBER_TOOL_DESCRIPTION, inputSchema: rememberInputSchema, }; } /** * May this author write a memory at this scope? * * The same four rules as `skillWriteVerdict`, deliberately duplicated rather than shared: they are * the same rules about two different things, and folding them into one function would mean a future * change to how skills are authored silently changing who may edit what the assistant believes about * a person. Rule three is the one that matters, and it bites harder here than it does for skills: * * 1. You may only write into a scope you are yourself in. * 2. Your OWN scope is yours. A memory at `actor:<you>` affects exactly one prompt — your own. * 3. NOTHING BUT A HUMAN MAY WRITE ABOVE ITS OWN SCOPE, whatever `elevated` says. A skill an agent * could publish at `tenant:` is a procedure anyone in the tenant can edit by talking to the * assistant; a MEMORY it could publish there is a fact everyone in the tenant is then answered * from, with no document to inspect and nobody aware it was written. An agent that has genuinely * learned something the organisation should know proposes it; a person publishes it. * 4. A human writing above their own scope needs the host to say so (`elevated`). */ export function memoryWriteVerdict(request) { const { scope, scopes, author, actor } = request; if (!scopes.includes(scope)) { return { allowed: false, reason: `"${scope}" is not a scope this actor belongs to` }; } const own = actorScope(actor); if (scope === own) { return { allowed: true }; } if (author.kind !== 'human') { return { allowed: false, reason: `only a human may write a memory at "${scope}"; an agent may write only at "${own}"`, }; } return request.elevated === true ? { allowed: true } : { allowed: false, reason: `writing at "${scope}" requires an elevated human author` }; } export function memoryForgetVerdict({ record, actor }) { return record.scope === actorScope(actor) ? { allowed: true } : { allowed: false, reason: `"${record.scope}" is not this actor's own scope; deleting it is an administrative action`, }; } export async function writeMemory({ config, digest, call, ctx, runId, }) { // Bound, not detached. A provider is normally a CLASS — an Adonis service holding a model or a // repository on `this` — and `const write = provider.write` hands back a function that has lost // its receiver, so the first `this.` inside it throws a TypeError the tool reports as a failed // call. Every provider in this repo's own tests is an object literal of arrow functions, which is // why that goes unseen: they never need a receiver. const write = config.provider.write?.bind(config.provider); if (write === undefined) { return { ok: false, error: 'Memory is read-only in this deployment.' }; } const key = call.key.trim(); const text = call.fact.trim(); if (key.length === 0 || text.length === 0) { return { ok: false, error: 'A memory needs both a key and a fact.' }; } const maxFactChars = config.maxFactChars ?? DEFAULT_MAX_FACT_CHARS; if (text.length > maxFactChars) { return { ok: false, error: `A memory must be at most ${maxFactChars} characters; that was ${text.length}. State the fact more briefly.`, }; } const scope = actorScope(ctx.actor); const verdict = memoryWriteVerdict({ scope, actor: ctx.actor, scopes: digest.scopes, author: { kind: 'agent', actorRef: ctx.actor.id }, }); if (!verdict.allowed) { return { ok: false, error: verdict.reason }; } const record = await write({ key, text, scope, origin: { author: 'agent', threadId: ctx.threadId, runId, actorRef: ctx.actor.id }, ctx, }); return { ok: true, record }; } //# sourceMappingURL=memory.js.map