跳转到内容

Memory API

每个 Agent 有一份自己的记忆。Agent 里的 Gene(基因)用它保存每次运行之间学到的东西:用户的偏好、反复出现的模式、关于某项任务的知识,以及值得缓存的计算结果。

记忆属于 Agent,不属于任何一个 Gene。替换或更新 Agent 基因组里的 Gene,记忆原样保留。

每条记忆属于四类之一。类别决定它大概能留多久,以及 Agent 迁移时它会怎样。

类别存什么记忆满了会被自动清理吗Agent 迁到别的 binding 时会带走吗
PREFERENCEAgent 学到的用户习惯和偏好不会,从不会
PATTERNAgent 在多次运行中发现的模式会,在 CACHE 之后,从最旧的开始会
CONTEXT针对某项任务或某个领域积累的知识会,在 PATTERN 之后,从最旧的开始会
CACHE可以重新算出来的中间结果会,最先清,从最旧的开始可能不带

类别名在任何地方都用大写:TypeScript、JSON、WASM 宿主调用都一样。

每条记忆包含:

字段含义
entryId条目的固定标识:Agent 标识与键名的 SHA-256。更新条目时标识不变。
categoryPREFERENCE、PATTERN、CONTEXT 或 CACHE
keyGene 读写这条记忆时用的名字
value存的字节。在 JSON 里用 base64 编码。
createdBy创建这条记忆的 Gene 的 v2 标识(v2: 加 64 位十六进制;rotifer compile 和 rotifer wrap 会把它打印为 Gene ID (v2))
lastAccessedBy最近一次读或写它的 Gene 的 v2 标识。查询不会更新它。新建的条目里它和 createdBy 相同。
createdAt、updatedAt创建时间与最近修改时间(RFC 3339,UTC)
accessPolicy谁可以读、写、分享它(见下文)
retentionPolicy保留多久:必须正好是 { maxRetentionDays, autoDeleteOnTermination, anonymizationSupported } 三个字段,或者 null(跟随 Agent 的数据策略)

每条记忆都有一份访问策略,分三部分:谁可以读(readScope)、谁可以改或删(writeScope)、能不能离开这个 Agent(sharePolicy)。

readScope 和 writeScope 各取以下之一:

范围谁可以访问
CREATOR_GENE_ONLY只有创建这条记忆的 Gene
SAME_DOMAIN与创建者处于同一功能域的 Gene
ALL_GENESAgent 里的所有 Gene
DEPLOYER_CONFIGURED按 Agent 的数据策略决定。数据策略里目前还没有这一项设置,所以这一版一律拒绝;写入时设成它也会被拒绝

sharePolicy 取以下之一:

分享策略含义
PRIVATE只留在这个 Agent 里
ANONYMIZED_SHARE去掉所有能追溯到用户的信息后可以分享
KNOWLEDGE_GENE可以编码成知识基因,以基因的形式分享

写入时不给访问策略,就按缺省值:readScope: SAME_DOMAIN、writeScope: SAME_DOMAIN、sharePolicy: PRIVATE。

写入还必须符合 Agent 的数据策略。不做配置时 Agent 拿到的缺省策略不支持匿名化,所以 ANONYMIZED_SHARE 和 KNOWLEDGE_GENE 的写入会被拒绝,直到部署者配置了支持匿名化的策略。retentionPolicy 要求保留的时间超过 Agent 策略允许的上限,也会被拒绝。

每次读、写、删之前都会先检查权限。没有权限的 Gene 会收到 MEMORY_ACCESS_DENIED,这次尝试也会被记录下来。

每个 Agent 有一份记忆配额,按所有键和值的 UTF-8 字节数合计。缺省是 10 MB;创建 Agent 时可以另设:

终端窗口
rotifer agent create my-agent --genes my-gene --memory-quota=50MB

--memory-quota 填一个带 KB、MB 或 GB 的数,单位大小写都行(1 KB = 1024 字节),例如 512KB、1.5GB。不带单位、0、负数或者数值过大,都会被拒绝,Agent 不会被创建。

一次写入如果会超出配额,Agent 按下面的顺序腾地方,每一类都从最久没有更新的条目开始清(最近改写过的条目算新的):

  1. CACHE
  2. PATTERN
  3. CONTEXT

PREFERENCE 不会被这样清掉。清完仍然放不下,这次写入失败,返回 MEMORY_QUOTA_EXCEEDED,什么都不写。

  • 单条 value 最大 1 MiB(1,048,576 字节)。
  • 一次查询最多返回 limit 条(最多 1000 条),总大小最多 4 MiB。符合条件的条目超过 4 MiB 时,查询失败,返回 MEMORY_INVALID_INPUT:请调小 limit 或收窄过滤条件。不会悄悄只返回一部分。

条目的 maxRetentionDays 会真的执行:到 updatedAt 加上这么多天的那一刻(精确到秒),条目就过期,所以重新写入一条记忆会重新计时。条目自己的策略里 maxRetentionDays 为 null 时,这一条永不过期。retentionPolicy 整个为 null 的条目则跟随 Agent 的数据策略;缺省的数据策略不设上限,所以默认情况下这样的条目也永不过期。

过期的条目就等于没了:读返回 null,查询不会列出它,它不再占配额,再写同一个键会新建一条。过期条目会在下一次读、查、写、删这个 Agent 的记忆,或用 rotifer agent memory list 查看时被清除,每清除一条都会在审计日志里记一行。

autoDeleteOnTermination 会被保存和校验,但这一版不会触发:目前还没有终止或删除 Agent 的命令。

  • 以 Node.js 方式运行的 TypeScript Gene(Wrapped、Hybrid,或者没编译过的):通过 express() 的第二个参数拿到 ctx.memory,用法见下面的示例。各种组合方式里都是这样。Par、Cond、Try 组合里只要有一个 Gene 没编译,rotifer agent run 就会按顺序把组合里的每个 Gene 都跑一遍,所以它们都能读写记忆。
  • 自带 WASM 的 Gene(Rust、AssemblyScript、手写 WAT):调用在 WASM 模块里调用里的宿主函数。不管是单独运行、按顺序组合,还是在全部 Gene 都编译过的 Par、Cond、Try 组合里,rotifer agent run 都会把它们接到所在 Agent 的记忆上。每个 Gene 以自己的 v2 标识读写;没有 v2 标识的 Gene,每次调用都返回 -2。
  • 编译成 WASM 的 TypeScript Gene 用不了记忆。 编译后的 Gene 调用 express() 时只传入输入,没有 ctx.memory。需要记忆的 TypeScript Gene,请以 Node.js 方式运行。

Gene 要有 v2 标识才能用记忆;没有 v2 标识的 Gene,每次调用都会返回 MEMORY_ACCESS_DENIED。

只有作为 Agent 的一部分运行时,记忆调用才有效。用 rotifer run 脱离 Agent 单独运行一个 Gene,所有记忆调用都会返回 MEMORY_ACCESS_DENIED(WASM 侧是 -2)。

想试一试用到记忆的 Gene,就跑 rotifer test。它会在测试期间给这个 Gene 一份空的、专用的 Agent 记忆,测完就删掉,不会动到你的 Agent 的记忆。Gene 带测试套件时,套件里的用例共用这一份记忆,后面的用例能读到前面用例写进去的内容。

readMemory(key: string, category?: MemoryCategory): MemoryEntry | null
writeMemory(
key: string,
value: Uint8Array,
category: MemoryCategory,
accessPolicy?: MemoryAccessPolicy,
retentionPolicy?: RetentionPolicy | null,
): MemoryEntry
queryMemory(filter?: MemoryFilter): MemoryEntry[]
deleteMemory(key: string): void
interface MemoryFilter {
category?: MemoryCategory;
keyPrefix?: string; // 纯前缀,不是匹配模式
since?: string; // RFC 3339,含,比较 updatedAt
until?: string; // RFC 3339,不含,比较 updatedAt
limit?: number; // 缺省 100,最多 1000
}
interface RetentionPolicy {
maxRetentionDays: number | null; // 整数天数,1 到 2147483647;null 表示不限
autoDeleteOnTermination: boolean;
anonymizationSupported: boolean;
}

这四个调用都是同步的,直接返回结果,所以 express() 不需要写成 async。

  • readMemory 返回 key 对应的条目,没有就返回 null。传了 category,就只匹配该类别的条目。条目存在但这个 Gene 无权读取时,调用失败,返回 MEMORY_ACCESS_DENIED,不会假装条目不存在。
  • writeMemory 新建条目;键已存在时替换它的值,返回存好的条目。不传 accessPolicy 就用上面的缺省值,不传 retentionPolicy 就跟随 Agent 的数据策略。替换已有条目要有这条记忆的写权限,替换后原创建者和原访问策略保持不变;只有创建这条记忆的 Gene 才能修改它的访问策略或保留策略。其他 Gene 如果带了和原来不同的策略,会收到 MEMORY_ACCESS_DENIED,什么都不写;不带策略,或者带的和原来完全一样,就照常写入。传 retentionPolicy: null 表示把条目改回跟随 Agent 的数据策略,这同样只有创建者能做。
  • queryMemory 返回这个 Gene 有权读取、且满足所给全部过滤条件的条目,最近更新的排在前面,最多 limit 条。不传过滤条件就返回这个 Gene 能读的全部条目,同样最多 limit 条。没权限读的条目直接略过,不会报告。过滤条件里出现未定义的字段,返回 MEMORY_INVALID_INPUT。
  • deleteMemory 删除条目。只有有权写这条记忆的 Gene 才能删。
错误码什么时候出现
MEMORY_NOT_FOUND条目不存在(比如删除一个从没写过的键)
MEMORY_ACCESS_DENIED条目的访问策略不允许这个 Gene 读、写或删;非创建者想修改条目的策略;或者这次写入不符合 Agent 的数据策略
MEMORY_QUOTA_EXCEEDED清理之后仍然放不下;什么都没写
MEMORY_INVALID_INPUT键名不合法(为空、含 NUL 字符,或超过 1024 字节)、类别或范围的值不认识、值不是字节或超过 1 MiB、保留策略不是正好那三个字段、或字段类型不对(null 除外)、或 maxRetentionDays 不是 1 到 2147483647 之间的整数、过滤条件里有未定义的字段,或者查询结果超过 4 MiB
MEMORY_STORAGE_ERROR记忆存储出了故障,或者 CLI 的原生绑定不支持记忆(“native binding unavailable”)。记忆不会退回到别的存储。

在 TypeScript 里,调用失败会抛出 MemoryError,它的 code 就是上表中的一个。在 WASM 沙箱里,这些错误和其他宿主调用走同一条错误通道返回。

自带 WASM 的 Gene 从 rotifer 模块导入 readMemory、writeMemory、queryMemory、deleteMemory。它们和其他 rotifer 宿主函数是同一种形状:

(req_ptr: i32, req_len: i32, out_ptr: i32, out_cap: i32) -> i32

请求和响应都是 JSON,字段名和大写取值与本页一致;value 用 base64。返回值大于等于 0 时,是写进 out 的响应字节数;返回负数表示出错:

返回值含义
-1out 放不下响应
-2MEMORY_ACCESS_DENIED
-8MEMORY_QUOTA_EXCEEDED
-10MEMORY_INVALID_INPUT
-12MEMORY_NOT_FOUND
-13MEMORY_STORAGE_ERROR
-4这次运行的截止时间或这次调用的预算已用完

除 -4 以外,out 有空间时还会带上 {"__rotifer_error":{"code":"MEMORY_…","message":"…"}}。Gene 把这个对象原样作为自己的输出返回,就等于报告输入被拒绝。对不存在的键调用 readMemory 返回 JSON null,不算错误。

这个格式化 Gene 会记住用户上次要求的缩进宽度;下次请求没有指定时,就用记住的那个。

import type { GeneMemoryAccess } from "@rotifer/playground/memory";
// reindent() 是这个 Gene 自己的格式化逻辑,这里省略。
const KEY = "formatter/indent";
export function express(
input: { code: string; indent?: number },
ctx: { memory: GeneMemoryAccess },
): { formatted: string; indent: number } {
let indent = input.indent;
if (indent === undefined) {
const saved = ctx.memory.readMemory(KEY, "PREFERENCE");
indent = saved ? Number(new TextDecoder().decode(saved.value)) : 2;
} else {
ctx.memory.writeMemory(
KEY,
new TextEncoder().encode(String(indent)),
"PREFERENCE",
);
}
return { formatted: reindent(input.code, indent), indent };
}

这条记忆按缺省访问策略写入,所以同一功能域里的其他格式化 Gene 也能读到这个偏好。把这个 Gene 换成同一功能域里更好的格式化 Gene,新 Gene 会在原处找到之前存下的缩进设置。

记忆的每一次变动都会在 Agent 的审计日志 .rotifer/audit.jsonl 里追加一行:写入、更新、删除、为腾地方被清掉的条目、过期被清除的条目,以及因空间不足被拒绝的写入。这一行在变动保存之前写;写不进去,这次变动就不做。普通的读取不记。被拒绝的访问也会在那里记一行,并在 .rotifer/telemetry.jsonl 里记一条事件;设了 ROTIFER_TELEMETRY=0 或 DO_NOT_TRACK 时不写这条事件,审计那一行照写。两个文件都只留在你的机器上,不会通过网络发送。

如果 Gene 把来自 Agent 外部的内容(消息、issue、网页、文档)写进记忆,写那段内容的人就能影响记忆里存什么。有大模型参与时,提示注入可以让 Gene 存下攻击者想要的条目,之后的运行会像读 Agent 自己学到的东西一样读到它们。

PREFERENCE 条目永远不会为腾地方被清掉。被污染的偏好会一直留着,直到被删除(部署者用 rotifer agent memory delete,或由有权修改它的 Gene 删除),或者它的 maxRetentionDays 到期。

  • 只用可信来源的内容写 PREFERENCE 条目。
  • 不可信的输入放进 CACHE,或者给它设 maxRetentionDays,让它到期自动清除。
  • Gene 升级后标识会变。 旧版本用 CREATOR_GENE_ONLY 写下的条目,新版本读不到、改不了、也删不掉。希望跨版本保留的数据,请用缺省的 SAME_DOMAIN;留下来的旧条目,用 rotifer agent memory delete 清理(见下文)。
  • PREFERENCE 条目不会因为空间不够被清掉。 一个 Gene 不停地写偏好,可能把配额写满,之后的写入都会返回 MEMORY_QUOTA_EXCEEDED。请给 Agent 设合适的配额,并用 rotifer agent memory delete 删掉不再需要的偏好。
  • 清理不看条目是谁写的。 某个 Gene 写入需要空间时,最旧的 CACHE、PATTERN、CONTEXT 条目先被清,不管它们是哪个 Gene 创建的。
终端窗口
rotifer agent memory list <agent> [--category <类别>]
rotifer agent memory delete <agent> (--key <键名> | --category <类别>)
  • list 列出每条记忆的键名、类别、大小、创建者和最近更新时间,不显示值。
  • delete 按键名删除一条,或者删除某个类别的全部条目。--key 和 --category 必须给且只给一个;删完会显示删了几条。删除不存在的键会返回 MEMORY_NOT_FOUND。Agent 归你所有,所以不受条目访问策略的限制;升级后 Gene 再也够不着的条目,也用它清理。每次删除都会以部署者的身份记进审计日志。
  • 没有修改条目的命令,云端这一版也没有对应的功能。

Agent 迁到别的 binding 时,PREFERENCE、PATTERN、CONTEXT 条目会一起带走,访问策略、保留策略和 createdBy 都不变。CACHE 条目可以留下不带,到新环境再重新算。

这是协议规定的迁移方式。这个版本还没有把 Agent 或它的记忆迁到别的 binding 的命令,记忆也不能上传到 Rotifer Cloud。