一个agent可以被好几个人共用,共用同一份记忆工作区。当前渠道入口同时保留正文前缀,
并把可信的speaker_id/speaker_display作为独立字段写入消息节点。这页分三层:改之前是什么样、
references里八个框架各自怎么做、我们怎么做的。
第06到08节保留安全审查前的正文-only中间方案及其取舍记录;第09到12节说明替代它的双表示、
source framing和按发言人过滤实现。
正文在docs/reference/design/memory/overview.md,整体机制在memory-architecture.html。
这一层记的是改动落地之前的样子。发信人的显示名逐条消息存到了消息节点上,稳定id止步于门禁, 记忆写入那一步两个都没读。下面两节说清楚这为什么是真问题,以及断在哪一行。 第01节的会话形状今天照旧成立,那是这个改动存在的理由。
发信人的显示名逐条消息带到了消息节点上:user_display从渠道适配器一路存进
session store,get_branch读回来还在。
发信人的稳定id在base.py:176门禁那一步用完就丢,没进dispatch_inbound。
TurnRequest.peer_id装的是路由目标,群聊里是群id。
_records()只读role,已经送到眼前的peer_display被丢掉,
三个人的话进SourceRecord之后全是user。
身份要不要逐条消息带,取决于一个session里会不会出现两个发言人。会。路由键
(openprogram/channels/_session_routing.py:33-52 session_key_for_agent)
有两种配置把多个人放进同一条会话,其中一种还是Telegram群聊的默认值。
_session_routing.py:33-52的分支(group / channel永远按peer_id隔离,
scope == "main"把所有私聊折成一条);Telegram的peer_id_for在
implementations/telegram.py:55-60,group_sessions默认shared(telegram.py:48)。
一条群消息进来,经过八站到达主题文件。改之前:显示名到达第七站,稳定id在第二站后不再传递,
两个都没有进入第七站的SourceRecord。下面的图只记录改造前基线;当前实现把两个可信字段
传到第七站,同时保留正文前缀,最终链路见第11节。
八个都看了实现,不看README。八个里只有三个接了聊天渠道,三个里只有两个碰到过多人同会话, 两个的做法几乎一样:把发言人名字拼进消息正文,别的什么都不加。 剩下五个是单人命令行,"多人同会话"这个概念在它们那里不存在,这本身也是一种设计选择。
先分清"没做"和"不需要做"。有没有多人同会话,取决于这个框架接不接聊天渠道。
senderName
(src/tools/SendMessageTool/SendMessageTool.ts:93,157、
src/tools/TaskUpdateTool/TaskUpdateTool.ts:278-294),
但那是团队模式下agent之间互发消息的署名,不是人类发言人。codex-cli里的participants
命中全在codex-rs/rmcp-client/src/bin/test_stdio_server.rs的测试用barrier服务器,跟身份无关。
opencode、pi-ai、pi-mono三个仓库对发言人相关词的搜索零命中。
| 框架 | 多人同会话 | 身份存在哪一层 | 模型怎么知道谁说的 | 长期记忆里 | 发言人这块的实现体量 |
|---|---|---|---|---|---|
| openclaw | 有,群/频道 | 逐条消息五个字段SenderId/Name/Username/Tag/E164 |
群聊正文前缀名字 (id): ;私聊放信封头;另加一个只在当轮出现的user角色JSON块 |
记忆里没有发言人字段。人物是模型自己写的wiki页,canonicalId+aliases+handles |
标签10行、正文前缀6行、JSON块106行、把JSON块从历史里剥掉317行 |
| hermes-agent | 有,群/线程 | 会话origin对象SessionSource,逐条消息进来时取 |
正文前缀[名字] ,只在共享会话时加;系统提示加一句"这是多人会话" |
记忆里没有发言人字段。MEMORY.md/USER.md是自由文本 |
前缀2行、共享判定6行、系统提示1句 |
| weclaw | 没有,iLink一对一 | 没有这个功能 | 没有 | 没有 | FromUserID直接当会话键,全仓没有发言人概念 |
| claude-code-leaked | 没有 | 没有 | 没有 | 没有 | senderName只用在agent之间的消息署名 |
| codex-cli | 没有 | 没有 | 没有 | 没有 | 没有 |
| opencode | 没有 | 没有 | 没有 | 没有 | 没有 |
| pi-ai | 没有 | 没有 | 没有 | 没有 | 没有 |
| pi-mono | 没有 | 没有 | 没有 | 没有 | 没有 |
memory-core、memory-lancedb、
active-memory三个插件对sender/speaker/participant的搜索全部零命中;
hermes的tools/memory_tool.py同样零命中。
身份之所以能进记忆,只因为它已经在消息正文里,记忆层原样收下。
同一件事,两份实现,画在一起看。左右两边的关键行都逐字核对过。
共识只有一条(名字进正文),剩下四处两边选得不一样。这四处正好是我们自己拿了主意的地方,第08节一条条给出选法。
| 分歧点 | openclaw | hermes-agent | 代价差在哪 |
|---|---|---|---|
| 标签带不带稳定id | 带,名字 (id);name→username→tag依次降级,id用e164→id |
不带,只有user_name |
openclaw多10行,换来改名和重名两种情况下人还能对上 |
| 私聊加不加前缀 | 不加,名字改放信封头[Discord 乙 id:702 12:04] |
不加,判定第一条就是dm → False |
两边一致:一对一时会话本身已经指名道姓 |
| 前缀要不要从历史里剥掉 | 额外那块JSON要剥,strip-inbound-meta.ts整整317行;正文前缀不剥 |
不剥,一行都没写 | openclaw那317行是为JSON块付的,不是为前缀付的 |
| 系统提示要不要说明 | 说明放在user角色的"不可信元数据"块里,并写明理由:发言人名字是外部输入,不能进被当作权威的系统提示;会变的id进系统提示还会打掉前缀缓存 | 多人会话时系统提示加一句,且刻意不写死人名,同样为了前缀缓存 | 两边理由相同,落点不同 |
| 群聊会话怎么划 | 一个群一条会话agent:x:telegram:group:id;私聊默认全汇进main;session.identityLinks把同一个人的多渠道账号并成一组 |
群默认按人隔离(group_sessions_per_user=True),线程默认共享 |
hermes把多人场景做成需要主动打开的,openclaw把它当默认 |
_session_routing.py的docstring写明
session_scope枚举继承自openclaw的dmScope,group/channel永远按peer_id共享。
所以"群聊里一定会出现多个发言人"对我们是默认状态,不是可选项,这一条不用再选。
第06到08节记录安全审查前的正文-only中间方案:它采用hermes的正文形式和
openclaw的名字 (id)标签,一度计划只修改两个文件。该方案不能提供可信过滤键,
也不能阻止正文标签建立错误身份,现已被第09到12节的双表示方案替代。最终实现保留正文前缀,
同时传递结构化speaker字段,并在source archive使用可信frame。
本节描述已被替代的中间方案。它只在base.py:_dispatch_and_reply增加正文前缀;该位置同时持有
ch_msg.user_id和ch_msg.user_display,而且是dispatch_inbound
的唯一调用点。最终实现仍在这里生成兼容前缀,但还会把两个值独立传给dispatcher。
_records()若只读
role就没有可信检索键;若从content反解析身份,发信人又能伪造该键。
最终实现保留前缀,同时让_records()只读取持久化的结构化speaker字段。
本节保留当时对“结构化字段”与“正文-only”两种方案的改动面比较。右侧的两文件方案 后来因信任边界不足被替代;当前实现采用左侧结构化传输,并增加source framing和检索接口。
channels/base.py,因此没有渠道前缀,
也不会合成speaker身份。渠道消息同时携带正文前缀和结构化字段;dispatcher、
SourceRecord、writer/archive、BM25/inspect与memory_search都按第11节的
最终契约处理。正文-only的两文件估算只作为历史设计记录保留。
本节保留参考实现审计得到的六项取舍。A项在后续安全审查中改为双表示;其余取舍继续适用。
A.身份放正文,还是放结构化字段
最终选择双表示,分别满足当前轮次可见性和记忆信任边界。
f"[{user_name}] {text}"(run.py:7759-7765),openclaw是`${sender}: ${body}`(envelope.ts:214-219)。当前轮次的agent只读content,因此兼容前缀继续存在。speaker_id/speaker_display只来自渠道消息,writer和检索不从可伪造正文反解析新身份。B.标签里带不带稳定id
显示名会改也会重名,id不变但读不出是谁。这一点两个参考实现选得不一样。
显示名 (id)(sender-label.ts:21-30,10行)。base.py那里两个字段本来就都在手上,多拼一个括号不要钱。C.私聊要不要也加前缀
两个参考实现在这一点上一致:私聊不加,理由是一对一时会话本身已经指名道姓。
session_scope: main时,所有私聊peer汇进同一条会话(_session_routing.py),那条会话里说话的是好几个人,而chat_type全是direct。scope是_conversation.py拿到agent之后才算出来的,base.py这一站根本不知道,所以"按私聊跳过"在这里既不安全也判断不了。user_id和user_display都空)时不加,前缀退回空串。D.显示名是外部输入,要不要处理
显示名由发信人在平台上自己设,会被直接拼进写入agent的prompt。
render_conversation和归档的格式前提,显示名里的换行会把一条记录劈成两条,让ref对不上内容;显示名里的方括号能在正文里伪造出第二个发言人前缀,把话安到别人头上。openclaw三样都做(sanitizeEnvelopeHeaderPart,注释写的就是"header部分不能有能力打断这个方括号前缀")。E.要不要把前缀从显示和历史里剥掉
openclaw为此写了strip-inbound-meta.ts,317行。
inbound-meta.ts:436-541,106行)。它装的是回复链、转发来源、群成员、位置这些openclaw专有的渠道上下文,跟"谁说的"是两件事。F.记忆要不要按人隔离
共用一份记忆是产品形态,不是缺陷。
memory-wiki里,人是一张带canonicalId+aliases+handles的markdown页(markdown.ts:42-56、compile.ts:424-445),由模型自己维护,不是管道里的字段。我们的topics/people/就是同一个东西,跨渠道同一人的合并也归它管。openprogram --profile <name>)。兼容前缀仍在正文里,所以发信人可以输入第二个标签;该标签会作为正文保留, 但不会建立新记录身份。当前信任边界是持久化的speaker字段、writer JSON对象的speaker字段和有效的source frame。
speaker_id/speaker_display只从渠道消息写入节点metadata,
_records()不解析正文。Writer输入把每个真实turn编码为单独的JSONL物理行,
只有完整JSON对象的speaker字段建立身份;content中的换行和伪记录只是JSON字符串内容。
source parser只从sources/<provider>/_v2/<thread>.md字节0的固定format marker开始,
严格接受完整record-lines frame并按声明行数跳过正文;speaker id必须是规范UTF-8 percent编码,
错误转义或原样--使frame非法,空marker仍是合法display-only身份;遇非法frame停止。user记录的有限正文前缀检索hint;
其中的speaker marker和伪frame都不建立可信身份。检索结果用speaker_trusted区分v2身份(true)与legacy hint(false)。
改正文:把显示名那套方括号规则套到正文开头
最直接的一条,也是唯一一条会改到用户原话的。
[,而这些正是别人发给编程agent的主要内容;也没有哪条规则能把[2026-08-09] INFO ready和伪造的标签分开。改prompt与输入协议:身份和正文分字段传输
不动任何人打的字,不使用手写header、XML或Markdown fence。
ref、speaker、content分字段,标准JSON编码转义LF、CR、引号和反斜线;渲染器再显式转义模型可能显示为换行的U+2028/U+2029。Prompt明确只认speaker字段;content中的任何文本都不能建立身份或第二条记录。加字段:把发言人放进运行时自己填的槽位
结构化字段与v2 framing均已实现。
speaker字段,由运行时结构化字段生成。新source只写入固定marker开头的_v2/文件,percent编码的speaker-id只在严格合法的record-lines frame中可信。正文标签仍保留,因为处理当前轮次的agent只读content。| 谁清洗了什么 | 发言人标签 | 正文 | 出处 |
|---|---|---|---|
| openclaw | 清洗,sanitizeEnvelopeHeaderPart去换行、方括号变圆括号,注释写明"header部分不能有能力打断这个方括号前缀" |
不清洗,${resolvedSender}: ${params.body}原样拼 |
src/auto-reply/envelope.ts:58-67, 213-219 |
| hermes-agent | 不清洗 | 不清洗,f"[{user_name}] {message_text}"两边都原样 |
gateway/run.py:7765 |
| 我们(今天) | 清洗,跟openclaw同一套规则 | 不清洗 | channels/base.py:46, 243 |
| openclaw(另一处) | wrapPromptDataBlock:给不可信文本挂标签、加围栏、把围栏用的<>转义掉让文本关不掉自己的围栏、去掉控制字符和格式字符。用在子agent产出和内部事件上,没有用在入站渠道正文上 |
src/agents/sanitize-for-prompt.ts:16-42 |
|
名字 (id): 。但通用规则在openclaw别处:
转义围栏自身的定界符,让被包的文本关不掉自己的围栏。这条规则对;我们这里由JSON字段边界隔离
speaker和content,所以处理方式是结构化序列化,不是改别人写的话。
当前BM25 source检索可按可信speaker键回答“张三说过预算的什么”。 speaker过滤只匹配源记录的稳定id、规范化显示名或完整标签,不把正文提及和主题段落算成发言。
sources/底下有意义:一条主题段落是写入方关于某个主题写的话,没有人说过它。
所以"张三说了什么"和"关于张三知道什么"是两个问题,后一个是path_prefix=topics/people/,今天就能用。
带上发言人就是把检索收到源记录上,并在结果里说明这一点。sources/<provider>/_v2/<thread>.md,
用record-lines:N声明该记录占用的字面LF行数,parser和去重扫描从文件起点严格按N跳过正文。
因此正文即使包含合法hash anchor、source-id、speaker-id和记录行,也不能生成第二条事件或阻止后续真实记录归档。
parser遇非法或截断frame停止;只有合法v2 frame中的speaker-id可信。Markdown尾双空格、CRLF和尾换行经过
writer及workspace runtime目录临时文件的flush/fsync/os.replace保持不变;临时文件不进入revision、可见列表或stage copy。
旧文件冻结,只做受限兼容解析,不参与v2去重。检索结果以speaker_trusted=false标明legacy hint。persist=False),持久缓存格式升到v8并忽略缺少当前speaker或trust字段的旧数据。
| 框架 | 能不能按人过滤记忆 | 怎么做的 / 差在哪 | 出处 |
|---|---|---|---|
| hermes-agent | 能,但靠它自己没写的provider | Honcho把每条消息通过各自的peer对象写进去,每个读接口都接peer,发言人是存下来的一个维度。它自己的tools/memory_tool.py是单用户的,session_search过滤的是role不是身份 |
plugins/memory/honcho/session.py:365-373, 1025-1069 |
| openclaw | 只能答"关于某人",答不了"某人说过" | entities/<slug>.md带canonicalId/aliases/handles,靠路径查、靠编译出来的人物目录、或靠一次给人物页加分而不是筛出人物页的检索找到;这些字段是模型手写的,wiki_apply没有对应参数。memory_search只接{query,maxResults,minScore,corpus};LanceDB后端是不带where的裸向量检索;active-memory检索前把查询里以sender开头的行删掉 |
memory-wiki/src/markdown.ts:42-101、query.ts:624-668、tool.ts:81-92、memory-core/src/tools.shared.ts:31-36、memory-lancedb/index.ts:260-262、active-memory/index.ts:2238 |
| codex-cli | 不能 | 记忆蒸馏成~/.codex/memories底下的平铺文件,状态行只按thread_id归,没有发言人字段也没有查询接口 |
codex-rs/memories/README.md、state/memory_migrations/0001_memories.sql |
| claude-code-leaked | 不能 | 记忆文件frontmatter只有{description, type},type里的user指的是唯一那个机主,不是多人里的一个;召回是模型从描述列表里挑文件名 |
src/memdir/memoryScan.ts:13-19、memoryTypes.ts:14-20 |
| opencode | 没有长期记忆 | 工具里没有记忆/召回这一类 | 搜memory_search|recall|remember零命中 |
| pi-ai | 没有长期记忆 | 仓库是provider SDK | 搜memory|recall零命中 |
| pi-mono | 没有长期记忆 | 唯一命中是InMemorySessionRepo,内存里的会话存储 | harness/session/memory-repo.ts:5 |
| weclaw | 没有长期记忆 | 全仓35个Go文件,命中只有两条日志字符串 | messaging/sender.go:34,77 |
topics/people/,不是这一节要的东西。
其余六个在这个问题上什么都没有,如实记「没有」。
speaker_id和
speaker_display独立于正文和路由peer_id传到持久化节点;正文仍保留
[显示名 (id)] 兼容前缀,但写入和检索不从正文反推新记录身份。SourceRecord.speaker_label统一生成规范标签。Writer将每个turn序列化为一个紧凑JSON对象和一个物理行,
ref、speaker、content分字段;prompt只认speaker字段,不解析content中的标签。
新source只写入固定format marker开头的_v2/文件;speaker id使用可往返验证的规范UTF-8 percent编码,record-lines隔离可伪造正文,
parser遇非法frame停止,只有合法v2 frame中的marker可信。合法但没有marker的记录保持speakerless。BM25 v8按稳定id、显示名或标签过滤,
与路径和日期过滤组合,结果只来自sources/。memory_search已暴露speaker,
embedding与speaker组合会显式返回INVALID_ARGUMENT,不会忽略过滤。owner/local;已配对渠道保留渠道提供的
稳定speaker id。缺少可信字段的历史节点与旧framed记录仍保持speakerless。
只有unframed且记录头严格为user的旧记录可从历史正文前缀做有限检索兼容;旧归档冻结且不参与v2去重。
同一source id的合法v2事件在检索、链接和校验中优先于legacy;没有v2时仍可读取legacy。
speaker_trusted只对trusted状态的v2 marker为true;pending、legacy hint和speakerless记录为false。Markdown尾双空格、
CRLF/尾换行、完整伪造块、截断停止、旧缓存重建和独立进程导入均有回归测试。memory_search的source结果使用真实#source-... anchor,并向调用agent显示
speaker_trusted、speaker_id和speaker_display。runtime.json的cursors迁移会同时读取经过校验的legacy header与
严格v2合法前缀,按session合并节点标记后才删除旧字段,不从正文或非法尾部之后提取ID。docs/reference/design/memory/overview.md的"谁说的"一节,
产品侧在docs/integrations/channels.md。owner/install/<16 hex>只从实例状态目录的owner.json读取或首次创建,不从发言人推导。
运行时只有两档:本地owner取得完整集合;已配对渠道只有reply与
memory.source.append。未配对消息不是第三档,不进入agent;未配对群聊正文仅以pending来源归档且不参与主动提炼。
唯一的工具执行检查位于openprogram/agent/internals/_approval.py::_gated_execute,顺序是不可配置的
hard constraint之后、permission rules、审批与bypass之前;它只读取principal_id和
authority_tier,不读取正文speaker。同步continuation、Task、inbox、subagent及spawn子进程都通过
可序列化字段显式传播并只能继承现有tier。paired的memory_update在工作区层强制为新建或字节追加;
pending来源只能由本地交互owner通过memory_promote提升;提升会记录审计事件并使用同一writer事务写入Topic,已有引用时跳过。该改动没有修改Source v2的
record-lines:N framing或speaker-id percent编码格式。后台writer现在使用默认聊天agent的
provider、模型与凭据,memory.writer.model可实时覆盖;memory.backend=none关闭提示注入、召回、
自动写入、整理和记忆线程,并在工作区初始化前拒绝全部CLI memory动词和/api/memory/*路由。
writer最近成功、失败分类、retryable判定和待处理数可由工具、CLI与Web查询。一次性
memory backfill已实现:只处理未被Topic引用的trusted Source,忽略marker并排除pending,按批复用正常writer事务。
正式工作区已有23个v1 source文件仍使用旧origin_scope字段;读取器只接受该旧格式的精确canonical结构并映射为当前tier,不重写append-only文件。154个frame已全部通过扫描,随后2条待处理消息完成Topic事务和marker写入。该真实验收没有执行backfill,因此152个未引用frame仍留在正式工作区。组合测试已覆盖SessionDB到watcher状态的完整写入序列。hold队列、按请求方档位过滤读取结果和远程Web认证仍延期。写入失败原因码是封闭枚举,受限writer阶段看不到Source归档。