From 46e63a004c5be02841e5c9047870d3c4c2b3f47a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 17:47:51 +0800 Subject: [PATCH] time-context: durable per-step history (round 1) --- docs/config-catalog.md | 8 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.md | 3 +- docs/rfc/INDEX.md | 1 + .../2026-07-14-time-context-plugin.i18n.yaml | 4 +- .../feature/2026-07-14-time-context-plugin.md | 2 + .../2026-07-14-time-context-plugin.zh.md | 2 + ...16-durable-per-step-time-context.i18n.yaml | 6 + ...026-07-16-durable-per-step-time-context.md | 68 ++++ ...-07-16-durable-per-step-time-context.zh.md | 68 ++++ packages/context/README.md | 2 +- packages/context/time-context/README.md | 40 +- packages/context/time-context/package.json | 3 +- packages/context/time-context/src/index.ts | 165 ++++---- .../time-context/tests/time-context.e2e.ts | 43 ++- .../time-context/tests/time-context.spec.ts | 357 ++++++++---------- packages/context/time-context/tsconfig.json | 2 +- 17 files changed, 432 insertions(+), 344 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md create mode 100644 docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..4dc1e1346b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -867,19 +867,17 @@ Source: [`packages/core/system-prompt/src/index.ts:143`](../packages/core/system ## `@deepseek-ai/dsh-time-context` -Requires: `systemPrompt` +Requires: `agents` ```ts config-catalog -/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +/** Request-time clock formatting. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ - refreshIntervalMs?: number } ``` -Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tool-bash` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d8c924b24d..1b2923e63e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`time-context`](../packages/context/time-context), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 82763bdc55..dc35d8c88b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -203,7 +203,6 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_llm pkg_time_context --> pkg_agent - pkg_time_context --> pkg_system_prompt pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_session @@ -418,7 +417,7 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..f298077d94 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -81,6 +81,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | +| [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml index cb5d12c562..06f0c510e6 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-14-time-context-plugin.md: 105bf53550f087fdefb1e6fe0ec493f8628d3e18 -2026-07-14-time-context-plugin.zh.md: 60e9004b1453e75e1bcd84870ad7f18d200a95d8 +2026-07-14-time-context-plugin.md: aa24c6246718cfe0bb3ed63d0791cf890514d9fe +2026-07-14-time-context-plugin.zh.md: c077cc5a38d7c559b4f5101450a1a8268a204f1c diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md index 105bf53550..aa24c62467 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md @@ -6,6 +6,8 @@ English | [中文](2026-07-14-time-context-plugin.zh.md) ## Problem +The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract. + An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message. Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle. diff --git a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md index 60e9004b14..c077cc5a38 100644 --- a/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md @@ -6,6 +6,8 @@ Status: implemented ## 问题 +本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。选择加入式 package(包)、分区时间格式和校验仍然保留;后续 RFC 负责当前的模型可见与持久性契约。 + 如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml new file mode 100644 index 0000000000..b8d7b45e9b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-16-durable-per-step-time-context.md: eac975fd2a85d18d8323ba7651995516226ab887 +2026-07-16-durable-per-step-time-context.zh.md: fe38239729a00f71138ad3b37ce2c1d6f7895a60 diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md new file mode 100644 index 0000000000..eac975fd2a --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -0,0 +1,68 @@ +# RFC: Durable per-step time context + +Status: implemented + +English | [中文](2026-07-16-durable-per-step-time-context.zh.md) + +## Problem + +A request-only clock can tell the model the current time, but replacing that value in the system prompt removes the evidence behind earlier time-sensitive reasoning. Multi-step turns need each request to see its own reading and the readings that shaped preceding steps. The request must remain reconstructable after restart, and automatic compaction must account for the same timing context the model receives. + +Refresh intervals make the displayed time depend on process-local cache state rather than the durable session. They also let multiple steps share a reading even though each step is a distinct model request. + +## Decision + +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and calls `agent.inject()` once for every step whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata. + +The listener records context before the matching `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe the pending step's time context. The message then enters the history snapshot used by that step. + +The plugin has one optional config key, `timeZone`. An omitted value resolves the Node process's IANA zone once at plugin load; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. There is no refresh interval or timer because every step records a reading. + +### Text and elapsed baselines + +The first step in a turn receives: + +```text +Time recorded before turn , step 1: +Elapsed since the preceding model-visible message: . +``` + +The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. + +Later steps receive: + +```text +Time recorded before turn , step : +Elapsed since the preceding step context: . +``` + +Their baseline is the durable event timestamp of the preceding time-context message in the same turn. Duration formatting uses compact whole-second units and clamps backward wall-clock movement to zero. The explicit turn and step make every retained reading historically attributable after later turns append more context. + +### Durability and request reconstruction + +Each reading remains a normal surface node until compaction shadows it. A later request therefore sees the cumulative unshadowed readings that affected earlier steps, rather than a system-prompt value rewritten in place. + +The plugin contributes nothing to system-prompt assembly. `request/header` and `request/header-delta` contain no time-context text; request reconstruction obtains the reading from the durable surface prefix at the matching `step/start`. The plugin depends on the agent registry for its lifecycle listener and does not require the system-prompt service at runtime. + +## Testing + +Unit and real-loop tests pin formatting, both elapsed baselines, backward-clock clamping, time-zone validation, aborted-signal behavior, listener disposal, source and surface metadata, ordering before `step/start` and ordinary pre-step listeners, exactly one event per transmitted request, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally. + +## Supersedes + +This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable per-step history replaces the `context:time` prompt section, refresh cache, `refreshIntervalMs`, and request-header deltas. + +## Alternatives considered + +- **Keep the dynamic system-prompt section and refresh cache** — rejected because replacement erases earlier readings, cache state is not replayable, and a frozen request envelope would make the value stale for an entire loop instance. +- **Replace the preceding context surface node** — rejected because replacement preserves the old node's position or shadows intervening conversation; neither represents when the new reading became visible. +- **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing. +- **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step. +- **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings. + +## Consequences + +- Every opted-in model request receives a fresh, reconstructable time reading before the step opens. +- Timing context grows by one two-line message per step until compaction shadows older surface nodes; historical truth costs more tokens than a replace-in-place system section. +- The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context. +- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. diff --git a/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md new file mode 100644 index 0000000000..fe38239729 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -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 , step 1: +Elapsed since the preceding model-visible message: . +``` + +基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 + +后续步骤收到: + +```text +Time recorded before turn , step : +Elapsed since the preceding step context: . +``` + +其基线是同一轮次中上一条时间上下文消息的持久事件时间戳。时长采用紧凑的整秒单位,并在系统挂钟向后移动时钳制为零。显式的轮次号和步骤号使每个保留的读数在后续轮次追加更多上下文后仍可按历史归属。 + +### 持久性与请求重建 + +每个读数都作为普通表层节点保留,直至压缩将其隐藏。因此,后续请求会看到影响先前步骤且尚未被隐藏的累计读数,而不是一个被原地改写的系统提示词值。 + +插件不向系统提示词组装贡献任何内容。`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 的持久追加边界而非客户端来源时间戳。 diff --git a/packages/context/README.md b/packages/context/README.md index 0045c6629c..b765f101c4 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -4,4 +4,4 @@ Opt-in plugins that add bounded model-visible request context without defining a | Package | Role | ctx key | |---|---|---| -| `time-context/` | Current time and elapsed-time system-prompt context | (none) | +| `time-context/` | Durable per-step current time and elapsed-time context | (none) | diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 84d487445b..a5b058b1e3 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-time-context -Opt-in dynamic system-prompt context with the current zoned time and elapsed time since the latest model-visible message before the turn. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). +Opt-in durable context with the current zoned time and elapsed time at every model step. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the durable time-context RFC](../../../docs/rfc/implemented/feature/2026-07-16-durable-per-step-time-context.md). ## Config @@ -8,36 +8,44 @@ Opt-in dynamic system-prompt context with the current zoned time and elapsed tim - id: time-context name: '@deepseek-ai/dsh-time-context' config: - timeZone: Asia/Shanghai # optional IANA override; omit for the process zone - refreshIntervalMs: 60000 # default; 0 refreshes on every step + timeZone: Asia/Shanghai # optional IANA override; omit for the process zone ``` -When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer. Every turn's first request refreshes; later steps reuse the reading until its age reaches the interval. `0` refreshes every step. Refresh occurs only during request assembly and creates no timer work. +When `timeZone` is omitted, the plugin resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit `timeZone` must be an IANA identifier and is validated at plugin load. -## Message baseline +## Timing semantics -The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time. +The plugin prepends an `agent/pre-step` listener. Every non-aborted step appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. -The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history. +Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline reports `unavailable`. + +The time reading stays in derived conversation history until a later compaction shadows it. Request headers and header deltas contain no time-context state, so the durable message plus the matching `step/start` reconstruct each request's reading. ## Model Experience -### Temporal system prompt +### Per-step temporal context -**What the model sees**: Every request in an active turn includes the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; `` is compact whole-second units or the first-turn fallback. +**What the model sees**: Before each step, one source-tagged context message containing the two lines below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; durations use compact whole-second units. -**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate. +**Token effect**: One two-line message accumulates per step until compaction shadows older history. -#### Temporal context section +#### First step ```markdown -Current time: -Time since previous message: . +Time recorded before turn , step 1: +Elapsed since the preceding model-visible message: . +``` + +#### Later steps + +```markdown +Time recorded before turn , step : +Elapsed since the preceding step context: . ``` ## Known Limitations and Deferred Work -- **Request-bound refresh only** — no clock update is emitted while the agent is waiting inside a model call or tool; the next assembled step refreshes once the configured interval has elapsed. -- **Whole-second display** — timestamps and durations omit sub-second precision even when `refreshIntervalMs` is below 1,000. -- **Session-event baseline** — elapsed time starts from the durable append timestamp, not a client transport's original send timestamp. +- **Whole-second display** — timestamps and durations omit sub-second precision even though durable event times retain milliseconds. +- **Session-event baseline** — elapsed time starts from durable append timestamps, not a client transport's original send timestamp. - **Process-local default zone** — omission uses the Node process's `TZ`, host, or container zone captured at plugin load, not a remote user's zone; configure an explicit IANA zone when those differ. +- **History cost between compactions** — one reading remains model-visible for every unshadowed step so prior timing claims stay historically truthful. diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index f319c7a5b1..dac0667c81 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-time-context", - "description": "Opt-in system-prompt context with the current time and elapsed time since the previous message", + "description": "Opt-in durable per-step context with the current time and elapsed time", "version": "0.0.1", "private": true, "type": "module", @@ -26,7 +26,6 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index cccd433811..0478e2e616 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -1,8 +1,6 @@ /** - * Opt-in request-time clock context. Active turns receive the current zoned - * time and elapsed time since the preceding model-visible message. The loop - * logs each rendered value as request-header state rather than conversation - * history. + * Opt-in per-step clock context. Every pending model request receives a + * durable, source-attributed time reading in conversation history. * * @module @deepseek-ai/dsh-time-context */ @@ -10,77 +8,27 @@ import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' +import type { Message } from '@deepseek-ai/dsh-llm' /** Cordis plugin name used by loader diagnostics. */ export const name = 'time-context' -/** The system-prompt registry that owns the dynamic request section. */ -export const inject = ['systemPrompt'] +/** The agent registry that owns the pre-step lifecycle seam. */ +export const inject = ['agents'] -/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ +/** Request-time clock formatting. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp. Omit to resolve the Node process's system zone at plugin load. */ timeZone?: string - /** Maximum age of a reading within one turn, in milliseconds (default 60,000; `0` refreshes every step). */ - refreshIntervalMs?: number } -/** Schemastery validation and defaults for {@link Config}. */ +/** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ timeZone: z.string(), - refreshIntervalMs: z.number().default(60_000), }) -interface OpenTurn { - turn: number - startSeq: number -} - -/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */ -interface RenderState { - turn: number - renderedAt: number - previousMessageTime: number | undefined - text: string -} - type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' -function openTurn(agent: Agent): OpenTurn | undefined { - for (const event of [...agent.session.events].reverse()) { - switch (event.type) { - case 'turn/end': - return undefined - case 'turn/start': - return { turn: event.data.turn, startSeq: event.seq } - default: - // Merge-extensible session events: only turn boundaries matter here. - break - } - } - return undefined -} - -/** Find the latest model-visible timestamp strictly before one turn boundary. */ -function previousMessageTime(agent: Agent, turnStartSeq: number): number | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.seq >= turnStartSeq) continue - switch (event.type) { - case 'user/message': - case 'assistant/message': - case 'tool/result': - case 'context/message': - case 'steering/message': - return event.time - default: - // Merge-extensible session events: non-surface records are not messages. - break - } - } - return undefined -} - /** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */ function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string { const parts = Object.fromEntries( @@ -107,31 +55,59 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } +/** Find the latest model-visible event, excluding this plugin's pending append. */ +function precedingMessageTime(agent: Agent): number | undefined { + for (const event of [...agent.session.events].reverse()) { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'tool/result': + case 'context/message': + case 'steering/message': + return event.time + default: + // Merge-extensible session events: non-surface records are not messages. + break + } + } + return undefined +} + +/** Find the preceding time-context event within the open turn. */ +function precedingStepContextTime(agent: Agent, turn: number): number | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'turn/start' && event.data.turn === turn) return undefined + if (event.type === 'context/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + return event.time + } + } + return undefined +} + function renderText( now: number, + turn: number, + step: number, previous: number | undefined, formatter: Intl.DateTimeFormat, timeZone: string, ): string { - const elapsed = previous === undefined - ? 'unavailable (no earlier message in this session)' - : formatDuration(now - previous) - return `Current time: ${formatTimestamp(now, formatter, timeZone)}\nTime since previous message: ${elapsed}.` + const elapsed = previous === undefined ? 'unavailable' : formatDuration(now - previous) + const baseline = step === 1 ? 'model-visible message' : 'step context' + return `Time recorded before turn ${turn}, step ${step}: ${formatTimestamp(now, formatter, timeZone)}\n` + + `Elapsed since the preceding ${baseline}: ${elapsed}.` } /** - * Register the request-time clock section for the lifetime of `ctx`. - * @param ctx - plugin context; the section registration is disposed with it. - * @param config - validated time zone and intra-turn refresh interval. - * @throws when the time zone or refresh interval is invalid. + * Register a prepended pre-step listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - validated time zone configuration. + * @throws when the configured or process time zone cannot be resolved. */ export function apply(ctx: Context, config: Config): void { const timeZone = config.timeZone - const refreshIntervalMs = config.refreshIntervalMs as number - if (!Number.isSafeInteger(refreshIntervalMs) || refreshIntervalMs < 0) { - throw new Error(`time-context: refreshIntervalMs must be a non-negative safe integer, got ${refreshIntervalMs}`) - } - let formatter: Intl.DateTimeFormat try { formatter = new Intl.DateTimeFormat('en-US', { @@ -152,32 +128,23 @@ export function apply(ctx: Context, config: Config): void { throw new Error(message, { cause: error }) } const resolvedTimeZone = formatter.resolvedOptions().timeZone - const states = new WeakMap() - ctx.systemPrompt.section({ - name: 'context:time', - order: 10, - text(context: AssembleContext): string { - const agent = context.agent - if (agent === undefined) return '' - const currentTurn = openTurn(agent) - if (currentTurn === undefined) return '' - - const now = Date.now() - const prior = states.get(agent) - if (prior !== undefined - && prior.turn === currentTurn.turn - && now >= prior.renderedAt - && now - prior.renderedAt < refreshIntervalMs) { - return prior.text - } - - const previous = prior?.turn === currentTurn.turn - ? prior.previousMessageTime - : previousMessageTime(agent, currentTurn.startSeq) - const text = renderText(now, previous, formatter, resolvedTimeZone) - states.set(agent, { turn: currentTurn.turn, renderedAt: now, previousMessageTime: previous, text }) - return text - }, - }) + ctx.on('agent/pre-step', ( + agent: Agent, + turn: number, + step: number, + _fullSystemPrompt: string, + _sessionPrefix: readonly Message[], + signal: AbortSignal, + ) => { + if (signal.aborted) return + const now = Date.now() + const previous = step === 1 + ? precedingMessageTime(agent) + : precedingStepContextTime(agent, turn) + agent.inject( + [{ type: 'text', text: renderText(now, turn, step, previous, formatter, resolvedTimeZone) }], + { source: { kind: 'plugin', plugin: name } }, + ) + }, { prepend: true }) } diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts index f83b451ef7..66966bb439 100644 --- a/packages/context/time-context/tests/time-context.e2e.ts +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { foldRequestHeader, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('../../../examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) @@ -12,7 +12,8 @@ const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.m const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PROCESS_TIMEOUT_MS = 30_000 const TEST_TIMEOUT_MS = PROCESS_TIMEOUT_MS + 15_000 -const FIRST_REPLY = 'You said: "first". Try "echo " to see a tool call.' +const FIRST_REPLY = '[main turn 1] You said: "Time recorded before turn 1, step 1:' +const SECOND_REPLY = '[main turn 2] You said: "Time recorded before turn 2, step 1:' let child: ChildProcessWithoutNullStreams | undefined let workdir: string | undefined @@ -60,7 +61,7 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { proc.stdout.setEncoding('utf8') proc.stdout.on('data', (chunk: string) => { stdout += chunk - if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) { + if (!sentSecond && stdout.includes(FIRST_REPLY) && stdout.includes('Try "echo " to see a tool call.\n> ')) { sentSecond = true proc.stdin.end('second\n') } @@ -84,12 +85,12 @@ async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { } describe('time-context through a real cordis.yml and stdio process', () => { - it('uses the process zone and persists both first-turn and elapsed-time request context', async () => { + it('uses the process zone and persists one ordered context event per request', async () => { const { stdout, stderr } = await runTwoTurns() expect(stderr).not.toContain('UNHANDLED') expect(stdout).toContain('time-context e2e ready.') expect(stdout).toContain(FIRST_REPLY) - expect(stdout).toContain('You said: "second".') + expect(stdout).toContain(SECOND_REPLY) const logs = await jsonlFiles(join(workdir as string, '.sessions')) expect(logs).toHaveLength(1) @@ -97,19 +98,29 @@ describe('time-context through a real cordis.yml and stdio process', () => { const events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - const firstHeader = events.find(event => event.type === 'request/header') - if (firstHeader?.type !== 'request/header') throw new Error('missing initial request/header event') - expect(firstHeader.data.header.system).toMatch( - /Current time: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, + const contexts = events.filter(event => event.type === 'context/message') + const starts = events.filter(event => event.type === 'step/start') + expect(contexts).toHaveLength(2) + expect(starts).toHaveLength(2) + for (let index = 0; index < contexts.length; index += 1) { + expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + expect(contexts[index]!.surfaceOp).toBe('append') + expect(contexts[index]!.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + } + const contextText = contexts.map(event => event.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n')) + expect(contextText[0]).toMatch( + /Time recorded before turn 1, step 1: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00\[Asia\/Shanghai\]/, ) - expect(firstHeader.data.header.system).toContain( - 'Time since previous message: unavailable (no earlier message in this session).', + expect(contextText[0]).toMatch( + /Elapsed since the preceding model-visible message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, ) + expect(contextText[1]).toMatch(/Time recorded before turn 2, step 1:/) - const finalSystem = foldRequestHeader(events)?.system - expect(finalSystem).toContain('[Asia/Shanghai]') - expect(finalSystem).toMatch( - /Time since previous message: (?:\d+d )?(?:\d+h )?(?:\d+m )?\d+s\./, - ) + const headers = events.filter(event => event.type === 'request/header' + || event.type === 'request/header-delta') + expect(JSON.stringify(headers)).not.toContain('Time recorded before') }, TEST_TIMEOUT_MS) }) diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 562b002b68..6df7957fb7 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -14,6 +14,7 @@ import type { Config } from '@deepseek-ai/dsh-time-context' const BASE = Date.parse('2026-07-14T00:00:00.000Z') const ORIGINAL_TIME_ZONE = process.env['TZ'] +const SIGNAL = new AbortController().signal beforeEach(() => { process.env['TZ'] = 'UTC' @@ -30,18 +31,29 @@ afterEach(() => { async function mount(config: Config = {}) { const ctx = new Context() - await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) const fiber = await ctx.plugin(timeContext, config) return { ctx, fiber } } function sessionAgent(session: Session, id = 'agent'): Agent { - return { id: AgentId(id), session } as unknown as Agent -} - -async function sectionText(ctx: Context, agent?: Agent): Promise { - const assembly = await ctx.systemPrompt.assemble(agent === undefined ? {} : { agent }) - return assembly.sections.find(section => section.name === 'context:time')?.text + return { + id: AgentId(id), + options: {}, + session, + status: 'running', + ctx: new Context(), + send() {}, + steer() {}, + inject(content, options) { + session.append('context/message', { + content, + source: options?.source ?? { kind: 'user' }, + }, { surfaceOp: 'append' }) + }, + cancel() {}, + whenIdle: () => Promise.resolve(), + } } function openMessageTurn(session: Session, turn: number): void { @@ -52,6 +64,22 @@ function openMessageTurn(session: Session, turn: number): void { }, { surfaceOp: 'append' }) } +function contextTexts(session: Session): string[] { + return session.events + .filter(event => event.type === 'context/message') + .map(event => event.data.content.find(block => block.type === 'text')?.text ?? '') +} + +async function fire( + ctx: Context, + agent: Agent, + turn: number, + step: number, + signal: AbortSignal = SIGNAL, +): Promise { + await ctx.serial('agent/pre-step', agent, turn, step, '', [], signal) +} + function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, @@ -100,173 +128,92 @@ async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promi return ctx } -describe('temporal section rendering', () => { - it('renders the first turn in UTC with the explicit no-previous-message fallback', async () => { - const { ctx } = await mount() +function requestText(request: GenerateOptions): string { + return request.messages + .flatMap(message => message.content) + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') +} + +describe('durable step context', () => { + it('records turn, step, zoned time, and the preceding model-visible message baseline', async () => { + const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) const session = new Session(SessionId('first')) openMessageTurn(session, 1) - - expect(await sectionText(ctx, sessionAgent(session))).toBe( - 'Current time: 2026-07-14T00:00:00+00:00[UTC]\n' - + 'Time since previous message: unavailable (no earlier message in this session).', - ) - }) - - it('renders a non-UTC numeric offset and all compact duration units', async () => { - const { ctx } = await mount({ timeZone: 'Asia/Shanghai' }) - const session = new Session(SessionId('offset')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'previous' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) vi.setSystemTime(BASE + 90_061_000) - openMessageTurn(session, 2) - expect(await sectionText(ctx, sessionAgent(session))).toBe( - 'Current time: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' - + 'Time since previous message: 1d 1h 1m 1s.', + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toEqual([ + 'Time recorded before turn 1, step 1: 2026-07-15T09:01:01+08:00[Asia/Shanghai]\n' + + 'Elapsed since the preceding model-visible message: 1d 1h 1m 1s.', + ]) + const event = session.events.at(-1) + expect(event?.type).toBe('context/message') + if (event?.type !== 'context/message') throw new Error('missing time context') + expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'time-context' }) + expect(event.surfaceOp).toBe('append') + }) + + it('reports an unavailable first-step baseline when no model-visible message precedes it', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('unavailable')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)[0]).toContain( + 'Elapsed since the preceding model-visible message: unavailable.', ) }) - it('clamps a backward wall-clock adjustment to a zero duration', async () => { + it('uses the preceding durable step-context timestamp after step one', async () => { const { ctx } = await mount() - const session = new Session(SessionId('backward-duration')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'future by adjusted clock' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const session = new Session(SessionId('later-step')) + const agent = sessionAgent(session) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + vi.setSystemTime(BASE + 61_000) + + await fire(ctx, agent, 3, 2) + + expect(contextTexts(session)[1]).toBe( + 'Time recorded before turn 3, step 2: 2026-07-14T00:01:01+00:00[UTC]\n' + + 'Elapsed since the preceding step context: 1m 1s.', + ) + }) + + it('clamps backward wall-clock movement against the preceding context to zero', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('backward')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) vi.setSystemTime(BASE - 5_000) - openMessageTurn(session, 2) - expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 0s.') + await fire(ctx, agent, 1, 2) + + expect(contextTexts(session)[1]).toContain('Elapsed since the preceding step context: 0s.') }) - const previousMessageCases = [ - ['user/message', (session: Session): void => { - session.append('user/message', { content: [{ type: 'text', text: 'u' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - }], - ['assistant/message', (session: Session): void => { - session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' }) - }], - ['tool/result', (session: Session): void => { - session.append('tool/result', { - turn: 1, - step: 1, - callId: CallId('previous'), - content: [{ type: 'text', text: 'r' }], - isError: false, - }, { surfaceOp: 'append' }) - }], - ['context/message', (session: Session): void => { - session.append('context/message', { - content: [{ type: 'text', text: 'c' }], - source: { kind: 'plugin', plugin: 'test' }, - }, { surfaceOp: 'append' }) - }], - ['steering/message', (session: Session): void => { - session.append('steering/message', { - turn: 1, - content: [{ type: 'text', text: 's' }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) - }], - ] as const - - it.each(previousMessageCases)('uses a prior %s as the duration baseline', async (_name, appendPrevious) => { + it('runs before ordinary pre-step listeners and skips an already-aborted step', async () => { const { ctx } = await mount() - const session = new Session(SessionId(`previous-${_name}`)) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - appendPrevious(session) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - vi.setSystemTime(BASE + 5_000) - openMessageTurn(session, 2) - - expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 5s.') - }) - - it('contributes empty text without an active agent turn', async () => { - const { ctx } = await mount() - expect(await sectionText(ctx)).toBe('') - - const empty = sessionAgent(new Session(SessionId('empty'))) - expect(await sectionText(ctx, empty)).toBe('') - - const closedSession = new Session(SessionId('closed')) - openMessageTurn(closedSession, 1) - closedSession.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(await sectionText(ctx, sessionAgent(closedSession))).toBe('') - }) -}) - -describe('refresh policy', () => { - it('reuses within the interval, refreshes at expiry, and refreshes after a backward clock jump', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const session = new Session(SessionId('interval')) + const session = new Session(SessionId('ordering')) const agent = sessionAgent(session) openMessageTurn(session, 1) + let ordinarySawContext = false + ctx.on('agent/pre-step', (subject) => { + ordinarySawContext = subject.session.events.some(event => event.type === 'context/message') + }) - const first = await sectionText(ctx, agent) - vi.setSystemTime(BASE + 30_000) - expect(await sectionText(ctx, agent)).toBe(first) - vi.setSystemTime(BASE + 60_000) - const expired = await sectionText(ctx, agent) - expect(expired).toContain('2026-07-14T00:01:00+00:00[UTC]') - vi.setSystemTime(BASE + 59_000) - expect(await sectionText(ctx, agent)).toContain('2026-07-14T00:00:59+00:00[UTC]') - }) + await fire(ctx, agent, 1, 1) + const abort = new AbortController() + abort.abort() + await fire(ctx, agent, 1, 2, abort.signal) - it('refreshes every assembly when refreshIntervalMs is zero', async () => { - const { ctx } = await mount({ refreshIntervalMs: 0 }) - const session = new Session(SessionId('every-step')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - const first = await sectionText(ctx, agent) - vi.setSystemTime(BASE + 1_000) - expect(await sectionText(ctx, agent)).not.toBe(first) - }) - - it('always refreshes for a new turn and keeps the preceding message baseline', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const session = new Session(SessionId('turn-refresh')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - const first = await sectionText(ctx, agent) - vi.setSystemTime(BASE + 1_000) - session.append('assistant/message', { - turn: 1, - step: 1, - content: [{ type: 'text', text: 'done' }], - }, { surfaceOp: 'append' }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - vi.setSystemTime(BASE + 2_000) - openMessageTurn(session, 2) - - const second = await sectionText(ctx, agent) - expect(second).not.toBe(first) - expect(second).toContain('Time since previous message: 1s.') - }) - - it('keeps refresh caches independent per agent', async () => { - const { ctx } = await mount({ refreshIntervalMs: 60_000 }) - const sessionA = new Session(SessionId('agent-a')) - const sessionB = new Session(SessionId('agent-b')) - const agentA = sessionAgent(sessionA, 'a') - const agentB = sessionAgent(sessionB, 'b') - openMessageTurn(sessionA, 1) - openMessageTurn(sessionB, 1) - const aFirst = await sectionText(ctx, agentA) - vi.setSystemTime(BASE + 30_000) - const bFirst = await sectionText(ctx, agentB) - vi.setSystemTime(BASE + 40_000) - - expect(await sectionText(ctx, agentA)).toBe(aFirst) - expect(bFirst).toContain('2026-07-14T00:00:30+00:00[UTC]') + expect(ordinarySawContext).toBe(true) + expect(contextTexts(session)).toHaveLength(1) }) }) @@ -278,49 +225,44 @@ describe('configuration and lifecycle', () => { const session = new Session(SessionId('system-zone')) openMessageTurn(session, 1) - expect(await sectionText(ctx, sessionAgent(session))).toContain( - 'Current time: 2026-07-14T08:00:00+08:00[Asia/Shanghai]', + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)[0]).toContain('2026-07-14T08:00:00+08:00[Asia/Shanghai]') + }) + + it('fails loud for an invalid explicit zone or an unavailable process zone', async () => { + const invalid = new Context() + await invalid.plugin(AgentRegistry) + await expect(invalid.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow( + /invalid IANA timeZone/, ) - }) - it('fails loud for negative, fractional, unsafe, and invalid-zone config', async () => { - for (const refreshIntervalMs of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await expect(ctx.plugin(timeContext, { refreshIntervalMs })).rejects.toThrow(/non-negative safe integer/) - } - - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await expect(ctx.plugin(timeContext, { timeZone: 'Not/A_Real_Zone' })).rejects.toThrow(/invalid IANA timeZone/) - }) - - it('fails loud when the process system zone cannot be resolved', async () => { vi.spyOn(Intl, 'DateTimeFormat').mockImplementationOnce(() => { throw new RangeError('system zone unavailable') }) - const ctx = new Context() - await ctx.plugin(SystemPrompt) - - await expect(ctx.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) + const unresolved = new Context() + await unresolved.plugin(AgentRegistry) + await expect(unresolved.plugin(timeContext, {})).rejects.toThrow(/failed to resolve the system time zone/) }) - it('removes its section when the plugin fiber disposes', async () => { + it('removes its listener when the plugin fiber disposes', async () => { const { ctx, fiber } = await mount() const session = new Session(SessionId('dispose')) const agent = sessionAgent(session) openMessageTurn(session, 1) - expect(await sectionText(ctx, agent)).toContain('Current time:') + await fire(ctx, agent, 1, 1) await fiber.dispose() - expect(await sectionText(ctx, agent)).toBeUndefined() + await fire(ctx, agent, 1, 2) + + expect(contextTexts(session)).toHaveLength(1) }) }) -describe('real agent-loop request logging', () => { - it('refreshes a long turn in the system prompt and records the header delta without context history', async () => { - const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done'), textResponse('next turn')]) - const ctx = await loopHarness(adapter, { refreshIntervalMs: 60_000 }) +describe('real agent-loop request history', () => { + it('persists one ordered context per request, accumulates readings, and leaves system headers unchanged', async () => { + const adapter = new ScriptedAdapter([toolCallResponse(), textResponse('done')]) + const ctx = await loopHarness(adapter) ctx.tools.register(defineTool({ name: 'tick', description: 'advance fake time', @@ -334,38 +276,55 @@ describe('real agent-loop request logging', () => { agent.send([{ type: 'text', text: 'start' }]) await agent.whenIdle() - expect(adapter.requests).toHaveLength(2) - expect(adapter.requests[0]!.system).toContain('2026-07-14T00:00:00+00:00[UTC]') - expect(adapter.requests[1]!.system).toContain('2026-07-14T00:01:01+00:00[UTC]') - expect(agent.session.events.some(event => event.type === 'context/message')).toBe(false) - expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(1) - expect(foldRequestHeader(agent.session.events)?.system).toBe(adapter.requests[1]!.system) - vi.setSystemTime(BASE + 361_000) - agent.send([{ type: 'text', text: 'again' }]) - await agent.whenIdle() - expect(adapter.requests[2]!.system).toContain('Time since previous message: 5m 0s.') + expect(adapter.requests).toHaveLength(2) + const contexts = agent.session.events.filter(event => event.type === 'context/message') + const starts = agent.session.events.filter(event => event.type === 'step/start') + expect(contexts).toHaveLength(adapter.requests.length) + expect(starts).toHaveLength(adapter.requests.length) + for (let index = 0; index < contexts.length; index += 1) { + expect(contexts[index]!.seq).toBeLessThan(starts[index]!.seq) + } + expect(contexts.every(event => event.data.source.kind === 'plugin' + && event.data.source.plugin === 'time-context' + && event.surfaceOp === 'append')).toBe(true) + + const firstRequestText = requestText(adapter.requests[0]!) + const secondRequestText = requestText(adapter.requests[1]!) + expect(firstRequestText).toContain('Time recorded before turn 1, step 1:') + expect(firstRequestText).toContain('Elapsed since the preceding model-visible message: 0s.') + expect(firstRequestText).not.toContain('Time recorded before turn 1, step 2:') + expect(secondRequestText).toContain('Time recorded before turn 1, step 1:') + expect(secondRequestText).toContain('Time recorded before turn 1, step 2:') + expect(secondRequestText).toContain('Elapsed since the preceding step context: 1m 1s.') + + for (const request of adapter.requests) expect(request.system).not.toContain('Time recorded before') + const headers = agent.session.events.filter(event => event.type === 'request/header' + || event.type === 'request/header-delta') + expect(JSON.stringify(headers)).not.toContain('Time recorded before') + expect(agent.session.events.filter(event => event.type === 'request/header-delta')).toHaveLength(0) await ctx.fiber.dispose() }) }) describe('real Loader export path', () => { - it('keeps the namespace metadata and boots through unwrapExports', async () => { + it('keeps namespace metadata and boots the agent listener through unwrapExports', async () => { expect('default' in timeContext).toBe(false) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeContext) as Record expect(unwrapped).toBe(timeContext) expect(unwrapped.name).toBe('time-context') - expect(unwrapped.inject).toEqual(['systemPrompt']) + expect(unwrapped.inject).toEqual(['agents']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') const ctx = new Context() - await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentRegistry) const plugin = loader.unwrapExports(timeContext) as Parameters[0] await ctx.plugin(plugin) const session = new Session(SessionId('loader')) openMessageTurn(session, 1) - expect(await sectionText(ctx, sessionAgent(session))).toContain('Current time:') + await fire(ctx, sessionAgent(session), 1, 1) + expect(contextTexts(session)[0]).toContain('Time recorded before turn 1, step 1:') }) }) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index eda3a81772..f8e14d8aa2 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -9,7 +9,7 @@ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, { "path": "../../../vendor/schemastery" }, - { "path": "../../core/system-prompt" }, + { "path": "../../llm/llm" }, { "path": "../../core/agent" } ] }