会话与事件溯源
一次对话就是一条只追加的事件日志。模型看到的消息历史不是独立存储的状态,而是从日志重放折叠出来的投影。
对应实现:src/Tether.Core.Contracts/(定义)与 src/Tether.Core.Session/(内存实现)。
会话接口
public interface ISession
{
SessionId Id { get; }
SessionHeader Header { get; }
SessionLogOffset NextOffset { get; }
IReadOnlyList<SessionEvent> Events { get; }
SessionEvent? EventAt(SessionSeq seq);
IReadOnlyList<SessionEvent> SnapshotEvents(
SessionLogOffset from,
SessionLogOffset? toExclusive = null);
IReadOnlyList<SessionEvent> OwnEvents();
bool IsOwnSeq(SessionSeq seq);
ValueTask<SessionEvent> AppendAsync<T>(
SessionEventType<T> type,
T data,
SessionEventMetadata? metadata = null);
ValueTask<SessionEvent?> AppendIfAsync<T>(
SessionEventType<T> type,
T data,
Func<IReadOnlyList<SessionEvent>, bool> condition,
SessionEventMetadata? metadata = null,
CancellationToken cancellation = default);
IReadOnlyList<ChatMessage> DeriveMessages();
SurfaceFoldResult SnapshotSurface();
SessionEvent? LatestEvent(SessionEventType type);
}
Events 与无参尾界的 SnapshotEvents 在下一次 append 前复用同一个 immutable 全量快照;EventAt 是 O(1) 点读。日志按固定大小分段只追加存放,已发布的槽位永不改写,所以全量与区间快照都是 O(1) 的前缀视图,而不是整段复制——逐事件订阅者读取快照不会让每次追加的代价随日志增长。区间使用 [from, toExclusive),边界 clamp 到当前日志长度,尾后与倒置区间返回空。OwnEvents() 以 header 的 InheritedEventCount 切 fork 前缀。DeriveMessages() 给出当前模型可见顺序下的脱离副本:每次调用返回新的列表与消息对象;消息投影覆盖的节点只复制内容列表与属性字典,内容项与会话维护的 fold 共享,只读使用。
SnapshotSurface()(dsh session.surface)与 LatestEvent()(dsh requestHeader()/requestContext() 的按事件名推广)是热路径读取:前者返回维护中的 surface fold——节点、投影覆盖、被折叠前缀的 NextOffset,以及 Revision/ReplaceGeneration/ContentGeneration 三种单调代数;后者返回某类型最近提交的事件。两者都随追加增量推进,读取不重放历史,是 #244 之后替代 Events 全量扫描的读法。
SessionId 是一个 version 7 UUID 的强类型包装。非空且版本必须为 7,否则构造就抛 ArgumentException;用 SessionId.Create() 生成时间有序的新 id。
追加的确切语义
AppendAsync 的完成含义需要精确理解,这决定了错误处理该怎么写。
- 完成表示事件已提交,并且每个观察者都已被调用;观察者内部的异步工作可能仍在进行。
- 观察者失败由事件总线容纳,不会回滚已提交的事件,也不会让这次调用看起来像没提交。
- 观察者失败经宿主错误通道上报。
- 观察者不能重入地向同一个会话追加事件。
事件提交时会先对载荷做 JSON 快照,因此之后修改原对象不影响已记录的内容。
接纳是增量的(dsh SurfaceManager.validateNext):会话维护一份与完整 FoldSurface 共用同一转移函数的 surface 状态,候选事件只按这份状态做信封、载荷与 surface 转移校验,校验通过才提交,失败时状态不变——单次追加的代价与日志长度无关。SessionEventCatalog 的注册(事件类型或消息投影)一旦变化,下一次读写会先按当前注册把已提交日志重折叠一次,结论与逐次全量校验在该点一致:投影卸载后含其事件的会话失败关闭,换上新的投影定义则按新定义重新解释历史。
不可变事件信封
| 成员 | 含义 |
|---|---|
Type | 稳定事件名,写进日志。 |
Seq | 会话内非负、连续的序列号。 |
Time | Unix 毫秒时间戳。 |
Data | 权威的 JSON 载荷快照(JsonElement)。 |
Ignorable | 读取方遇到未知类型时是否可以跳过。 |
SurfaceOp | 该事件对模型可见面的操作,可空。 |
SourceEventSeqs | 引用的更早事件序列号,可空。 |
读取载荷要带上描述符,类型不匹配会被拒绝:
var message = evt.ReadData(SessionEventTypes.UserMessage);
// 事件名与描述符不符时抛 InvalidOperationException
类型化事件描述符
事件不是裸字符串加任意 JSON,而是由 SessionEventType<T> 描述:
public SessionEventType(
string name, // 稳定事件名
JsonTypeInfo<T> jsonTypeInfo, // 快照与重放用的 JSON 元数据
Action<T>? validate = null, // 载荷不变量校验
Func<T, ChatMessage?>? projectMessage = null, // 纯投影:给出模型可见消息
Func<T, T, bool>? allowsReplacement = null, // 限制本类型产生的位置改写
Action<JsonElement>? validateElement = null, // 类型化解码前的原始 JSON canonical 检查
bool requiredOnRead = true, // false:以 ignorable envelope 写入,旧读者可跳过
bool requiresMessageProjection = false) // 回放必须有已注册的 SessionMessageProjection
IsSurfaceEligible 不是单独配置的,而是由 projectMessage 是否为 null 推导出来的——能投影出消息的事件才参与模型可见面。
内置事件类型
SessionEventTypes.All 是 core 层固定注册的描述符集合,下表按它的声明顺序列出,条目以代码为准。审批、子代理、workflow 等能力包在各自程序集里声明自己的事件类型,不在 All 中。
宿主构建里所有 Tether*.dll 声明的描述符都登记在词汇快照 tests/Tether.Bundles.Tests/session-event-vocabulary.txt,All 的条目对应其中程序集为 Tether.Core.Contracts 的行。增删、改名描述符或翻转后两列的取值而不同步快照,门禁测试就会失败;变更分类规则见会话持久化。
后两列取自描述符本身,也就是快照的第二、三列:
- 上模型可见面:
IsSurfaceEligible,快照记作surface/log。 - 读取要求:
RequiredOnRead。旧读者遇到不认识的required事件会拒绝整条日志;optional事件(声明requiredOnRead: false)以 ignorable envelope 写入,旧读者可以跳过。optional事件不能上模型可见面,构造期直接拒绝。
| 事件名 | 载荷 | 上模型可见面 | 读取要求 |
|---|---|---|---|
session/end-seed | EndSeedEventData | 否 | required |
system/message | SystemMessageEventData(System 角色) | 是;content 为空时不产生消息,但节点保留 surface 位置 | required |
user/message | UserMessageEventData(User 角色,含来源归属与附件) | 是 | required |
assistant/message | AssistantMessageEventData(Assistant 角色,含 interrupted 标记) | 是;内容为空或只有 usage 时不投影 | required |
tool/result | ChatMessage(Tool 角色) | 是;改写受限 | required |
tool/presentation | ToolPresentationMetadata | 否 | required |
request/header | RequestHeaderEventData | 否 | required |
request/start | RequestStartEventData | 否 | optional |
request/failed | RequestFailedEventData(turn / step / 结构化 failure;失败尝试的结算,web wire 映射成上游 assistant/attempt) | 否 | optional |
agent/inbox/spliced | InboxSplicedEventData | 否 | required |
agent/inbox/moved | InboxMovedEventData | 否 | optional |
turn/start | TurnStartEventData | 否 | required |
turn/end | TurnEndEventData | 否 | required |
step/start | StepBoundaryEventData | 否 | required |
step/end | StepBoundaryEventData | 否 | required |
tool/call | ToolCallEventData | 否 | required |
assistant/chunk | AssistantChunkEventData(turn / step / provider-neutral chunk) | 否 | required |
model/selection | ModelSelectionEventData | 否 | required |
request/context | RequestContextEventData | 否 | required |
image/offload | ImageOffloadData | 否;由已注册的 SessionMessageProjection 改写既有节点的消息,未注册投影时 fold 失败关闭 | required |
chunk 是 durable stream fact,message 是 assembled fact
AgentLoop 会逐块写入 provider-neutral assistant/chunk,再让最终
assistant/message.sourceEventSeqs 精确引用构成它的 chunks。只有 usage、没有
text/reasoning/tool 的成功响应同样会写 assembled message:保留 UsageContent,但不会
制造占位文本。取消时只把已真实交付的前缀关闭成 interrupted message;失败的尝试不写 message,而是以
request/failed 结算,它的 chunks 就是同 step 最近一条 request/start 之后的那些。
任何结算都不能把另一次 attempt 的 chunk 混进来。
消息类事件有一组持久化校验,目的是保证日志里只存放真正可序列化的东西:
- 角色必须与描述符匹配;
ChatMessage.RawRepresentation以及各内容项、注解的RawRepresentation都必须为空——它们不是持久 JSON;FunctionCallContent.Exception与FunctionResultContent.Exception必须为空,因为Microsoft.Extensions.AI不序列化它们;tool/result必须恰好包含一个函数结果。
tool/result 的改写限制
对 tool/result 做位置改写时,只有在把两侧的 Result 置空之后
其余部分深度相等的情况下才被允许。也就是说可以修正结果值,但不能借改写偷换这条结果对应的调用身份。
模型可见面与 SurfaceOp
日志里并非所有事件都进入模型请求,进入的那些也可以被后来的事件替换掉。这由追加时的 SurfaceOp 决定:
| 操作 | 含义 |
|---|---|
SurfaceOp.Append | 把消息追加到可见面尾部。 |
SurfaceOp.Replace(start, end) | 按位置替换当前可见面上从 start 到 end 的闭区间。 |
Replace 的两端都是当前可见面上的序列号,闭区间包含端点。
折叠与投影
投影是纯函数,只依赖不可变日志,因此可以对任意完整日志或其前缀重放。
// 折叠出当前仍可见的事件
IReadOnlyList<SessionEvent> surface = SessionProjections.FoldSurface(session.Events);
// 折叠后再投影成模型请求顺序的消息
IReadOnlyList<ChatMessage> messages = SessionProjections.DeriveMessages(session.Events);
两个方法都接受可选的 SessionEventCatalog,省略时使用构建期默认目录。DeriveMessages 的做法是先 FoldSurface,再对每个可见事件调用其描述符的投影,跳过投影为 null 的事件。它们是离线日志(存储读取、导出、校验)的纯函数重放;活的会话在每次追加时增量推进同一个 fold,session.SnapshotSurface() 与 session.DeriveMessages() 只读维护中的状态,不重放日志。
为什么值得这么设计
消息历史是导出物而不是权威状态,意味着"当时到底发了什么给模型"永远可以从日志重建, 而不依赖任何可变缓存是否被正确维护。这也是会话可持久化、可回放、可审计的前提。
Stats 与 telemetry 都在日志 authority 之后
sessionStats 是 projection,而不是另一份可变计数器。它以 exact (turn, step) 匹配窗口:
- 每个 matching
step/end计一个 step,同一 turn 只在第一次关闭 step 时计一次 turn; tool/call计调用,result/dangling 沿既有分类折叠;- first-token 时间先留在 pending state,只有 matching assembled
assistant/message到达后才一起提交 total、TTFT 与 decode; - cancelled/failed step 没有 assembled message 时不贡献 LLM timing;usage-only message 仍贡献 token/sample;
- live、cold full-log 与 checkpoint+tail 使用同一 fold,所以结果一致。
Telemetry 同样只是 fail-soft consumer:durable append 不等待 exporter。它使用固定容量、single-reader
队列;redactor 失败会丢弃 envelope 并只发 secret-free 诊断,绝不回退导出原始值。worker 自行按
有界 backoff 重试,EffectScope 卸载会取消并等待已准入 capture/export。OTLP/HTTP 输出遵守 Logs
JSON mapping(scopeLogs.scope、AnyValue body、十进制字符串 timeUnixNano 与合法
severityNumber),不会把 exporter/cache 反客为主变成会话 authority。
载荷的 JSON 约束
快照时会递归校验 JSON,不合规直接抛 JsonException:
- 对象内不允许重复属性名;
- 数字必须有限,且不允许负零;
- 只接受对象、数组、数字、字符串、布尔与 null 这几种 kind。
序列号相关的整数被限制在 IEEE 双精度可安全表示的范围内(上界 9007199254740991),以保证跨语言读取方不会丢精度。
会话工厂
public interface ISessionFactory
{
ISession Create(SessionId? id = null, IEnumerable<SessionEvent>? seed = null);
}
工厂产出的是尚未发布为进程级 ISession 的会话,供以编程方式创建 Agent 时使用。id 省略时铸造新的 version 7 UUID;seed 用于恢复,必须是从序列号零开始的完整事件日志。
事件提交的对外通知
会话事件提交后会在共享根总线上发出 session/event(常量 CoreEvents.SessionEventAppended),参数是 ISession 与那条 SessionEvent。插件靠它观察会话推进,而不需要轮询日志。