time-context: durable per-step history (round 1)

This commit is contained in:
Hypatia May
2026-07-16 17:47:51 +08:00
parent 4139e093dd
commit 46e63a004c
17 changed files with 432 additions and 344 deletions
@@ -0,0 +1,68 @@
# RFC: 持久的逐步骤时间上下文
Status: implemented
[English](2026-07-16-durable-per-step-time-context.md) | 中文
## 问题
仅存在于请求中的时钟可以告诉模型当前时间,但在系统提示词中替换这个值会移除先前时效性推理所依据的证据。在包含多个步骤的轮次中,每个请求既需要看到自己的读数,也需要看到影响先前步骤的读数。系统必须能在重启后重建请求,自动压缩(compaction)也必须核算模型实际收到的同一份时间上下文。
刷新间隔使显示的时间取决于进程本地缓存状态,而不是持久会话。它还允许多个步骤共用同一个读数,即使每个步骤对应不同的模型请求。
## 决策
`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并为信号尚未取消的每个步骤调用一次 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据。
监听器在匹配的 `step/start` 之前记录上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到待执行步骤的时间上下文。随后,该消息进入该步骤使用的历史快照。
插件只有一个可选配置键 `timeZone`。省略时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。由于每个步骤都会记录读数,因此插件没有刷新间隔或计时器。
### 文本与时长基线
轮次中的第一个步骤收到:
```text
Time recorded before turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```
基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。
后续步骤收到:
```text
Time recorded before turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration>.
```
其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后仍可按历史归属。
### 持久性与请求重建
每个读数都作为普通表层节点保留,直至压缩将其隐藏。因此,后续请求会看到影响先前步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。
插件不向系统提示词组装贡献任何内容。`request/header` 和 `request/header-delta` 不包含时间上下文文本;请求重建从匹配 `step/start` 时的持久表层前缀取得读数。插件通过 agent 注册表使用生命周期监听器,运行时不需要系统提示词服务。
## 测试
单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、挂钟后退钳制、时区校验、已取消信号行为、监听器 dispose(资源释放)、来源与表层元数据、相对于 `step/start` 和普通预步骤监听器的顺序、每个已发送请求恰好一个事件、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。
## 取代的决策
本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久的逐步骤历史取代 `context:time` 提示词区段、刷新缓存、`refreshIntervalMs` 和请求头增量。
## 考虑过的替代方案
- **保留动态系统提示词区段和刷新缓存**——不予采纳,因为替换会抹去先前读数,缓存状态无法回放,而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
- **替换前一条上下文表层节点**——不予采纳,因为替换会保留旧节点的位置或隐藏中间的会话内容;两者都不能表达新读数何时开始可见。
- **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
- **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。
- **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。
## 后果
- 选择加入的每个模型请求都会在步骤开始前获得新鲜且可重建的时间读数。
- 在压缩隐藏旧表层节点之前,时间上下文会按每个步骤一条两行消息的速度增长;与原地替换的系统提示词区段相比,保持历史真实性会消耗更多 token。
- 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。