# Agent Note: 持久的逐步骤时间上下文

Status: implemented

[English](2026-07-16-durable-per-step-time-context.md) | 中文

## 问题

仅存在于请求中的时钟可以告诉模型当前时间，但在系统提示词中替换这个值会移除先前对时间敏感的推理所依据的证据。在包含多个步骤的轮次中，请求需要保留影响先前步骤的读数。系统必须能在重启后重建请求，自动压缩（compaction）也必须将模型实际收到的同一份时间上下文纳入考量。

进程本地刷新缓存会使显示时间依赖于一种既无法在恢复后保留、也无法从持久会话重建的状态。持久的间隔调度可以减少追加频率，而不引入这种隐藏状态。

## 决策

`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强，这些增强既不定义工具也不定义服务；已交付示例不挂载此插件，因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器；需要读数且下游决策 enter 时，返回一条额外的 `UserMessage`。该消息携带来源 `{ kind: 'plugin', plugin: 'time-context' }`；受间隔抑制、reject 或失败的尝试不会追加任何内容。

监听器在 `step/start` 之前采样，并只在最终 enter 决策中结算读数。AgentLoop 会在 `step/start` 之后、请求派生之前记录它。因此，下游 reject 或失败会阻止读数进入持久历史。

省略可选配置 `timeZone` 时，插件在加载时解析一次 Node 进程的 IANA 时区；显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。

插件在加载时手动校验可选配置 `refreshIntervalMs`，其值必须为非负安全整数。省略或设为 `0` 时，每次符合条件的准备尝试都会注入。设为正数时，插件扫描原始会话事件，查找来源属于本插件的最新 `user/message`；不存在此类事件、系统挂钟向后移动，或该事件已达到配置时长时，插件执行注入。即使压缩已隐藏消息，调度仍以原始事件时间戳为准，因此该机制无需计时器或进程本地缓存，也能跨轮次和进程恢复持续生效。

### 文本与时长基线

第一个步骤的注入读数为：

```text
Time sampled while preparing turn <turn>, step 1: <timestamp>
Elapsed since the preceding model-visible message: <duration-or-unavailable>.
```

基线是前一条用户消息、助手消息、工具结果或 steering（中途引导）消息。对于普通消息轮次，这包括开启轮次的已接受提示词。如果不存在模型可见消息，时长为 `unavailable`。

后续步骤的注入读数为：

```text
Time sampled while preparing turn <turn>, step <step>: <timestamp>
Elapsed since the preceding step context: <duration-or-unavailable>.
```

其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。如果间隔抑制导致同一轮次中没有更早的读数，时长为 `unavailable`。时长采用紧凑的整秒单位，并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后，仍可归属于对应的历史准备尝试。

### 持久性与请求重建

每个读数都作为普通表层节点保留，直至压缩将其隐藏；正数间隔调度绝不会移除已有读数。因此，后续请求会看到影响先前准备过程和步骤且尚未被隐藏的累计读数，而不是一个被原地改写的系统提示词值。

插件不向系统提示词组装贡献任何内容。`request/header` 不包含时间上下文文本；请求重建从每个 `step/start` 取得完整的持久表层前缀。读数与请求无需一一对应，因为间隔抑制可以让请求进入步骤而不追加读数，reject 或失败则两者都不追加。插件通过 agent 注册表使用生命周期监听器，运行时不需要系统提示词服务。

## 测试

单元测试和真实 agent loop（智能体循环）测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已中止信号行为、后续监听器取消和失败、监听器 dispose（资源释放）、来源与表层元数据、多步骤累计可见性，以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 loader，依次驱动两个单次任务轮次，并从外部校验持久化且来源归属于插件的消息。

## 考虑过的替代方案

- **保留动态系统提示词区段和进程本地刷新缓存**——不予采纳，因为替换会抹去先前读数，缓存状态无法回放，而且冻结的请求内容集合会使该值在整个 agent loop 实例期间保持陈旧。
- **替换前一条上下文表层节点**——不予采纳，因为替换会保留旧节点的位置或隐藏中间的会话内容；两者都不能表达新读数何时开始可见。
- **通过后台计时器注入**——不予采纳，因为空闲期间没有待处理请求消费该值，而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。
- **只通过工具提供时间**——不予采纳，因为普通时间推理会产生本可避免的工具往返，也不能保证每个步骤之前都有读数。
- **使用 `agent/session-prefix`**——不予采纳，因为一个 loop 实例前缀无法表示不同的步骤时间戳，也不会累计具有历史归属的读数。
- **修改已组装的请求或注册独立提示词变量**——不予采纳，因为请求内插入会绕过持久表层，不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。
- **默认使用 UTC 或增加时区检测依赖**——不予采纳，因为显式挂载的插件默认遵循其进程环境，除非操作方选择 IANA 时区，而任何服务端库都无法推断远程用户的时区。
- **在已交付组合中挂载插件，或把它放进 `core/`**——不予采纳，因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择，不是产品主干策略。

## 后果

- 省略 `refreshIntervalMs` 或设为 `0` 时，每次符合条件的准备尝试都会留下记录；正数间隔会减少追加频率和历史增长，同时使持久调度在恢复后继续生效。
- 时间上下文仅追加并保留到压缩隐藏旧表层节点为止，其中也包括后续取消或失败所留下的准备读数。
- 第一个步骤的时长通常从开启轮次的提示词起算，后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。
- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区，时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间，需要另行建立持久输入契约。
