From a9d74932b12e05721cdfb66c25d3aaf2ae70fde9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:04:34 +0800 Subject: [PATCH 1/3] feat: add optional time context plugin --- AGENTS.md | 1 + docs/config-catalog.md | 16 + docs/module-graph.md | 6 + docs/rfc/INDEX.md | 1 + .../2026-07-14-time-context-plugin.i18n.yaml | 6 + .../feature/2026-07-14-time-context-plugin.md | 54 +++ .../2026-07-14-time-context-plugin.zh.md | 54 +++ packages/README.md | 3 +- packages/context/README.md | 7 + packages/context/time-context/README.md | 42 +++ packages/context/time-context/package.json | 41 ++ packages/context/time-context/src/index.ts | 189 ++++++++++ .../time-context/tests/time-context.spec.ts | 354 ++++++++++++++++++ packages/context/time-context/tsconfig.json | 15 + pnpm-lock.yaml | 28 ++ tsconfig.base.json | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 18 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 docs/rfc/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md create mode 100644 docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md create mode 100644 packages/context/README.md create mode 100644 packages/context/time-context/README.md create mode 100644 packages/context/time-context/package.json create mode 100644 packages/context/time-context/src/index.ts create mode 100644 packages/context/time-context/tests/time-context.spec.ts create mode 100644 packages/context/time-context/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 4dfbedb97b..07cc20f706 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend + context/ optional bounded model-context enrichments subagent/ subagent seam + spawn/fork/ACP backends + delegation tool workflow/ workflow seam + worker-thread engine + the workflow tool todo/ the todo_write tool diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f3765b9045..e07823c738 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -864,6 +864,22 @@ export interface Config { Source: [`packages/core/system-prompt/src/index.ts:227`](../packages/core/system-prompt/src/index.ts) +## `@deepseek-ai/dsh-time-context` + +Requires: `systemPrompt` + +```ts config-catalog +/** Configuration for the request-time clock section. */ +export interface Config { + /** IANA time zone used for the rendered timestamp (default `UTC`). */ + 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:28`](../packages/context/time-context/src/index.ts) + ## `@deepseek-ai/dsh-tool-cordis` Requires: `tools` diff --git a/docs/module-graph.md b/docs/module-graph.md index 7d6bf65c21..19d471f175 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -107,6 +107,9 @@ flowchart TD pkg_code_runtime["code-runtime"] pkg_code_runtime_worker["code-runtime-worker"] end + subgraph group_context["packages/context"] + pkg_time_context["time-context"] + end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] end @@ -183,6 +186,8 @@ flowchart TD pkg_user_approval --> pkg_system_prompt pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_llm + pkg_time_context --> pkg_agent + pkg_time_context --> pkg_system_prompt pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm @@ -375,6 +380,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) | | [`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) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c99226a264..b2268571d5 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -78,6 +78,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [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 | ### 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 new file mode 100644 index 0000000000..5ca24553a5 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.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-14-time-context-plugin.md: cab55c78c7f649db63d3049f9a1dfa8e0a6673e9 +2026-07-14-time-context-plugin.zh.md: a4644d8484c8c6f906a1123502c7530f09bcb1d8 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 new file mode 100644 index 0000000000..cab55c78c7 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md @@ -0,0 +1,54 @@ +# RFC: Optional time-context plugin + +Status: implemented + +English | [中文](2026-07-14-time-context-plugin.zh.md) + +## Problem + +An agent request has no live clock unless a deployment hard-codes one into prompt text or gives the model a tool to query it. Static text becomes false immediately, while a tool call is unnecessary overhead for ordinary reasoning about dates, deadlines, or how long a conversation has been idle. The missing companion fact is elapsed time: the model receives the current user prompt but cannot distinguish a quick follow-up from one sent hours after the preceding conversation message. + +The prompt assembly and session log already provide the necessary inputs. A section provider runs once per step with the active agent, model-visible session events carry durable append timestamps, and the request-header fold records the exact rendered system prompt. The design question is where temporal facts belong and how often they change without accumulating stale readings or creating background work. + +## Decision + +`@deepseek-ai/dsh-time-context` is an optional function plugin at `packages/context/time-context/`. It opens the `context/` product group for bounded request-context enrichments that define neither a tool nor a service seam. The package is not loaded by `dsh-agent-core` or a shipped example; a deployment mounts it explicitly when temporal context is worth the tokens and disclosure. + +The plugin registers one global `ctx.systemPrompt.section()` contribution named `context:time` at order 10, after the deployment persona and before tool guidance. Its provider returns two lines for an active agent turn: an ISO-shaped timestamp with numeric UTC offset and IANA zone, and a compact whole-second duration since the last model-visible message before that turn opened. A bare or idle prompt assembly receives an empty section. + +### Previous-message baseline + +At a turn's first assembly, the provider scans backward from that turn's `turn/start` and uses the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message` timestamp. It deliberately excludes the current turn's newly appended user prompt: measuring from that event would make the first request report approximately zero and lose the inter-turn gap the feature exists to convey. Every later refresh in the same turn retains the baseline, so a long-running turn reports the growing duration since the preceding conversation message. The first turn reports `unavailable (no earlier message in this session)`. + +The baseline is the session event's append time, not an unlogged client receipt time. That makes resume and fork behavior deterministic from the durable log and keeps the model-visible value reconstructable without introducing a new event. A backward wall-clock adjustment clamps the displayed duration to zero rather than producing a negative interval. + +### Refresh policy + +`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes regardless of the prior turn's timestamp. Within a multi-step turn, a later assembly reuses the cached block until its age reaches the interval; `0` refreshes every step. The policy is request-bound: no timer creates work while the agent is inside a model call, running a tool, or idle, because no request exists to consume a new value. + +`timeZone` defaults to `UTC` and is validated as an IANA identifier at plugin load. The formatter emits an ISO-shaped local timestamp including the resolved zone and its current numeric offset, so daylight-saving changes remain explicit instead of silently shifting a zone-less clock. + +### Logging and token shape + +The temporal block is dynamic system-prompt state. The loop's existing `request/header` snapshot and `request/header-delta` fold records every rendered change before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). A request carries exactly one current block; previous readings do not remain in derived conversation history. This follows the ownership rule in the [prompt-variables RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md): the optional plugin owns the temporal fact and contributes it through the ordinary prompt registry, with no loop special case. + +## Testing + +The package suite uses fake system time and covers UTC and offset formatting, first-turn fallback, every eligible previous-message variant, whole-duration units, backward-clock clamping, per-turn refresh, interval reuse, interval expiry, `0` per-step behavior, independent per-agent caches, invalid config, HMR disposal, and the Loader namespace path. A real agent-loop test pins the transmitted system prompt and its `request/header-delta` refresh record. No default snapshot changes because the plugin is intentionally absent from every shipped composition; mounting it in a default snapshot fixture would violate the opt-in decision. + +## Alternatives considered + +- **Append a `context/message` on every turn or refresh** — rejected: each reading remains in derived history, so stale clock values and token cost accumulate with conversation length. A surface replacement cannot both remove the old node and move the new reading to the tail; replacement preserves the old node's position, while replacing through the tail would hide intervening conversation. +- **Use `agent/session-prefix`** — rejected: the prefix is composed once per loop instance and is intentionally session-stable, so it cannot represent a clock that changes per turn or step. +- **Mutate requests in `agent/request`** — rejected: that seam shapes call config only, fires after the message boundary, and model-visible content inserted there would bypass both prompt-pressure accounting and the logged-header contract. +- **Register separate `{{current_time}}` and `{{elapsed}}` prompt variables** — rejected: independent providers can sample different instants and need shared caching to keep refresh semantics atomic. One section provider computes and records the pair as one value; deployments do not need to repeat a temporal template in their persona. +- **Inject from a background timer at the configured interval** — rejected: while no model request is being assembled, a fresh value has no consumer. Timer-driven `agent.inject()` would create durable one-shot turns and wake or mutate idle sessions merely to announce time passing. +- **Mount the plugin in `dsh-agent-core`** — rejected: time zone, disclosure, token budget, and desired freshness are deployment policy. Explicit opt-in keeps the default harness context stable. +- **Place the package in `core/`** — rejected: core owns the product API spine. A context enrichment is an optional leaf with no service key, so the dedicated group states its composition role directly. + +## Consequences + +- Models in opted-in deployments receive an unambiguous zoned clock and an inter-turn elapsed duration without spending a tool call. The system-prompt token cost is fixed per request instead of growing with the session. +- A refresh changes the request header and therefore adds a `request/header-delta` event. `refreshIntervalMs` trades clock freshness against those durable deltas; setting it to zero intentionally records a new value on every step whose whole-second rendering changed. +- No request is created solely to refresh time. A tool that runs longer than the interval leaves the prior reading in place until the next step assembles, when the provider catches up. +- The duration reflects harness processing time at durable append boundaries, not client-network latency before the message entered the log. Preserving a client-origin timestamp would require a separate durable input contract and is outside this plugin. 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 new file mode 100644 index 0000000000..a4644d8484 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-14-time-context-plugin.zh.md @@ -0,0 +1,54 @@ +# RFC:可选时间上下文插件 + +Status: implemented + +[English](2026-07-14-time-context-plugin.md) | 中文 + +## 问题 + +如果部署方既没有将当前时间硬编码到提示词中,也没有向模型提供查询时间的工具,agent(智能体)请求就无法获得实时准确的时钟信息。静态文本会立即失真,而对于日期、截止时间或会话闲置时长等常规推理,调用工具会带来不必要的开销。模型还缺少另一项配套信息:已经过去的时长。模型虽然能收到当前用户提示词,却无法区分紧接着发送的消息与上一条会话消息几小时后才发送的消息。 + +提示词组装流程和会话日志已经提供所需输入。区段提供方会在每个步骤中针对活跃 agent 运行一次;模型可见的会话事件带有持久的追加时间戳;请求头折叠结果会记录系统提示词实际渲染的确切内容。设计需要决定时间信息应归属何处、应以多高频率变化,同时避免累积陈旧读数或创建后台任务。 + +## 决策 + +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/` 的可选函数式插件。它新增 `context/` 产品分组,用于容纳既不定义工具、也不定义服务边界的有界请求上下文增强。`dsh-agent-core` 和仓库提供的任何示例都不会加载该 package;只有当时间上下文值得占用 token 和披露信息时,部署方才显式挂载它。 + +该插件注册一个名为 `context:time`、顺序值为 10 的全局 `ctx.systemPrompt.section()` 贡献,位置在部署方角色设定之后、工具指导之前。对于处于活跃轮次中的 agent,其提供方返回两行内容:一行是带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳;另一行是从该轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,提示词组装结果中的该区段为空。 + +### 上一条消息基线 + +在轮次首次组装时,提供方从该轮次的 `turn/start` 向前扫描,采用最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message` 时间戳。当前轮次中新追加的用户提示词会被刻意排除:若从该事件开始计时,首次请求会报告接近零的时长,从而丢失此功能要表达的轮次间隔。同一轮次内的后续刷新始终保留这条基线,因此长时间运行的轮次会报告从上一条会话消息起不断增加的时长。首个轮次报告 `unavailable (no earlier message in this session)`。 + +基线采用会话事件的追加时间,而不是日志中不存在的客户端接收时间。这样,恢复和 fork 行为都能从持久日志中确定性重现,模型可见值也无需新增事件即可重建。如果系统挂钟向后调整,插件会将显示时长钳制为零,而不会产生负数间隔。 + +### 刷新策略 + +`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都强制刷新,不受上一轮次时间戳影响。在包含多个步骤的轮次内,后续组装会复用缓存区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。该策略仅由请求驱动:agent 正在等待模型调用、运行工具或处于空闲状态时,没有请求会消费新值,因此计时器不会创建任何任务。 + +`timeZone` 默认为 `UTC`,插件加载时会校验它是否为 IANA 标识符。格式化器会输出形似 ISO 的本地时间戳,其中包含已解析的时区及其当前数字偏移,使夏令时变化保持显式可见,而不是让不带时区的时钟在无提示的情况下发生偏移。 + +### 日志与 token 形态 + +时间区块属于动态系统提示词状态。agent loop(智能体循环)现有的 `request/header` 快照和 `request/header-delta` 折叠结果会在发送前记录每次渲染变化,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在派生的会话历史中。该设计遵循[提示词变量 RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)的所有权规则:可选插件拥有时间信息,并通过常规提示词注册表贡献该信息,不为循环添加特殊分支。 + +## 测试 + +该 package 的测试套件使用伪造的系统时间,覆盖 UTC 与偏移格式化、首轮次回退文本、所有符合条件的上一条消息类型、完整时长单位、挂钟回拨钳制、逐轮次刷新、间隔内复用、间隔到期、`0` 对应的逐步骤行为、相互独立的逐 agent 缓存、无效配置、HMR(热模块替换)资源释放以及 Loader 命名空间路径。一个使用真实 agent loop 的测试会固定实际发送的系统提示词及其 `request/header-delta` 刷新记录。默认快照不发生变化,因为所有仓库提供的组合都刻意不包含该插件;若在默认快照 fixture 中挂载它,将违反显式选择加入的决策。 + +## 考虑过的替代方案 + +- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳:每个读数都会留在派生历史中,因此陈旧时钟值和 token 成本会随会话长度累积。表层替换操作无法同时删除旧节点并将新读数移动到尾部;替换会保留旧节点的位置,而通过尾部节点替换又会隐藏中间的会话内容。 +- **使用 `agent/session-prefix`**——不予采纳:前缀在每个循环实例中只组装一次,并且按设计在会话期间保持稳定,因此无法表示每个轮次或步骤都会变化的时钟。 +- **在 `agent/request` 中修改请求**——不予采纳:该边界只负责塑造调用配置,触发时间晚于消息边界;如果在此处插入模型可见内容,会同时绕过提示词压力核算和请求头日志契约。 +- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 提示词变量**——不予采纳:两个独立提供方可能在不同时间点采样,并且需要共享缓存才能保证刷新语义的原子性。单个区段提供方将二者作为一个值计算和记录;部署方也不需要在角色设定中重复时间模板。 +- **按照配置的间隔通过后台计时器注入**——不予采纳:没有正在组装的模型请求时,新值没有消费方。由计时器驱动 `agent.inject()` 会创建持久的一次性轮次,并且只为通知时间流逝就唤醒或修改空闲会话。 +- **在 `dsh-agent-core` 中挂载插件**——不予采纳:时区、信息披露、token 预算和期望新鲜度都属于部署策略。显式选择加入能保持默认 harness 上下文稳定。 +- **将 package 放入 `core/`**——不予采纳:core 负责产品 API 主干。上下文增强是没有服务键的可选叶节点,因此专用分组能直接表达其组合角色。 + +## 后果 + +- 选择加入的部署无需消耗工具调用,即可让模型获得无歧义的分区时钟和轮次间隔时长。每个请求的系统提示词 token 成本固定,不会随会话增长。 +- 刷新会改变请求头,因此会新增一条 `request/header-delta` 事件。`refreshIntervalMs` 用时钟新鲜度换取这些持久增量记录的数量;将其设为零会在每个整秒渲染结果发生变化的步骤中刻意记录新值。 +- 系统不会仅为刷新时间而创建请求。工具运行时间超过该间隔时,先前读数会保持不变,直至下一步骤开始组装,此时提供方会追赶到当前时间。 +- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约,不属于本插件范围。 diff --git a/packages/README.md b/packages/README.md index 13da4b50da..63d17a5718 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,6 @@ # Packages -Harness packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis plugin: a default `Service` subclass or functional plugin declaring ctx keys/events through declaration merging and contributing through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) and root [AGENTS.md](../AGENTS.md) § Conventions. +Packages use the `@deepseek-ai/dsh-*` scope. Each is a Cordis `Service` subclass or function plugin; contributions use `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). ## Hierarchy @@ -16,6 +16,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | | [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`context/`](context/README.md) | Opt-in request-context enrichment | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface | | [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | diff --git a/packages/context/README.md b/packages/context/README.md new file mode 100644 index 0000000000..00755fce26 --- /dev/null +++ b/packages/context/README.md @@ -0,0 +1,7 @@ +# context/ — optional request context + +Product plugins that add bounded model-visible request context without defining a tool or service seam. They are opt-in deployment leaves and are not part of the default `dsh-agent-core` bundle. + +| Package | Role | ctx key | +|---|---|---| +| `time-context/` | Dynamic current time and elapsed-since-previous-message system-prompt section | (none) | diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md new file mode 100644 index 0000000000..3d9ffdd656 --- /dev/null +++ b/packages/context/time-context/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-time-context + +Optional temporal request context. The plugin contributes one dynamic system-prompt section with the current zoned time and the elapsed duration since the last model-visible message before the current turn. It is not mounted by `dsh-agent-core` or any shipped example; deployments opt in explicitly. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). + +## Config + +```yaml +- id: time-context + name: '@deepseek-ai/dsh-time-context' + config: + timeZone: UTC # default; any IANA time-zone identifier + refreshIntervalMs: 60000 # default; 0 refreshes on every step +``` + +`timeZone` is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer and is evaluated only when a request is assembled: every turn's first request gets a fresh reading, and a later step in the same turn reuses that reading until it is at least this old. Thus `0` means per-step refresh, while a positive value bounds staleness at request boundaries without creating timer-driven turns. + +## Message baseline + +The duration starts at the latest model-visible session event before the current `turn/start`: a user, assistant, tool-result, context, or steering message. All later refreshes in that turn retain the same baseline, so the value measures elapsed time since the preceding conversation message rather than collapsing to approximately zero after the current prompt is appended. The first turn reports that no earlier message exists. Session event append time is the durable clock source; client-side send time is not part of the session contract. + +The plugin uses a dynamic system-prompt section rather than retained `context/message` history. The loop records the exact rendered value in `request/header` / `request/header-delta`, so requests remain reconstructable while the current request carries only one timing block. + +## Model Experience + +### Temporal system prompt + +**What the model sees**: Every request in an active turn includes the two-line section below. `` is an ISO-shaped local timestamp with numeric offset and IANA zone; `` is compact whole-second units or the first-turn fallback. + +**Token effect**: Fixed two-line request context. A refresh replaces the section in the request header rather than retaining prior readings in conversation history. + +#### Temporal context section + +```markdown +Current time: +Time since previous message: . +``` + +## 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. diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json new file mode 100644 index 0000000000..202976ccf8 --- /dev/null +++ b/packages/context/time-context/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-time-context", + "description": "Optional dynamic system-prompt context with the current time and elapsed duration since the previous message", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts new file mode 100644 index 0000000000..9162ef8614 --- /dev/null +++ b/packages/context/time-context/src/index.ts @@ -0,0 +1,189 @@ +/** + * Optional temporal context for model requests. The plugin contributes one + * dynamic system-prompt section that reports the current zoned time and the + * elapsed duration since the last model-visible message before the current + * turn. A turn always gets a fresh reading on its first request; later steps + * refresh only when the configured maximum age is reached. + * + * The section is request state, not retained conversation history. The agent + * loop records each rendered value through its existing `request/header` or + * `request/header-delta` event, preserving the model-visible/logged invariant + * without accumulating stale `context/message` entries. + * + * @module @deepseek-ai/dsh-time-context + */ + +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' + +/** 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'] + +/** Configuration for the request-time clock section. */ +export interface Config { + /** IANA time zone used for the rendered timestamp (default `UTC`). */ + 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}. */ +export const Config: z = z.object({ + timeZone: z.string().default('UTC'), + refreshIntervalMs: z.number().default(60_000), +}) + +/** The open turn currently being assembled, including its log boundary. */ +interface OpenTurn { + turn: number + startSeq: number +} + +/** One agent's last rendered block and its fixed previous-turn baseline. */ +interface RenderState { + turn: number + renderedAt: number + previousMessageTime: number | undefined + text: string +} + +/** Date-time fields required from the fixed formatter below. */ +type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' + +/** Find the open turn at the tail of an agent's balanced session log. */ +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 +} + +/** Timestamp of the last model-visible message before one turn opened. */ +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( + formatter.formatToParts(now).map(part => [part.type, part.value]), + ) as Record + const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3) + return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]` +} + +/** Format a non-negative elapsed millisecond count as compact whole-second units. */ +function formatDuration(elapsedMs: number): string { + let seconds = Math.floor(Math.max(0, elapsedMs) / 1000) + const days = Math.floor(seconds / 86_400) + seconds %= 86_400 + const hours = Math.floor(seconds / 3600) + seconds %= 3600 + const minutes = Math.floor(seconds / 60) + seconds %= 60 + const parts: string[] = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0) parts.push(`${minutes}m`) + parts.push(`${seconds}s`) + return parts.join(' ') +} + +/** Build the exact two-line model-facing section. */ +function renderText( + now: 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}.` +} + +/** + * Register the dynamic temporal system-prompt section. + * @param ctx - plugin context; the section registration is disposed with it. + * @param config - validated time zone and intra-turn refresh interval. + */ +export function apply(ctx: Context, config: Config): void { + const timeZone = config.timeZone as string + 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', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + timeZoneName: 'longOffset', + }) + } catch (error: unknown) { + throw new Error(`time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`, { 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 + }, + }) +} diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts new file mode 100644 index 0000000000..c035a0594f --- /dev/null +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -0,0 +1,354 @@ +/** Unit, loop-integration, lifecycle, and real-Loader coverage for dsh-time-context. */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import * as timeContext from '@deepseek-ai/dsh-time-context' +import type { Config } from '@deepseek-ai/dsh-time-context' + +const BASE = Date.parse('2026-07-14T00:00:00.000Z') + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(BASE) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** Mount the system-prompt service and the optional plugin. */ +async function mount(config: Config = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + const fiber = await ctx.plugin(timeContext, config) + return { ctx, fiber } +} + +/** Minimal agent-shaped holder over a real append-only Session. */ +function sessionAgent(session: Session, id = 'agent'): Agent { + return { id: AgentId(id), session } as unknown as Agent +} + +/** Resolve only this plugin's assembled section text. */ +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 +} + +/** Append the prompt side of an open message turn. */ +function openMessageTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +/** Script helper for a text-only model response. */ +function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** Script helper for one tool-call response. */ +function toolCallResponse(): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: CallId('tick-1'), name: 'tick', arguments: '{}' }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} + +/** Deterministic adapter that records each request and consumes one chunk script. */ +class ScriptedAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + + constructor(private readonly script: StreamChunk[][]) { + super() + } + + override async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const chunks = this.script.shift() + if (chunks === undefined) throw new Error('ScriptedAdapter: script exhausted') + for (const chunk of chunks) yield chunk + } +} + +/** Mount the real loop spine plus this optional plugin. */ +async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(timeContext, config) + ctx.llm.registerAdapter(['mock'], adapter) + 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() + 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.', + ) + }) + + it('clamps a backward wall-clock adjustment to a zero duration', 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' } }) + vi.setSystemTime(BASE - 5_000) + openMessageTurn(session, 2) + + expect(await sectionText(ctx, sessionAgent(session))).toContain('Time since previous message: 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) => { + 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 agent = sessionAgent(session) + openMessageTurn(session, 1) + + 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]') + }) + + 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]') + }) +}) + +describe('configuration and lifecycle', () => { + 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('removes its section 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 fiber.dispose() + expect(await sectionText(ctx, agent)).toBeUndefined() + }) +}) + +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 }) + ctx.tools.register(defineTool({ + name: 'tick', + description: 'advance fake time', + parameters: {}, + async execute() { + vi.setSystemTime(BASE + 61_000) + return [{ type: 'text' as const, text: 'advanced' }] + }, + })) + const agent = ctx.agentLoop.create(AgentId('loop'), { model: 'mock' }) + + 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.') + await ctx.fiber.dispose() + }) +}) + +describe('real Loader export path', () => { + it('keeps the namespace metadata and boots 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.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + + const ctx = new Context() + await ctx.plugin(SystemPrompt) + 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:') + }) +}) diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json new file mode 100644 index 0000000000..eda3a81772 --- /dev/null +++ b/packages/context/time-context/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/system-prompt" }, + { "path": "../../core/agent" } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6b0d4519c..650addb80a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,34 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/context/time-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/cordis/tool-cordis: dependencies: schemastery: diff --git a/tsconfig.base.json b/tsconfig.base.json index c6dff7c732..29f954f68f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -47,6 +47,7 @@ "./packages/fs/*/src", "./packages/skill/*/src", "./packages/compact/*/src", + "./packages/context/*/src", "./packages/guard/*/src", "./packages/subagent/*/src", "./packages/workflow/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 6deb2c6e01..591955b260 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -21,6 +21,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, { "path": "./packages/ui/permission" }, diff --git a/tsconfig.json b/tsconfig.json index dd283ec5d7..e97a8295a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, { "path": "./packages/ui/permission" }, From d9d9487e0e000b992f07edf8aad01cf78c960826 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:44:32 +0800 Subject: [PATCH 2/3] docs: align time-context prose standard --- AGENTS.md | 4 +- docs/config-catalog.md | 4 +- .../2026-07-14-time-context-plugin.i18n.yaml | 4 +- .../feature/2026-07-14-time-context-plugin.md | 42 +++++++++---------- .../2026-07-14-time-context-plugin.zh.md | 42 +++++++++---------- packages/context/README.md | 4 +- packages/context/time-context/README.md | 12 +++--- packages/context/time-context/package.json | 2 +- packages/context/time-context/src/index.ts | 27 ++++-------- .../time-context/tests/time-context.spec.ts | 10 ----- 10 files changed, 66 insertions(+), 85 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 10ed41baca..44aa15aea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai skill/ skill provider registry + local impl + catalog/loader tool web/ web seam + search/fetch providers + model-facing web tools compact/ compaction seam + basic backend - context/ optional bounded model-context enrichments + context/ request-context plugins subagent/ subagent seam + spawn/fork/ACP backends + delegation tool workflow/ workflow seam + worker-thread engine + the workflow tool todo/ the todo_write tool @@ -35,7 +35,7 @@ docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see scripts/ repo gates and generators ``` -Per-package map: the group READMEs, indexed from [packages/README.md](packages/README.md). +Package groups: [packages/README.md](packages/README.md). ## Commands diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 615de5051f..650fe16d23 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -785,7 +785,7 @@ Source: [`packages/core/system-prompt/src/index.ts:147`](../packages/core/system Requires: `systemPrompt` ```ts config-catalog -/** Configuration for the request-time clock section. */ +/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp (default `UTC`). */ timeZone?: string @@ -794,7 +794,7 @@ export interface Config { } ``` -Source: [`packages/context/time-context/src/index.ts:28`](../packages/context/time-context/src/index.ts) +Source: [`packages/context/time-context/src/index.ts:22`](../packages/context/time-context/src/index.ts) ## `@deepseek-ai/dsh-tool-cordis` 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 5ca24553a5..10b441f03b 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: cab55c78c7f649db63d3049f9a1dfa8e0a6673e9 -2026-07-14-time-context-plugin.zh.md: a4644d8484c8c6f906a1123502c7530f09bcb1d8 +2026-07-14-time-context-plugin.md: 54ed3188c794eb088db47b653ea0e27db1c14ed8 +2026-07-14-time-context-plugin.zh.md: fa0de3c6460eb6ab508b53e1ca3acf99564b1926 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 cab55c78c7..54ed3188c7 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,49 +6,49 @@ English | [中文](2026-07-14-time-context-plugin.zh.md) ## Problem -An agent request has no live clock unless a deployment hard-codes one into prompt text or gives the model a tool to query it. Static text becomes false immediately, while a tool call is unnecessary overhead for ordinary reasoning about dates, deadlines, or how long a conversation has been idle. The missing companion fact is elapsed time: the model receives the current user prompt but cannot distinguish a quick follow-up from one sent hours after the preceding conversation message. +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. -The prompt assembly and session log already provide the necessary inputs. A section provider runs once per step with the active agent, model-visible session events carry durable append timestamps, and the request-header fold records the exact rendered system prompt. The design question is where temporal facts belong and how often they change without accumulating stale readings or creating background work. +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. ## Decision -`@deepseek-ai/dsh-time-context` is an optional function plugin at `packages/context/time-context/`. It opens the `context/` product group for bounded request-context enrichments that define neither a tool nor a service seam. The package is not loaded by `dsh-agent-core` or a shipped example; a deployment mounts it explicitly when temporal context is worth the tokens and disclosure. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-core` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. -The plugin registers one global `ctx.systemPrompt.section()` contribution named `context:time` at order 10, after the deployment persona and before tool guidance. Its provider returns two lines for an active agent turn: an ISO-shaped timestamp with numeric UTC offset and IANA zone, and a compact whole-second duration since the last model-visible message before that turn opened. A bare or idle prompt assembly receives an empty section. +The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section. ### Previous-message baseline -At a turn's first assembly, the provider scans backward from that turn's `turn/start` and uses the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message` timestamp. It deliberately excludes the current turn's newly appended user prompt: measuring from that event would make the first request report approximately zero and lose the inter-turn gap the feature exists to convey. Every later refresh in the same turn retains the baseline, so a long-running turn reports the growing duration since the preceding conversation message. The first turn reports `unavailable (no earlier message in this session)`. +At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`. -The baseline is the session event's append time, not an unlogged client receipt time. That makes resume and fork behavior deterministic from the durable log and keeps the model-visible value reconstructable without introducing a new event. A backward wall-clock adjustment clamps the displayed duration to zero rather than producing a negative interval. +The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero. ### Refresh policy -`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes regardless of the prior turn's timestamp. Within a multi-step turn, a later assembly reuses the cached block until its age reaches the interval; `0` refreshes every step. The policy is request-bound: no timer creates work while the agent is inside a model call, running a tool, or idle, because no request exists to consume a new value. +`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound. -`timeZone` defaults to `UTC` and is validated as an IANA identifier at plugin load. The formatter emits an ISO-shaped local timestamp including the resolved zone and its current numeric offset, so daylight-saving changes remain explicit instead of silently shifting a zone-less clock. +`timeZone` defaults to `UTC` and is validated as an IANA identifier at plugin load. The ISO-shaped local timestamp includes the resolved zone and current numeric offset, making daylight-saving changes explicit. ### Logging and token shape -The temporal block is dynamic system-prompt state. The loop's existing `request/header` snapshot and `request/header-delta` fold records every rendered change before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). A request carries exactly one current block; previous readings do not remain in derived conversation history. This follows the ownership rule in the [prompt-variables RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md): the optional plugin owns the temporal fact and contributes it through the ordinary prompt registry, with no loop special case. +The loop records the temporal block through `request/header` and `request/header-delta` before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case. ## Testing -The package suite uses fake system time and covers UTC and offset formatting, first-turn fallback, every eligible previous-message variant, whole-duration units, backward-clock clamping, per-turn refresh, interval reuse, interval expiry, `0` per-step behavior, independent per-agent caches, invalid config, HMR disposal, and the Loader namespace path. A real agent-loop test pins the transmitted system prompt and its `request/header-delta` refresh record. No default snapshot changes because the plugin is intentionally absent from every shipped composition; mounting it in a default snapshot fixture would violate the opt-in decision. +Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, and disposal. A real agent-loop test pins the transmitted prompt and `request/header-delta`; a Loader test pins the named-export path. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. ## Alternatives considered -- **Append a `context/message` on every turn or refresh** — rejected: each reading remains in derived history, so stale clock values and token cost accumulate with conversation length. A surface replacement cannot both remove the old node and move the new reading to the tail; replacement preserves the old node's position, while replacing through the tail would hide intervening conversation. -- **Use `agent/session-prefix`** — rejected: the prefix is composed once per loop instance and is intentionally session-stable, so it cannot represent a clock that changes per turn or step. -- **Mutate requests in `agent/request`** — rejected: that seam shapes call config only, fires after the message boundary, and model-visible content inserted there would bypass both prompt-pressure accounting and the logged-header contract. -- **Register separate `{{current_time}}` and `{{elapsed}}` prompt variables** — rejected: independent providers can sample different instants and need shared caching to keep refresh semantics atomic. One section provider computes and records the pair as one value; deployments do not need to repeat a temporal template in their persona. -- **Inject from a background timer at the configured interval** — rejected: while no model request is being assembled, a fresh value has no consumer. Timer-driven `agent.inject()` would create durable one-shot turns and wake or mutate idle sessions merely to announce time passing. -- **Mount the plugin in `dsh-agent-core`** — rejected: time zone, disclosure, token budget, and desired freshness are deployment policy. Explicit opt-in keeps the default harness context stable. -- **Place the package in `core/`** — rejected: core owns the product API spine. A context enrichment is an optional leaf with no service key, so the dedicated group states its composition role directly. +- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation. +- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock. +- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging. +- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template. +- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. +- **Mount the plugin in `dsh-agent-core`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. +- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. ## Consequences -- Models in opted-in deployments receive an unambiguous zoned clock and an inter-turn elapsed duration without spending a tool call. The system-prompt token cost is fixed per request instead of growing with the session. -- A refresh changes the request header and therefore adds a `request/header-delta` event. `refreshIntervalMs` trades clock freshness against those durable deltas; setting it to zero intentionally records a new value on every step whose whole-second rendering changed. -- No request is created solely to refresh time. A tool that runs longer than the interval leaves the prior reading in place until the next step assembles, when the provider catches up. -- The duration reflects harness processing time at durable append boundaries, not client-network latency before the message entered the log. Preserving a client-origin timestamp would require a separate durable input contract and is outside this plugin. +- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session. +- A refresh changes the request header and can add a `request/header-delta`. `refreshIntervalMs` trades freshness against durable deltas; `0` records a new value on every step whose whole-second rendering changes. +- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles. +- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract. 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 a4644d8484..fa0de3c646 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,49 +6,49 @@ Status: implemented ## 问题 -如果部署方既没有将当前时间硬编码到提示词中,也没有向模型提供查询时间的工具,agent(智能体)请求就无法获得实时准确的时钟信息。静态文本会立即失真,而对于日期、截止时间或会话闲置时长等常规推理,调用工具会带来不必要的开销。模型还缺少另一项配套信息:已经过去的时长。模型虽然能收到当前用户提示词,却无法区分紧接着发送的消息与上一条会话消息几小时后才发送的消息。 +如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 -提示词组装流程和会话日志已经提供所需输入。区段提供方会在每个步骤中针对活跃 agent 运行一次;模型可见的会话事件带有持久的追加时间戳;请求头折叠结果会记录系统提示词实际渲染的确切内容。设计需要决定时间信息应归属何处、应以多高频率变化,同时避免累积陈旧读数或创建后台任务。 +提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/` 的可选函数式插件。它新增 `context/` 产品分组,用于容纳既不定义工具、也不定义服务边界的有界请求上下文增强。`dsh-agent-core` 和仓库提供的任何示例都不会加载该 package;只有当时间上下文值得占用 token 和披露信息时,部署方才显式挂载它。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-core` 和仓库提供的示例都不会加载该 package;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 -该插件注册一个名为 `context:time`、顺序值为 10 的全局 `ctx.systemPrompt.section()` 贡献,位置在部署方角色设定之后、工具指导之前。对于处于活跃轮次中的 agent,其提供方返回两行内容:一行是带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳;另一行是从该轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,提示词组装结果中的该区段为空。 +该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 ### 上一条消息基线 -在轮次首次组装时,提供方从该轮次的 `turn/start` 向前扫描,采用最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message` 时间戳。当前轮次中新追加的用户提示词会被刻意排除:若从该事件开始计时,首次请求会报告接近零的时长,从而丢失此功能要表达的轮次间隔。同一轮次内的后续刷新始终保留这条基线,因此长时间运行的轮次会报告从上一条会话消息起不断增加的时长。首个轮次报告 `unavailable (no earlier message in this session)`。 +在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。 -基线采用会话事件的追加时间,而不是日志中不存在的客户端接收时间。这样,恢复和 fork 行为都能从持久日志中确定性重现,模型可见值也无需新增事件即可重建。如果系统挂钟向后调整,插件会将显示时长钳制为零,而不会产生负数间隔。 +基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。 ### 刷新策略 -`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都强制刷新,不受上一轮次时间戳影响。在包含多个步骤的轮次内,后续组装会复用缓存区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。该策略仅由请求驱动:agent 正在等待模型调用、运行工具或处于空闲状态时,没有请求会消费新值,因此计时器不会创建任何任务。 +`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。 -`timeZone` 默认为 `UTC`,插件加载时会校验它是否为 IANA 标识符。格式化器会输出形似 ISO 的本地时间戳,其中包含已解析的时区及其当前数字偏移,使夏令时变化保持显式可见,而不是让不带时区的时钟在无提示的情况下发生偏移。 +`timeZone` 默认为 `UTC`,插件加载时会校验它是否为 IANA 标识符。形似 ISO 的本地时间戳包含已解析的时区及其当前数字偏移,使夏令时变化保持显式可见。 ### 日志与 token 形态 -时间区块属于动态系统提示词状态。agent loop(智能体循环)现有的 `request/header` 快照和 `request/header-delta` 折叠结果会在发送前记录每次渲染变化,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在派生的会话历史中。该设计遵循[提示词变量 RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)的所有权规则:可选插件拥有时间信息,并通过常规提示词注册表贡献该信息,不为循环添加特殊分支。 +agent loop(智能体循环)会在发送前通过 `request/header` 和 `request/header-delta` 记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 RFC](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。 ## 测试 -该 package 的测试套件使用伪造的系统时间,覆盖 UTC 与偏移格式化、首轮次回退文本、所有符合条件的上一条消息类型、完整时长单位、挂钟回拨钳制、逐轮次刷新、间隔内复用、间隔到期、`0` 对应的逐步骤行为、相互独立的逐 agent 缓存、无效配置、HMR(热模块替换)资源释放以及 Loader 命名空间路径。一个使用真实 agent loop 的测试会固定实际发送的系统提示词及其 `request/header-delta` 刷新记录。默认快照不发生变化,因为所有仓库提供的组合都刻意不包含该插件;若在默认快照 fixture 中挂载它,将违反显式选择加入的决策。 +单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态和资源释放行为。使用真实 agent loop 的测试固定实际发送的提示词和 `request/header-delta`;Loader 测试固定命名导出路径。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 ## 考虑过的替代方案 -- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳:每个读数都会留在派生历史中,因此陈旧时钟值和 token 成本会随会话长度累积。表层替换操作无法同时删除旧节点并将新读数移动到尾部;替换会保留旧节点的位置,而通过尾部节点替换又会隐藏中间的会话内容。 -- **使用 `agent/session-prefix`**——不予采纳:前缀在每个循环实例中只组装一次,并且按设计在会话期间保持稳定,因此无法表示每个轮次或步骤都会变化的时钟。 -- **在 `agent/request` 中修改请求**——不予采纳:该边界只负责塑造调用配置,触发时间晚于消息边界;如果在此处插入模型可见内容,会同时绕过提示词压力核算和请求头日志契约。 -- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 提示词变量**——不予采纳:两个独立提供方可能在不同时间点采样,并且需要共享缓存才能保证刷新语义的原子性。单个区段提供方将二者作为一个值计算和记录;部署方也不需要在角色设定中重复时间模板。 -- **按照配置的间隔通过后台计时器注入**——不予采纳:没有正在组装的模型请求时,新值没有消费方。由计时器驱动 `agent.inject()` 会创建持久的一次性轮次,并且只为通知时间流逝就唤醒或修改空闲会话。 -- **在 `dsh-agent-core` 中挂载插件**——不予采纳:时区、信息披露、token 预算和期望新鲜度都属于部署策略。显式选择加入能保持默认 harness 上下文稳定。 -- **将 package 放入 `core/`**——不予采纳:core 负责产品 API 主干。上下文增强是没有服务键的可选叶节点,因此专用分组能直接表达其组合角色。 +- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。 +- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。 +- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。 +- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。 +- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 +- **在 `dsh-agent-core` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 +- **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 ## 后果 -- 选择加入的部署无需消耗工具调用,即可让模型获得无歧义的分区时钟和轮次间隔时长。每个请求的系统提示词 token 成本固定,不会随会话增长。 -- 刷新会改变请求头,因此会新增一条 `request/header-delta` 事件。`refreshIntervalMs` 用时钟新鲜度换取这些持久增量记录的数量;将其设为零会在每个整秒渲染结果发生变化的步骤中刻意记录新值。 -- 系统不会仅为刷新时间而创建请求。工具运行时间超过该间隔时,先前读数会保持不变,直至下一步骤开始组装,此时提供方会追赶到当前时间。 -- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约,不属于本插件范围。 +- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。 +- 刷新会改变请求头,并可能新增 `request/header-delta`。`refreshIntervalMs` 用新鲜度换取持久增量记录的数量;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。 +- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。 +- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。 diff --git a/packages/context/README.md b/packages/context/README.md index 00755fce26..49a297d11a 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -1,7 +1,7 @@ # context/ — optional request context -Product plugins that add bounded model-visible request context without defining a tool or service seam. They are opt-in deployment leaves and are not part of the default `dsh-agent-core` bundle. +Opt-in plugins that add bounded model-visible request context without defining a tool or service. The default `dsh-agent-core` bundle excludes them. | Package | Role | ctx key | |---|---|---| -| `time-context/` | Dynamic current time and elapsed-since-previous-message system-prompt section | (none) | +| `time-context/` | Current time and elapsed-time system-prompt context | (none) | diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 3d9ffdd656..445101f4f6 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-time-context -Optional temporal request context. The plugin contributes one dynamic system-prompt section with the current zoned time and the elapsed duration since the last model-visible message before the current turn. It is not mounted by `dsh-agent-core` or any shipped example; deployments opt in explicitly. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). +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-core` and shipped examples do not mount it. Decision record: [the time-context RFC](../../../docs/rfc/implemented/feature/2026-07-14-time-context-plugin.md). ## Config @@ -12,21 +12,21 @@ Optional temporal request context. The plugin contributes one dynamic system-pro refreshIntervalMs: 60000 # default; 0 refreshes on every step ``` -`timeZone` is validated at plugin load. `refreshIntervalMs` must be a non-negative safe integer and is evaluated only when a request is assembled: every turn's first request gets a fresh reading, and a later step in the same turn reuses that reading until it is at least this old. Thus `0` means per-step refresh, while a positive value bounds staleness at request boundaries without creating timer-driven turns. +`timeZone` 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. ## Message baseline -The duration starts at the latest model-visible session event before the current `turn/start`: a user, assistant, tool-result, context, or steering message. All later refreshes in that turn retain the same baseline, so the value measures elapsed time since the preceding conversation message rather than collapsing to approximately zero after the current prompt is appended. The first turn reports that no earlier message exists. Session event append time is the durable clock source; client-side send time is not part of the session contract. +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 uses a dynamic system-prompt section rather than retained `context/message` history. The loop records the exact rendered value in `request/header` / `request/header-delta`, so requests remain reconstructable while the current request carries only one timing block. +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. ## Model Experience ### Temporal system prompt -**What the model sees**: Every request in an active turn includes the two-line section 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**: 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. -**Token effect**: Fixed two-line request context. A refresh replaces the section in the request header rather than retaining prior readings in conversation history. +**Token effect**: Fixed two-line cost per request. A refresh replaces the request-header section; prior readings do not accumulate. #### Temporal context section diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 202976ccf8..e18bb32540 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": "Optional dynamic system-prompt context with the current time and elapsed duration since the previous message", + "description": "Opt-in system-prompt context with the current time and elapsed time since the previous message", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 9162ef8614..7b087bc4de 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -1,14 +1,8 @@ /** - * Optional temporal context for model requests. The plugin contributes one - * dynamic system-prompt section that reports the current zoned time and the - * elapsed duration since the last model-visible message before the current - * turn. A turn always gets a fresh reading on its first request; later steps - * refresh only when the configured maximum age is reached. - * - * The section is request state, not retained conversation history. The agent - * loop records each rendered value through its existing `request/header` or - * `request/header-delta` event, preserving the model-visible/logged invariant - * without accumulating stale `context/message` entries. + * 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. * * @module @deepseek-ai/dsh-time-context */ @@ -24,7 +18,7 @@ export const name = 'time-context' /** The system-prompt registry that owns the dynamic request section. */ export const inject = ['systemPrompt'] -/** Configuration for the request-time clock section. */ +/** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ export interface Config { /** IANA time zone used for the rendered timestamp (default `UTC`). */ timeZone?: string @@ -38,13 +32,12 @@ export const Config: z = z.object({ refreshIntervalMs: z.number().default(60_000), }) -/** The open turn currently being assembled, including its log boundary. */ interface OpenTurn { turn: number startSeq: number } -/** One agent's last rendered block and its fixed previous-turn baseline. */ +/** Cached text and the fixed inter-turn baseline used by one agent's open turn. */ interface RenderState { turn: number renderedAt: number @@ -52,10 +45,8 @@ interface RenderState { text: string } -/** Date-time fields required from the fixed formatter below. */ type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year' -/** Find the open turn at the tail of an agent's balanced session log. */ function openTurn(agent: Agent): OpenTurn | undefined { for (const event of [...agent.session.events].reverse()) { switch (event.type) { @@ -71,7 +62,7 @@ function openTurn(agent: Agent): OpenTurn | undefined { return undefined } -/** Timestamp of the last model-visible message before one turn opened. */ +/** 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 @@ -116,7 +107,6 @@ function formatDuration(elapsedMs: number): string { return parts.join(' ') } -/** Build the exact two-line model-facing section. */ function renderText( now: number, previous: number | undefined, @@ -130,9 +120,10 @@ function renderText( } /** - * Register the dynamic temporal system-prompt section. + * 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. */ export function apply(ctx: Context, config: Config): void { const timeZone = config.timeZone as string diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index c035a0594f..8cf9e71f53 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -1,5 +1,3 @@ -/** Unit, loop-integration, lifecycle, and real-Loader coverage for dsh-time-context. */ - import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' @@ -25,7 +23,6 @@ afterEach(() => { vi.useRealTimers() }) -/** Mount the system-prompt service and the optional plugin. */ async function mount(config: Config = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt) @@ -33,18 +30,15 @@ async function mount(config: Config = {}) { return { ctx, fiber } } -/** Minimal agent-shaped holder over a real append-only Session. */ function sessionAgent(session: Session, id = 'agent'): Agent { return { id: AgentId(id), session } as unknown as Agent } -/** Resolve only this plugin's assembled section text. */ 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 } -/** Append the prompt side of an open message turn. */ function openMessageTurn(session: Session, turn: number): void { session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('user/message', { @@ -53,7 +47,6 @@ function openMessageTurn(session: Session, turn: number): void { }, { surfaceOp: 'append' }) } -/** Script helper for a text-only model response. */ function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, @@ -62,7 +55,6 @@ function textResponse(text: string): StreamChunk[] { ] } -/** Script helper for one tool-call response. */ function toolCallResponse(): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'tool-call' }, @@ -75,7 +67,6 @@ function toolCallResponse(): StreamChunk[] { ] } -/** Deterministic adapter that records each request and consumes one chunk script. */ class ScriptedAdapter extends LlmAdapter { readonly requests: GenerateOptions[] = [] @@ -91,7 +82,6 @@ class ScriptedAdapter extends LlmAdapter { } } -/** Mount the real loop spine plus this optional plugin. */ async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService) From 528f9cba6293beffbdaa4926968eabdbd6a51434 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:45:54 +0800 Subject: [PATCH 3/3] fix: default time context to system zone --- docs/config-catalog.md | 2 +- .../2026-07-14-time-context-plugin.i18n.yaml | 4 +- .../feature/2026-07-14-time-context-plugin.md | 7 +- .../2026-07-14-time-context-plugin.zh.md | 7 +- docs/testing.md | 2 +- knip.json | 4 + packages/AGENTS.md | 2 +- packages/context/time-context/README.md | 5 +- packages/context/time-context/src/index.ts | 13 +- .../time-context/tests/fixtures/cordis.yml | 17 +++ .../time-context/tests/time-context.e2e.ts | 115 ++++++++++++++++++ .../time-context/tests/time-context.spec.ts | 27 ++++ 12 files changed, 189 insertions(+), 16 deletions(-) create mode 100644 packages/context/time-context/tests/fixtures/cordis.yml create mode 100644 packages/context/time-context/tests/time-context.e2e.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 650fe16d23..69fad2238e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -787,7 +787,7 @@ Requires: `systemPrompt` ```ts config-catalog /** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ export interface Config { - /** IANA time zone used for the rendered timestamp (default `UTC`). */ + /** 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 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 10b441f03b..e70f8059f5 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: 54ed3188c794eb088db47b653ea0e27db1c14ed8 -2026-07-14-time-context-plugin.zh.md: fa0de3c6460eb6ab508b53e1ca3acf99564b1926 +2026-07-14-time-context-plugin.md: 13e0eff4b9d286ee562d7a0a2c0a3a659126ba3b +2026-07-14-time-context-plugin.zh.md: 5ee50a4d49eb9a7e00a09f70b436e15d72612b1f 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 54ed3188c7..13e0eff4b9 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 @@ -26,7 +26,7 @@ The baseline is the session event's append time, not an unlogged client timestam `refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound. -`timeZone` defaults to `UTC` and is validated as an IANA identifier at plugin load. The ISO-shaped local timestamp includes the resolved zone and current numeric offset, making daylight-saving changes explicit. +When `timeZone` is omitted, `Intl.DateTimeFormat` 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 value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone. ### Logging and token shape @@ -34,7 +34,7 @@ The loop records the temporal block through `request/header` and `request/header ## Testing -Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, and disposal. A real agent-loop test pins the transmitted prompt and `request/header-delta`; a Loader test pins the named-export path. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. +Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and `request/header-delta`. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. ## Alternatives considered @@ -43,12 +43,15 @@ Unit tests pin formatting, baselines, refresh policy, validation, per-agent stat - **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging. - **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template. - **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. +- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it. +- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either. - **Mount the plugin in `dsh-agent-core`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. - **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. ## Consequences - Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session. +- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user. - A refresh changes the request header and can add a `request/header-delta`. `refreshIntervalMs` trades freshness against durable deltas; `0` records a new value on every step whose whole-second rendering changes. - No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles. - Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract. 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 fa0de3c646..5ee50a4d49 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 @@ -26,7 +26,7 @@ Status: implemented `refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。 -`timeZone` 默认为 `UTC`,插件加载时会校验它是否为 IANA 标识符。形似 ISO 的本地时间戳包含已解析的时区及其当前数字偏移,使夏令时变化保持显式可见。 +省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。 ### 日志与 token 形态 @@ -34,7 +34,7 @@ agent loop(智能体循环)会在发送前通过 `request/header` 和 `reque ## 测试 -单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态和资源释放行为。使用真实 agent loop 的测试固定实际发送的提示词和 `request/header-delta`;Loader 测试固定命名导出路径。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 +单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和 `request/header-delta`。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 ## 考虑过的替代方案 @@ -43,12 +43,15 @@ agent loop(智能体循环)会在发送前通过 `request/header` 和 `reque - **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。 - **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。 - **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 +- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 +- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 - **在 `dsh-agent-core` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 - **将 package 放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 ## 后果 - 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。 +- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。 - 刷新会改变请求头,并可能新增 `request/header-delta`。`refreshIntervalMs` 用新鲜度换取持久增量记录的数量;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。 - 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。 - 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。 diff --git a/docs/testing.md b/docs/testing.md index 2a571d6015..d4acdc33c4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -23,7 +23,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## Test the real entry path -- A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). +- Product-visible plugins require a non-unit REAL-composition test. Hand-built `ctx.plugin(...)` suites are insufficient: boot test-only `cordis.yml` through Loader and app/process, mock only external/nondeterministic boundaries, and assert model-visible request/log, durable state, or user-visible output. Keep opt-ins out of shipped defaults. - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. - "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). diff --git a/knip.json b/knip.json index 825980f205..5b8fc7f436 100644 --- a/knip.json +++ b/knip.json @@ -23,6 +23,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/context/time-context": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/sandbox/sandbox-local": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/packages/AGENTS.md b/packages/AGENTS.md index 1f92a850a9..766a188105 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -4,7 +4,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Plugin export shape:** service packages default-export their service class; function plugins named-export `name` / `inject` / `Config` / `apply` and have no default export. Mixing the forms makes the Loader discard the function plugin's namespace ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). - **Optional services use `ctx.get(name)`.** Reserve `ctx.` for declared injections; the property proxy is topology-sensitive, while strict `ctx.get` reads the global service store ([postmortem](../docs/postmortem/0001-acp-default-export-drops-inject.md)). -- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md). +- **Product-visible plugins require a non-unit REAL-composition test.** Hand-built `ctx.plugin(...)` suites are insufficient. Boot test-only `cordis.yml` through the Loader and app/process; mock only external/nondeterministic boundaries and assert model-visible, durable, or user-visible output. Keep opt-ins out of shipped defaults. [Policy](../docs/testing.md). - **Typed same-process service and plugin calls are contracts, not serialization boundaries.** Prefer readonly borrowed values; materialize or defensively validate only at parser/config, queued, model/tool JSON, durable/file, worker, process, or wire boundaries. - **Represent one asynchronous operation with one lifecycle controller or transaction.** Separate readiness, cancellation, disposal, reservation, or sentinel state requires an independent owner or settlement boundary; otherwise fold it while preserving rollback, callback containment, and quiescence. diff --git a/packages/context/time-context/README.md b/packages/context/time-context/README.md index 445101f4f6..b50470aef7 100644 --- a/packages/context/time-context/README.md +++ b/packages/context/time-context/README.md @@ -8,11 +8,11 @@ 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: UTC # default; any IANA time-zone identifier + timeZone: Asia/Shanghai # optional IANA override; omit for the process zone refreshIntervalMs: 60000 # default; 0 refreshes on every step ``` -`timeZone` 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. `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. ## Message baseline @@ -40,3 +40,4 @@ Time since previous message: . - **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. +- **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. diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 7b087bc4de..cccd433811 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -20,7 +20,7 @@ export const inject = ['systemPrompt'] /** Request-time clock formatting and refresh policy. Invalid values fail plugin load. */ export interface Config { - /** IANA time zone used for the rendered timestamp (default `UTC`). */ + /** 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 @@ -28,7 +28,7 @@ export interface Config { /** Schemastery validation and defaults for {@link Config}. */ export const Config: z = z.object({ - timeZone: z.string().default('UTC'), + timeZone: z.string(), refreshIntervalMs: z.number().default(60_000), }) @@ -126,7 +126,7 @@ function renderText( * @throws when the time zone or refresh interval is invalid. */ export function apply(ctx: Context, config: Config): void { - const timeZone = config.timeZone as string + 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}`) @@ -135,7 +135,7 @@ export function apply(ctx: Context, config: Config): void { let formatter: Intl.DateTimeFormat try { formatter = new Intl.DateTimeFormat('en-US', { - timeZone, + ...(timeZone === undefined ? {} : { timeZone }), year: 'numeric', month: '2-digit', day: '2-digit', @@ -146,7 +146,10 @@ export function apply(ctx: Context, config: Config): void { timeZoneName: 'longOffset', }) } catch (error: unknown) { - throw new Error(`time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}`, { cause: error }) + const message = timeZone === undefined + ? 'time-context: failed to resolve the system time zone' + : `time-context: invalid IANA timeZone ${JSON.stringify(timeZone)}` + throw new Error(message, { cause: error }) } const resolvedTimeZone = formatter.resolvedOptions().timeZone const states = new WeakMap() diff --git a/packages/context/time-context/tests/fixtures/cordis.yml b/packages/context/time-context/tests/fixtures/cordis.yml new file mode 100644 index 0000000000..e9558abec6 --- /dev/null +++ b/packages/context/time-context/tests/fixtures/cordis.yml @@ -0,0 +1,17 @@ +# Test-only composition: keep time-context opt-in while exercising its real Loader/app path. +- id: mock-llm + name: '../../../../../examples/echo-agent/src/mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: time-context + name: '@deepseek-ai/dsh-time-context' + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-agent' + config: + model: mock-echo + persona: 'Test the time-context plugin.' + welcome: 'time-context e2e ready.' + persistenceRoot: './.sessions' diff --git a/packages/context/time-context/tests/time-context.e2e.ts b/packages/context/time-context/tests/time-context.e2e.ts new file mode 100644 index 0000000000..daa6e9a9b8 --- /dev/null +++ b/packages/context/time-context/tests/time-context.e2e.ts @@ -0,0 +1,115 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +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' + +const binScript = fileURLToPath(new URL('../../../ui/stdio-agent/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('./fixtures/cordis.yml', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) +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.' + +let child: ChildProcessWithoutNullStreams | undefined +let workdir: string | undefined + +afterEach(async () => { + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +async function runTwoTurns(): Promise<{ stdout: string; stderr: string }> { + workdir = await mkdtemp(join(tmpdir(), 'time-context-e2e-')) + const cwd = workdir + return new Promise((resolve, reject) => { + const proc = spawn( + process.execPath, + ['--expose-internals', '--import', tsxLoader, binScript, configPath], + { + cwd, + env: { + ...process.env, + TZ: 'Asia/Shanghai', + TSX_TSCONFIG_PATH: repoTsconfig, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + child = proc + let stdout = '' + let stderr = '' + let sentSecond = false + proc.stdout.setEncoding('utf8') + proc.stdout.on('data', (chunk: string) => { + stdout += chunk + if (!sentSecond && stdout.includes(`${FIRST_REPLY}\n> `)) { + sentSecond = true + proc.stdin.end('second\n') + } + }) + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', (chunk: string) => { stderr += chunk }) + + const timer = setTimeout(() => { + proc.kill('SIGKILL') + reject(new Error(`time-context e2e did not exit within ${PROCESS_TIMEOUT_MS / 1_000}s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, PROCESS_TIMEOUT_MS) + + proc.on('exit', (code) => { + clearTimeout(timer) + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`time-context e2e exited ${code}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + proc.on('error', (error) => { clearTimeout(timer); reject(error) }) + proc.stdin.write('first\n') + }) +} + +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 () => { + 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".') + + const logs = await jsonlFiles(join(workdir as string, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + 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\]/, + ) + expect(firstHeader.data.header.system).toContain( + 'Time since previous message: unavailable (no earlier message in this session).', + ) + + 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\./, + ) + }, 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 8cf9e71f53..562b002b68 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -13,14 +13,19 @@ import * as timeContext from '@deepseek-ai/dsh-time-context' 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'] beforeEach(() => { + process.env['TZ'] = 'UTC' vi.useFakeTimers() vi.setSystemTime(BASE) }) afterEach(() => { + vi.restoreAllMocks() vi.useRealTimers() + if (ORIGINAL_TIME_ZONE === undefined) delete process.env['TZ'] + else process.env['TZ'] = ORIGINAL_TIME_ZONE }) async function mount(config: Config = {}) { @@ -266,6 +271,18 @@ describe('refresh policy', () => { }) describe('configuration and lifecycle', () => { + it('defaults to the process system zone and retains the zone resolved at plugin load', async () => { + process.env['TZ'] = 'Asia/Shanghai' + const { ctx } = await mount() + process.env['TZ'] = 'America/New_York' + 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]', + ) + }) + 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() @@ -278,6 +295,16 @@ describe('configuration and lifecycle', () => { 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/) + }) + it('removes its section when the plugin fiber disposes', async () => { const { ctx, fiber } = await mount() const session = new Session(SessionId('dispose'))