refactor(schedule): make absolute times explicit
This commit is contained in:
@@ -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 packages/schedule/tool-schedule/README.md
|
||||
README.md: 3e0a0cea98dbe593974c5604736d155508f28044
|
||||
README.zh.md: b08bad14d50b07af2c36796335452b835fb9680a
|
||||
README.md: 144a72d0af36970b7888c15b16713b2ea92c1dea
|
||||
README.zh.md: 8fe59f30d6f317d188e5a6c489b889af066ad46f
|
||||
|
||||
@@ -2,49 +2,47 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
`dsh-tool-schedule` gives future live root agents three session-scoped tools for durable one-shot reminders. Version 1 accepts positive safe-integer `after_seconds` delays and absolute `at` targets. The session event log owns reminder state; timers, tool values, and model followups are disposable projections of that log.
|
||||
`dsh-tool-schedule` gives future live root Agents three Session-scoped tools for durable one-shot reminders. Version 1 accepts positive safe-integer `after_seconds` delays and explicit absolute `at` targets. The Session event log owns reminder state; timers, tool values, and model follow-ups are disposable projections of that log.
|
||||
|
||||
## Composition
|
||||
|
||||
Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx.sessionPersistence`, and the persistence listener that implements Session flushes. Static injection makes a missing persistence service a composition error. The plugin listens only to later `agent/created` events, installs on runtime roots, and registers all tools through the exact `agent.ctx`. Agents that already existed when the plugin loaded and runtime children do not receive Schedule.
|
||||
|
||||
Load `@deepseek-ai/dsh-time-context` before publishing a root that should resolve local `at` values without an explicit zone. The official Schedule Web overlay does so. Explicit-offset and explicit-zone values remain usable without implicit request-zone context.
|
||||
Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context.
|
||||
|
||||
Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation.
|
||||
|
||||
## Durable state
|
||||
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of the submitted offset, local calendar fields, or interpreting zone. Delete and one-shot dispatch carry only the id.
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of its submitted offset, local calendar fields, or interpreting zone. Delete and one-shot dispatch carry only the id.
|
||||
|
||||
Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
Replay rejects unknown versions, extra fields, reused ids, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` is the pure Host-facing receipt projection. It returns `scheduleId`, prompt, and occurrence from the dispatch's nearest preceding same-id create; the client renderer adds the fixed `session-local` label. The current fork's `seedLength` is a hard boundary for child-owned dispatches, while inherited dispatches search their persisted prefix; resumed ancestors therefore remain renderable, nested generations may reuse session-local ids, and presentation never changes live ownership.
|
||||
## Absolute-time input
|
||||
|
||||
## Absolute-time context
|
||||
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`. The string identifies an instant through `Z` or its numeric offset. The local form always requires explicit `UTC` or a valid IANA Area/Location zone. Missing `time_zone`, offset-free strings, extra keys, normalized calendar dates, invalid offsets, and non-future targets are rejected.
|
||||
|
||||
The `at` selector is either a strict `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` string or `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`. The offset form already identifies one instant. The local form validates an explicit `UTC` or IANA Area/Location zone, or may omit `time_zone` only when the current open turn has a time-context reading and its original user-rpc sources derive one client zone equal to the immutable Session zone.
|
||||
|
||||
The Web Host validates and canonicalizes the browser zone at Session creation and on every prompt. Session creation fixes `SessionHeader.timeZone`; each prompt instead carries its own `clientTimeZone` in the user-message source, so concurrent tabs do not overwrite shared state. Schedule derives directly from those original owners rather than copying them into the time-context source. A headerless Session, a missing or mixed client-zone result, or a client/Session mismatch returns `timezone_confirmation_required` with the known zones and requires an explicit `time_zone`.
|
||||
|
||||
Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only the canonical UTC target, and no Schedule path reads the process time zone.
|
||||
Schedule owns deterministic calendar normalization. Local times inside a daylight-saving gap are rejected. An overlap chooses its first, earlier instant. A successful create retains only canonical UTC `scheduledAt`; no Schedule path reads the browser, Session header, model time-context, connection, or process time zone.
|
||||
|
||||
## Management tools
|
||||
|
||||
The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds`.
|
||||
The generated [tool catalog](../../../docs/tool-catalog.md) owns the argument and output schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Their canonical values use camelCase record fields even though model input uses `after_seconds` and `time_zone`.
|
||||
|
||||
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. Direct callers therefore cannot interleave a fold with another Schedule mutation or observe a dispatch before its own barrier. `schedule_create` requires exactly one of `after_seconds` or `at`, validates shape-only failures before entering that queue, then checkpoints, allocates a never-reused id, appends the create, and checkpoints again; an absolute target must be strictly future. `schedule_list` returns every active record in create order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before entering the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after its preflight.
|
||||
One Agent-scoped queue serializes each accepted management transaction and the live owner's due transaction from preflight through any post-append barrier. `schedule_create` requires exactly one of `after_seconds` or `at`, validates shape-only failures before entering the queue, then checkpoints, allocates a never-reused id, appends create, and checkpoints again. `schedule_list` returns active records in creation order with `state: "scheduled" | "overdue"` and `deliveryMode: "session-local"`. `schedule_delete` rejects an empty or whitespace-padded id before the queue and appends only for an active id; an unknown or terminal id returns `{ id, deleted: false, code: "schedule_not_found" }` after preflight.
|
||||
|
||||
Every successful management preflight also asks the live owner to recompute. This matters after a create or delete barrier returned `persistence_uncertain`: a later list or mutation can confirm the retained batch and immediately arm or retire the now-durable record without a private persistence-retry timer.
|
||||
Every successful management preflight also asks the live owner to recompute. This recovers a retained create or delete batch after a previous post-append barrier returned `persistence_uncertain`, without a Schedule-specific persistence-retry timer.
|
||||
|
||||
The closed v1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `timezone_confirmation_required`, `not_future`, `time_out_of_range`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior.
|
||||
The closed version-1 domain error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `not_future`, `time_out_of_range`, `corrupt_schedule_log`, `persistence_uncertain`, and `internal_error`. Diagnostics are stable and do not expose backend exceptions. Rendered content is deterministic JSON of the canonical value; generic tool-result policy remains responsible for any model-facing spill behavior.
|
||||
|
||||
## Delivery lifecycle
|
||||
|
||||
The live owner derives the earliest target from the durable fold. It splits waits longer than the Node timer range and rereads the wall clock after every wake, so a rollback cannot fire early and a forward jump makes the record overdue.
|
||||
|
||||
An overdue reminder first checkpoints persistence. If a turn or another maintenance task already owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task samples one decision time, builds the complete framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch. Framing or synchronous followup failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves the dispatch pending for a later ordinary preflight and does not start a private retry timer.
|
||||
An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, builds the fixed reminder framing, synchronously queues `followup()`, and appends an id-only dispatch before releasing the phase. Waking input remains parked until that release, after which the owner checkpoints dispatch.
|
||||
|
||||
Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits. It never appends delete records during teardown.
|
||||
The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer.
|
||||
|
||||
Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -52,7 +50,7 @@ Agent or plugin disposal cancels timers, stops new work, and awaits in-flight pr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the three generated tool schemas only in a live root agent created after this plugin loads. Tool results contain the canonical JSON values described above.
|
||||
The model sees the three generated tool schemas only in a live root Agent created after this plugin loads. Tool results contain the canonical JSON values described above.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -62,7 +60,7 @@ The scoped schemas add a fixed request prefix while Schedule is installed. Each
|
||||
|
||||
The three schemas remain prefix-stable while their definitions and scope stay unchanged. Tool calls and results append to later history and preserve an already reusable prefix.
|
||||
|
||||
### Due reminder followup
|
||||
### Due reminder follow-up
|
||||
|
||||
#### What the model sees
|
||||
|
||||
@@ -80,17 +78,17 @@ reminder_prompt_json: <JSON.stringify(prompt)>
|
||||
|
||||
#### Token effect
|
||||
|
||||
Each dispatched `after` or `at` reminder adds one data-dependent user-role message. The message remains in session history and therefore contributes tokens to later requests until ordinary compaction removes or replaces that history.
|
||||
Each dispatched one-shot reminder adds one data-dependent user-role message. It remains in Session history and contributes tokens until ordinary compaction removes or replaces that history.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, or prompt changes only the appended suffix.
|
||||
The reminder appends after existing history and preserves its reusable prefix. Its id, occurrence, and prompt affect only the appended suffix.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Session-local delivery only** — a reminder runs on time only while its original session is live; a cold session receives no external notification and processes an overdue record only after resume.
|
||||
- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the overdue record active but starts no private retry timer; the owner retries after later Agent activity reaches idle or a successful Schedule management preflight asks it to recompute.
|
||||
- **One-shot protocol only** — version 1 supports `after` and `at` but rejects `every_seconds` and `cron`; recurring rules require their own transition and budget semantics rather than hidden compatibility fields.
|
||||
- **Immutable Session zone** — a new Schedule Web Session captures one default browser zone and has no zone editor. Older headerless Sessions remain `unavailable`, and a mismatched or ambiguous request must name `time_zone` explicitly.
|
||||
- **Narrow crash duplicate window** — a crash after synchronous followup admission but before the dispatch checkpoint can repeat the reminder after recovery; the package does not claim model completion, user acknowledgement, or exactly-once external effects.
|
||||
- **Load-order boundary** — the plugin does not scan or adopt agents that were already live when it loaded.
|
||||
- **Session-local delivery only** — a reminder runs on time only while its original Session is live; a cold Session receives no external notification and processes an overdue record only after resume.
|
||||
- **Activity-driven retry** — a rejected due preflight or contained framing/enqueue failure leaves the record active but starts no private retry timer; later Agent activity or a successful Schedule preflight triggers recomputation.
|
||||
- **Explicit local zone** — `at` never imports browser context; callers must translate natural language into either an offset-bearing RFC 3339 string or a local object with `time_zone`.
|
||||
- **One-shot protocol only** — version 1 supports `after` and `at` and rejects `every_seconds` and `cron`; recurrence needs explicit transition, catch-up, and model-budget semantics.
|
||||
- **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects.
|
||||
- **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded.
|
||||
|
||||
@@ -2,49 +2,47 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性提醒。版本 1 接受正的安全整数 `after_seconds` 延时与绝对 `at` 目标。会话事件日志拥有提醒状态;timer、工具值与模型 `followup` 都是该日志的可丢弃投影。
|
||||
`dsh-tool-schedule` 为未来创建的 live 根 agent(智能体)提供 3 个会话范围内的工具,用于管理持久的一次性提醒。版本 1 接受正的安全整数 `after_seconds` 延时和显式绝对时间 `at` 目标。会话事件日志拥有提醒状态;timer、工具值和模型 follow-up 都是该日志的可丢弃投影。
|
||||
|
||||
## 组合
|
||||
|
||||
请在 `ctx.sessions`、`ctx.agents`、`ctx.tools`、`ctx.sessionPersistence`,以及实现 Session flush 的持久化监听器之后加载此函数插件。静态注入会使缺少持久化服务的组合直接失败。此插件只监听后续的 `agent/created` 事件,在运行时根 agent 上安装,并通过完全相同的 `agent.ctx` 注册所有工具。插件加载时已经存在的 agent 与运行时子 agent 不会获得 Schedule。
|
||||
|
||||
若根 agent 需要在未显式指定时区时解析本地 `at` 值,请在发布该 agent 前加载 `@deepseek-ai/dsh-time-context`。官方 Schedule Web overlay 会按此顺序加载。带显式偏移量的值和带显式时区的值即使没有隐式请求时区上下文仍可使用。
|
||||
Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`;Schedule 绝不会从模型上下文中导入或推断该值。
|
||||
|
||||
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。
|
||||
|
||||
## 持久状态
|
||||
|
||||
此包(package)拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的 prompt,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区。delete 与一次性 dispatch 只携带 id。
|
||||
此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区。delete 与一次性 dispatch 只携带 id。
|
||||
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套项会对现有日志和候选事件应用相同策略。
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。
|
||||
|
||||
`scheduleReminderPresentation(events, dispatchSeq, seedLength)` 是供 Host 使用的纯回执投影。它从 dispatch 之前最近的同 id create 返回 `scheduleId`、prompt 和 occurrence;client renderer 添加固定的 `session-local` 标签。当前 fork 的 `seedLength` 是 child 自有 dispatch 的硬边界,而继承的 dispatch 则会搜索其已持久前缀;因此恢复后的祖先仍可渲染,嵌套 generation 可以复用会话本地 id,presentation 绝不会改变 live ownership。
|
||||
## 绝对时间输入
|
||||
|
||||
## 绝对时间上下文
|
||||
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone: string }`。字符串通过 `Z` 或数值偏移量标识一个时刻。本地形式始终要求显式 `UTC` 或有效的 IANA Area/Location 时区。缺少 `time_zone`、不带偏移量的字符串、额外键、需要规范化的日历日期、无效偏移量和非未来目标都会被拒绝。
|
||||
|
||||
`at` selector 可以是严格的 `YYYY-MM-DDTHH:mm:ss[.S|.SS|.SSS](Z|±HH:MM)` 字符串,也可以是 `{ date: "YYYY-MM-DD", time: "HH:mm:ss[.S|.SS|.SSS]", time_zone?: string }`。偏移量形式本身即可确定一个时刻。本地形式会校验显式指定的 `UTC` 或 IANA Area/Location 时区;仅当当前 open turn 含有 time-context 读数,并且其原始 user-rpc 来源派生出唯一一个与不可变 Session 时区相等的客户端时区时,才可以省略 `time_zone`。
|
||||
|
||||
Web Host 会在创建 Session 时以及每次提交提示词时校验并规范化浏览器时区。Session 创建会固定 `SessionHeader.timeZone`;每条提示词则会在用户消息来源中携带自己的 `clientTimeZone`,因此并发标签页不会覆盖共享状态。Schedule 会直接从这些原始拥有方派生,而不会把它们复制进 time-context source。如果 Session 没有 header、客户端时区结果缺失或混杂,或客户端与 Session 不匹配,系统会返回 `timezone_confirmation_required` 并附上已知时区,同时要求显式指定 `time_zone`。
|
||||
|
||||
落在夏令时空档内的本地时间会被拒绝。遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC 目标,Schedule 的任何路径都不会读取进程时区。
|
||||
Schedule 负责确定性的日历规范化。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时刻。创建成功后只保留规范化后的 UTC `scheduledAt`;Schedule 的任何路径都不会读取浏览器、Session 标头、模型 time-context、连接或进程时区。
|
||||
|
||||
## 管理工具
|
||||
|
||||
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds`,但其规范值中的记录字段使用 camelCase。
|
||||
生成的[工具目录](../../../docs/tool-catalog.md)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与输出 schema。虽然模型输入使用 `after_seconds` 和 `time_zone`,但其规范值中的记录字段使用 camelCase。
|
||||
|
||||
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。因此,直接调用方无法让一次 fold 与另一项 Schedule 变更交错,也无法在自身的 barrier 前观察到 dispatch。`schedule_create` 要求 `after_seconds` 与 `at` 有且只有一项;它会在进入该队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点;绝对目标必须严格位于未来。`schedule_list` 按创建顺序返回所有活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入该队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight(预检)后返回 `{ id, deleted: false, code: "schedule_not_found" }`。
|
||||
一条 Agent-scoped 队列会将每项已接纳的管理事务与 live owner 的到期事务从 preflight 到任何 post-append barrier 全程串行化。`schedule_create` 要求 `after_seconds` 与 `at` 有且只有一项;它会在进入队列前验证只依赖输入形状的失败,随后执行检查点、分配永不复用的 id、追加 create,再次执行检查点。`schedule_list` 按创建顺序返回活动记录,其中包含 `state: "scheduled" | "overdue"` 与 `deliveryMode: "session-local"`。`schedule_delete` 会在进入队列前拒绝空 id 或前后带空白的 id,并只为活动 id 追加事件;未知或已终结的 id 会在 preflight 后返回 `{ id, deleted: false, code: "schedule_not_found" }`。
|
||||
|
||||
每次成功的管理 preflight 还会要求 live owner 重新计算。这对 create 或 delete barrier 返回 `persistence_uncertain` 的情况很重要:后续 list 或 mutation 可以确认保留的 batch,并立即 arm 或退役此时已持久化的 record,而无需私有 persistence retry timer。
|
||||
每次成功的管理 preflight 还会要求 live owner 重新计算。如果先前的 post-append barrier 返回 `persistence_uncertain`,这会恢复所保留的 create 或 delete batch,而无需 Schedule 专属的持久化重试 timer。
|
||||
|
||||
版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`timezone_confirmation_required`、`not_future`、`time_out_of_range`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。
|
||||
版本 1 的封闭领域错误代码包括 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`not_future`、`time_out_of_range`、`corrupt_schedule_log`、`persistence_uncertain` 和 `internal_error`。诊断文本保持稳定,不会暴露后端异常。渲染内容是规范值的确定性 JSON;通用工具结果策略仍负责模型可见内容的 spill 行为。
|
||||
|
||||
## 交付生命周期
|
||||
|
||||
live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node timer 范围的等待,并在每次唤醒后重新读取墙钟,因此时钟回拨不会提前触发,时钟前跳则会使记录进入 overdue 状态。
|
||||
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会采样一次决策时间,构造完整 framing,同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。framing 构造或同步 `followup` 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight 处理,而不会启动私有重试 timer。
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、构造固定的提醒 framing、同步将 `followup()` 入队,并在释放 phase 前追加只含 id 的 dispatch。触发唤醒的 input 会保持 parked,直到该 phase 释放;随后 owner 为 dispatch 建立检查点。
|
||||
|
||||
agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新工作,并等待进行中的 preflight 和 idle wait。清理期间绝不会追加 delete 记录。
|
||||
Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。
|
||||
|
||||
framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作,并等待进行中的 preflight 与 idle wait,且不会删除持久记录。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -62,7 +60,7 @@ agent 或插件执行 dispose(资源释放)时,会取消 timer、停止新
|
||||
|
||||
3 个 schema 的定义与范围不变时,前缀保持稳定。工具调用和结果会追加到后续历史中,并保留已经可以复用的前缀。
|
||||
|
||||
### 到期提醒 followup
|
||||
### 到期提醒 follow-up
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
@@ -80,17 +78,17 @@ reminder_prompt_json: <JSON.stringify(prompt)>
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每条已 dispatch 的 `after` 或 `at` 提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,因此会持续为后续请求贡献 token,直到普通压缩(compaction)移除或替换这段历史。
|
||||
每条已 dispatch 的一次性提醒会增加一条与数据相关的用户角色消息。该消息保留在会话历史中,并持续贡献 token,直到普通压缩(compaction)移除或替换这段历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 或 prompt 只会改变追加的后缀。
|
||||
提醒会追加到现有历史之后,并保留可复用的前缀。提醒的 id、occurrence 和提示词只会影响追加的后缀。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅限会话本地交付**:提醒只有在原会话 live 时才能准时运行;cold 会话不会收到外部通知,只有恢复后才会处理 overdue 记录。
|
||||
- **活动驱动的重试**:到期 preflight 被拒绝或 framing/入队失败被收容后,overdue 记录仍保持活动,但不会启动私有重试 timer;后续 agent 活动进入 idle,或成功的 Schedule 管理 preflight 要求 owner 重新计算后,owner 会重试。
|
||||
- **仅支持一次性协议**:版本 1 支持 `after` 与 `at`,但拒绝 `every_seconds` 和 `cron`;周期性规则需要各自的转换与预算语义,而不是隐藏的兼容字段。
|
||||
- **Session 时区不可变**:新的 Schedule Web Session 会记录一个默认浏览器时区,且没有时区编辑器。旧有的无 header Session 仍为 `unavailable`,不匹配或有歧义的请求必须显式指定 `time_zone`。
|
||||
- **存在狭窄的崩溃重复窗口**:同步 `followup` 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒在恢复后重复;此包不承诺模型完成、用户确认或外部副作用恰好一次。
|
||||
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 agent。
|
||||
- **活动驱动的重试**:到期 preflight 被拒绝或 framing/入队失败被收容后,记录仍保持活动,但不会启动私有重试 timer;后续 Agent 活动或成功的 Schedule preflight 会触发重新计算。
|
||||
- **显式本地时区**:`at` 绝不会导入浏览器上下文;调用方必须把自然语言转换为带偏移量的 RFC 3339 字符串,或带 `time_zone` 的本地对象。
|
||||
- **仅支持一次性协议**:版本 1 支持 `after` 与 `at`,并拒绝 `every_seconds` 与 `cron`;周期性规则需要显式的状态转换、追赶和模型预算语义。
|
||||
- **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。
|
||||
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-time-context": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
@@ -47,7 +46,6 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-time-context": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
ScheduleChange,
|
||||
ScheduleId as ScheduleIdType,
|
||||
ScheduleRecord,
|
||||
ScheduleReminderPresentation,
|
||||
ScheduleView,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -48,14 +47,13 @@ export class ScheduleLogError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Error from a model-supplied after rule that cannot become a record. */
|
||||
/** Error from a model-supplied Schedule rule that cannot become a record. */
|
||||
export class ScheduleInputError extends Error {
|
||||
/** Stable public Schedule input code. */
|
||||
readonly code:
|
||||
| 'invalid_prompt'
|
||||
| 'invalid_rule'
|
||||
| 'invalid_time_zone'
|
||||
| 'timezone_confirmation_required'
|
||||
| 'not_future'
|
||||
| 'time_out_of_range'
|
||||
|
||||
@@ -70,7 +68,6 @@ export class ScheduleInputError extends Error {
|
||||
| 'invalid_prompt'
|
||||
| 'invalid_rule'
|
||||
| 'invalid_time_zone'
|
||||
| 'timezone_confirmation_required'
|
||||
| 'not_future'
|
||||
| 'time_out_of_range',
|
||||
message: string,
|
||||
@@ -568,7 +565,6 @@ export function createAfterScheduleRecord(
|
||||
* @param prompt - User-authored reminder content.
|
||||
* @param at - Explicit-offset instant or structured local calendar value.
|
||||
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
|
||||
* @param implicitTimeZone - Confirmed Session zone for a local value that omits `time_zone`.
|
||||
* @returns Frozen durable absolute one-shot record.
|
||||
*/
|
||||
export function createAtScheduleRecord(
|
||||
@@ -576,7 +572,6 @@ export function createAtScheduleRecord(
|
||||
prompt: string,
|
||||
at: AtInput,
|
||||
now: number,
|
||||
implicitTimeZone?: string,
|
||||
): AtScheduleRecord {
|
||||
const normalizedPrompt = prompt.trim()
|
||||
if (normalizedPrompt.length === 0) {
|
||||
@@ -587,29 +582,22 @@ export function createAtScheduleRecord(
|
||||
if (typeof at === 'string') {
|
||||
target = parseOffsetInstant(at)
|
||||
} else if (isRecord(at)) {
|
||||
if (!hasExactKeys(at, ['date', 'time']) && !hasExactKeys(at, ['date', 'time', 'time_zone'])) {
|
||||
throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and optional time_zone.')
|
||||
if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) {
|
||||
throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.')
|
||||
}
|
||||
if (typeof at['date'] !== 'string' || typeof at['time'] !== 'string') {
|
||||
throw new ScheduleInputError('invalid_rule', 'Local at date and time must be strings.')
|
||||
}
|
||||
const rawTimeZone = at['time_zone']
|
||||
if (rawTimeZone !== undefined && typeof rawTimeZone !== 'string') {
|
||||
if (typeof rawTimeZone !== 'string') {
|
||||
throw new ScheduleInputError('invalid_time_zone', 'time_zone must be a string.')
|
||||
}
|
||||
const selectedTimeZone = rawTimeZone ?? implicitTimeZone
|
||||
if (selectedTimeZone === undefined) {
|
||||
throw new ScheduleInputError(
|
||||
'timezone_confirmation_required',
|
||||
'Local at requires an explicit time_zone for this request.',
|
||||
)
|
||||
}
|
||||
const local: LocalAtInput = {
|
||||
date: at['date'],
|
||||
time: at['time'],
|
||||
...(rawTimeZone === undefined ? {} : { time_zone: rawTimeZone }),
|
||||
time_zone: rawTimeZone,
|
||||
}
|
||||
target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(selectedTimeZone))
|
||||
target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone))
|
||||
} else {
|
||||
throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.')
|
||||
}
|
||||
@@ -636,64 +624,6 @@ export function scheduleView(record: ScheduleRecord, now: number): ScheduleView
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the Web receipt for one dispatch from its owning stream segment.
|
||||
* A child-owned dispatch cannot cross the current fork's `seedLength`.
|
||||
* An inherited dispatch pairs with its nearest preceding same-id create, so
|
||||
* resumed ancestors remain renderable and nested forks may reuse local ids.
|
||||
* @param events - Complete contiguous Session log.
|
||||
* @param dispatchSeq - Exact event seq to present.
|
||||
* @param seedLength - Inherited fork prefix length.
|
||||
* @returns The immutable receipt, or `undefined` when the selected event is not a dispatch.
|
||||
*/
|
||||
export function scheduleReminderPresentation(
|
||||
events: readonly SessionEvent[],
|
||||
dispatchSeq: number,
|
||||
seedLength = 0,
|
||||
): ScheduleReminderPresentation | undefined {
|
||||
if (!Number.isSafeInteger(dispatchSeq) || dispatchSeq < 0) {
|
||||
throw new ScheduleLogError('schedule presentation seq must be a non-negative safe integer')
|
||||
}
|
||||
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
|
||||
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
|
||||
}
|
||||
const event = events[dispatchSeq]
|
||||
if (event === undefined || event.seq !== dispatchSeq) {
|
||||
throw new ScheduleLogError('schedule presentation seq must identify the matching contiguous event')
|
||||
}
|
||||
if (event.type !== 'schedule/change') return undefined
|
||||
const dispatch = decodeScheduleChange(event.data)
|
||||
if (dispatch.operation !== 'dispatch') return undefined
|
||||
|
||||
const segmentStart = dispatchSeq < seedLength ? 0 : seedLength
|
||||
for (let index = dispatchSeq - 1; index >= segmentStart; index -= 1) {
|
||||
const candidate = events[index]
|
||||
if (candidate?.type !== 'schedule/change') continue
|
||||
const change = decodeScheduleChange(candidate.data)
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
if (change.schedule.id !== dispatch.id) break
|
||||
return Object.freeze({
|
||||
scheduleId: change.schedule.id,
|
||||
prompt: change.schedule.prompt,
|
||||
occurrenceAt: change.schedule.scheduledAt,
|
||||
})
|
||||
case 'delete':
|
||||
case 'dispatch':
|
||||
if (change.id === dispatch.id) {
|
||||
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
|
||||
}
|
||||
break
|
||||
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
|
||||
default: {
|
||||
const unreachable: never = change
|
||||
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(dispatch.id)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the fixed injection-resistant model framing for a due reminder.
|
||||
* @param record - Due active record.
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
ScheduleLogError,
|
||||
allocateScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
createAtScheduleRecord,
|
||||
decodeScheduleChange,
|
||||
foldScheduleEvents,
|
||||
renderReminderFraming,
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { deriveClientTimeZoneContext } from '@deepseek-ai/dsh-time-context'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import {
|
||||
@@ -87,17 +85,6 @@ const BASIC_ERROR_SCHEMAS = [
|
||||
basicErrorSchema('internal_error'),
|
||||
] as const
|
||||
|
||||
const TIME_ZONE_CONFIRMATION_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: 'string', required: true, const: 'timezone_confirmation_required' },
|
||||
message: { type: 'string', required: true },
|
||||
sessionTimeZone: { type: 'string', required: true },
|
||||
clientTimeZones: { type: 'array', required: true, items: { type: 'string' } },
|
||||
},
|
||||
} as const
|
||||
|
||||
const PERSISTENCE_ERROR_SCHEMA = {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -111,7 +98,6 @@ const PERSISTENCE_ERROR_SCHEMA = {
|
||||
|
||||
const ERROR_SCHEMAS = [
|
||||
...BASIC_ERROR_SCHEMAS,
|
||||
TIME_ZONE_CONFIRMATION_SCHEMA,
|
||||
PERSISTENCE_ERROR_SCHEMA,
|
||||
] as const
|
||||
|
||||
@@ -211,103 +197,8 @@ function persistenceError(
|
||||
}
|
||||
}
|
||||
|
||||
/** Request-local zone evidence returned with an implicit-local confirmation failure. */
|
||||
interface AtTimeZoneContext {
|
||||
readonly implicitTimeZone?: string
|
||||
readonly sessionTimeZone: string
|
||||
readonly clientTimeZones: string[]
|
||||
}
|
||||
|
||||
/** Whether one durable message is the exact time-context snapshot marker. */
|
||||
function isTimeContextReading(event: SessionEvent): boolean {
|
||||
if (event.type !== 'user/message') return false
|
||||
const source = event.data.source
|
||||
if (source.kind !== 'plugin'
|
||||
|| source.plugin !== 'time-context'
|
||||
|| Object.keys(source).length !== 4
|
||||
|| source.form !== 'snapshot') return false
|
||||
const blockValue: unknown = event.data.content[0]
|
||||
const block = typeof blockValue === 'object' && blockValue !== null
|
||||
? blockValue as Record<string, unknown>
|
||||
: undefined
|
||||
const sections: unknown = source.sections
|
||||
const sectionValue: unknown = Array.isArray(sections) ? sections[0] : undefined
|
||||
const section = typeof sectionValue === 'object' && sectionValue !== null
|
||||
? sectionValue as Record<string, unknown>
|
||||
: undefined
|
||||
return event.data.content.length === 1
|
||||
&& block !== undefined
|
||||
&& Object.keys(block).length === 2
|
||||
&& block.type === 'text'
|
||||
&& typeof block.text === 'string'
|
||||
&& Array.isArray(sections)
|
||||
&& sections.length === 1
|
||||
&& section !== undefined
|
||||
&& Object.keys(section).length === 2
|
||||
&& section.name === 'time-context'
|
||||
&& section.text === block.text
|
||||
}
|
||||
|
||||
/** Derive request zones only while the current open turn contains a time-context reading. */
|
||||
function currentClientTimeZoneContext(agent: Agent): ReturnType<typeof deriveClientTimeZoneContext> | undefined {
|
||||
const events = agent.session.events
|
||||
let stepStart = -1
|
||||
let turn = 0
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index]
|
||||
/* v8 ignore next -- the loop bounds index to the dense Session event array. */
|
||||
if (event === undefined) continue
|
||||
if (event.type === 'step/end' || event.type === 'turn/end') return undefined
|
||||
if (event.type === 'step/start') {
|
||||
stepStart = index
|
||||
turn = event.data.turn
|
||||
break
|
||||
}
|
||||
}
|
||||
if (stepStart < 0) return undefined
|
||||
const turnStart = events.findLastIndex(event => event.type === 'turn/start' && event.data.turn === turn)
|
||||
if (turnStart < 0) return undefined
|
||||
const hasReading = events.slice(turnStart + 1).some(isTimeContextReading)
|
||||
if (!hasReading) return undefined
|
||||
const messages = events.slice(turnStart + 1)
|
||||
.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
return deriveClientTimeZoneContext(messages)
|
||||
}
|
||||
|
||||
/** Resolve the only request state that may supply an omitted local time zone. */
|
||||
function atTimeZoneContext(agent: Agent): AtTimeZoneContext {
|
||||
const sessionTimeZone = agent.session.header.timeZone ?? 'unavailable'
|
||||
const client = currentClientTimeZoneContext(agent)
|
||||
const clientTimeZones = client === undefined || client.kind === 'missing'
|
||||
? []
|
||||
: client.kind === 'resolved'
|
||||
? [client.timeZone]
|
||||
: [...client.timeZones]
|
||||
const implicitTimeZone = sessionTimeZone !== 'unavailable'
|
||||
&& client?.kind === 'resolved'
|
||||
&& client.timeZone === sessionTimeZone
|
||||
? sessionTimeZone
|
||||
: undefined
|
||||
return {
|
||||
...(implicitTimeZone === undefined ? {} : { implicitTimeZone }),
|
||||
sessionTimeZone,
|
||||
clientTimeZones,
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate one contained input failure to the closed tool union. */
|
||||
function inputError(error: ScheduleInputError, timeZone?: AtTimeZoneContext): ScheduleToolError {
|
||||
if (error.code === 'timezone_confirmation_required') {
|
||||
// The domain emits this code only for the omitted-zone local-at arm,
|
||||
// whose request context is computed immediately before decoding.
|
||||
const requestTimeZone = timeZone as AtTimeZoneContext
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
sessionTimeZone: requestTimeZone.sessionTimeZone,
|
||||
clientTimeZones: requestTimeZone.clientTimeZones,
|
||||
}
|
||||
}
|
||||
function inputError(error: ScheduleInputError): ScheduleToolError {
|
||||
return { code: error.code, message: error.message }
|
||||
}
|
||||
|
||||
@@ -406,7 +297,7 @@ export function registerScheduleTools(
|
||||
description: 'Positive safe-integer delay in seconds.',
|
||||
},
|
||||
at: {
|
||||
description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.',
|
||||
description: 'Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone.',
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
@@ -415,7 +306,7 @@ export function registerScheduleTools(
|
||||
properties: {
|
||||
date: { type: 'string', required: true },
|
||||
time: { type: 'string', required: true },
|
||||
time_zone: { type: 'string' },
|
||||
time_zone: { type: 'string', required: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -434,25 +325,15 @@ export function registerScheduleTools(
|
||||
if (isToolError(folded)) return folded
|
||||
const id = allocateScheduleId(folded)
|
||||
let record: ScheduleRecord
|
||||
let timeZone: AtTimeZoneContext | undefined
|
||||
try {
|
||||
if (args.after_seconds === undefined) {
|
||||
const at = args.at as AtInput
|
||||
timeZone = typeof at === 'string' || at.time_zone !== undefined
|
||||
? undefined
|
||||
: atTimeZoneContext(agent)
|
||||
record = createAtScheduleRecord(
|
||||
id,
|
||||
args.prompt,
|
||||
at,
|
||||
Date.now(),
|
||||
timeZone?.implicitTimeZone,
|
||||
)
|
||||
record = createAtScheduleRecord(id, args.prompt, at, Date.now())
|
||||
} else {
|
||||
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return error instanceof ScheduleInputError ? inputError(error, timeZone) : internalError()
|
||||
return error instanceof ScheduleInputError ? inputError(error) : internalError()
|
||||
}
|
||||
const cancelledBeforeAppend = cancellationPlaceholder(exec.signal)
|
||||
if (cancelledBeforeAppend !== undefined) return cancelledBeforeAppend
|
||||
|
||||
@@ -41,8 +41,8 @@ export interface LocalAtInput {
|
||||
readonly date: string
|
||||
/** Local wall-clock time with optional one-to-three digit milliseconds. */
|
||||
readonly time: string
|
||||
/** Explicit IANA zone; omit only when current request authority permits the Session zone. */
|
||||
readonly time_zone?: string
|
||||
/** Explicit UTC or IANA Area/Location zone. */
|
||||
readonly time_zone: string
|
||||
}
|
||||
|
||||
/** Absolute selector accepted by `schedule_create`. */
|
||||
@@ -116,14 +116,6 @@ export interface InvalidTimeZoneError {
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
/** Stable error returned when a local absolute time needs an explicit zone choice. */
|
||||
export interface TimeZoneConfirmationRequiredError {
|
||||
readonly code: 'timezone_confirmation_required'
|
||||
readonly message: string
|
||||
readonly sessionTimeZone: string
|
||||
readonly clientTimeZones: string[]
|
||||
}
|
||||
|
||||
/** Stable error returned when an absolute target is not strictly future. */
|
||||
export interface NotFutureError {
|
||||
readonly code: 'not_future'
|
||||
@@ -162,7 +154,6 @@ export type ScheduleToolError =
|
||||
| InvalidSelectorError
|
||||
| InvalidRuleError
|
||||
| InvalidTimeZoneError
|
||||
| TimeZoneConfirmationRequiredError
|
||||
| NotFutureError
|
||||
| TimeOutOfRangeError
|
||||
| CorruptScheduleLogError
|
||||
|
||||
@@ -207,19 +207,8 @@ describe('absolute record and time-zone resolution', () => {
|
||||
expect((error as ScheduleInputError).code).toBe('not_future')
|
||||
}
|
||||
}
|
||||
try {
|
||||
createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'x',
|
||||
'9999-12-31T23:59:59.999-23:59',
|
||||
now,
|
||||
)
|
||||
throw new Error('expected range failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('time_out_of_range')
|
||||
}
|
||||
for (const [at, sampleNow] of [
|
||||
['9999-12-31T23:59:59.999-23:59', now],
|
||||
['0001-01-01T00:00:00+23:59', Date.parse('0001-01-01T00:00:00.000Z') - 1],
|
||||
['2026-08-06T01:00:00Z', Number.NaN],
|
||||
] as const) {
|
||||
@@ -248,13 +237,10 @@ describe('absolute record and time-zone resolution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves local calendar time, rejects a gap, and chooses the first overlap instant', () => {
|
||||
it('resolves explicit local time, rejects a DST gap, and chooses the first overlap instant', () => {
|
||||
expect(createAtScheduleRecord(ScheduleId('shanghai'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'Asia/Shanghai',
|
||||
}, now).scheduledAt).toBe('2026-08-06T01:00:00.000Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('implicit'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00.25',
|
||||
}, now, 'Asia/Shanghai').scheduledAt).toBe('2026-08-06T01:00:00.250Z')
|
||||
date: '2026-08-06', time: '09:00:00.25', time_zone: 'Asia/Shanghai',
|
||||
}, now).scheduledAt).toBe('2026-08-06T01:00:00.250Z')
|
||||
expect(createAtScheduleRecord(ScheduleId('utc'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00', time_zone: 'UTC',
|
||||
}, now).scheduledAt).toBe('2026-08-06T09:00:00.000Z')
|
||||
@@ -273,6 +259,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ date: '2026-08-06', time: '09:00:00' }],
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 'UTC', extra: true }],
|
||||
[{ date: 20260806, time: '09:00:00', time_zone: 'UTC' }],
|
||||
[{ date: '2026-08-06', time: '09:00:00', time_zone: 8 }],
|
||||
@@ -289,7 +276,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
)).toThrow(ScheduleInputError)
|
||||
})
|
||||
|
||||
it('rejects empty at prompts and local instants outside the four-digit range', () => {
|
||||
it('rejects empty prompts and local instants outside the four-digit range', () => {
|
||||
expect(() => createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now,
|
||||
)).toThrow(ScheduleInputError)
|
||||
@@ -304,19 +291,7 @@ describe('absolute record and time-zone resolution', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when local calendar input has no confirmed zone', () => {
|
||||
try {
|
||||
createAtScheduleRecord(ScheduleId('schedule-at'), 'x', {
|
||||
date: '2026-08-06', time: '09:00:00',
|
||||
}, now)
|
||||
throw new Error('expected confirmation failure')
|
||||
} catch (error: unknown) {
|
||||
expect(error).toBeInstanceOf(ScheduleInputError)
|
||||
expect((error as ScheduleInputError).code).toBe('timezone_confirmation_required')
|
||||
}
|
||||
})
|
||||
|
||||
it('derives an at view and reminder framing without persisting input interpretation', () => {
|
||||
it('derives an at view and model framing without persisting input interpretation', () => {
|
||||
const record = createAtScheduleRecord(
|
||||
ScheduleId('schedule-at'),
|
||||
'join meeting',
|
||||
@@ -329,9 +304,5 @@ describe('absolute record and time-zone resolution', () => {
|
||||
deliveryMode: 'session-local',
|
||||
})
|
||||
expect(renderReminderFraming(record)).toContain('occurrence_at: 2026-08-06T01:00:00.000Z')
|
||||
expect(scheduleReminderPresentation([
|
||||
scheduleEvent(atCreateData(), 0),
|
||||
scheduleEvent({ version: 1, operation: 'dispatch', id: 'schedule-at' }, 1),
|
||||
], 1)).toMatchObject({ scheduleId: 'schedule-at', occurrenceAt: '2026-08-06T01:00:00.000Z' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentCancelCause, InboxTarget } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -22,10 +22,8 @@ interface ToolHarness {
|
||||
readonly disposeTools: () => void
|
||||
}
|
||||
|
||||
function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
|
||||
const session = ctx.sessions.create(SessionId(id), {
|
||||
...(timeZone === undefined ? {} : { meta: { timeZone } }),
|
||||
})
|
||||
function stubAgent(ctx: Context, id: string): Agent {
|
||||
const session = ctx.sessions.create(SessionId(id))
|
||||
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
|
||||
return {
|
||||
id: session.id,
|
||||
@@ -35,23 +33,23 @@ function stubAgent(ctx: Context, id: string, timeZone?: string): Agent {
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send(_message: UserMessage, _target: InboxTarget, _wakeup: boolean) {},
|
||||
runMaintenance: task => task(signal),
|
||||
cancel(_cause: AgentCancelCause) {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
runMaintenance: task => task(signal),
|
||||
followup(_message: UserMessage) {},
|
||||
steer(_message: UserMessage) {},
|
||||
inject(_message: UserMessage) {},
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(withPersistence = true, timeZone?: string): Promise<ToolHarness> {
|
||||
async function harness(withPersistence = true): Promise<ToolHarness> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
await ctx.plugin(ToolRegistry)
|
||||
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`, timeZone)
|
||||
const agent = stubAgent(ctx, `schedule-tools-${Math.random()}`)
|
||||
ctx.agents.register(agent)
|
||||
const flushes = { count: 0, outcomes: [] as Array<'resolve' | 'reject' | Promise<'resolve' | 'reject'>> }
|
||||
if (withPersistence) {
|
||||
@@ -91,25 +89,6 @@ function value(result: ToolExecutionResult): unknown {
|
||||
return result.value
|
||||
}
|
||||
|
||||
function appendRequestContext(agent: Agent, clientTimeZones: readonly string[]): void {
|
||||
for (const [index, clientTimeZone] of clientTimeZones.entries()) {
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${index + 1}` }],
|
||||
source: { kind: 'user', rpcId: `request-zone-${String(index + 1)}`, clientTimeZone } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
const text = 'time context'
|
||||
agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: [{ name: 'time-context', text }],
|
||||
},
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-08-05T12:00:00.000Z'))
|
||||
@@ -225,7 +204,7 @@ describe('Schedule tool protocol', () => {
|
||||
expect(test.flushes.count).toBe(0)
|
||||
})
|
||||
|
||||
it('creates explicit-offset and explicit-zone at records without persisting their interpretation', async () => {
|
||||
it('creates offset and explicit-zone at records without persisting their input interpretation', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'join meeting', at: '2026-08-06T09:00:00+08:00',
|
||||
@@ -255,203 +234,6 @@ describe('Schedule tool protocol', () => {
|
||||
expect(changes[0]?.data).not.toHaveProperty('time_zone')
|
||||
})
|
||||
|
||||
it('fails closed when local at lacks confirmed request-zone context', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'ambiguous', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toEqual({
|
||||
code: 'timezone_confirmation_required',
|
||||
message: 'Local at requires an explicit time_zone for this request.',
|
||||
sessionTimeZone: 'unavailable',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
expect(test.flushes.count).toBe(1)
|
||||
expect(test.agent.session.events.filter(event => event.type === 'schedule/change')).toEqual([])
|
||||
|
||||
const unmarked = await harness(true, 'Asia/Shanghai')
|
||||
unmarked.agent.session.append('turn/start', { turn: 1 })
|
||||
unmarked.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
unmarked.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request without time reading' }],
|
||||
source: { kind: 'user', rpcId: 'unmarked-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
expect(value(await execute(unmarked, 'schedule_create', {
|
||||
prompt: 'unmarked', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the current turn request zones behind a current-step time-context marker', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'implicit local', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
kind: 'at',
|
||||
scheduledAt: '2026-08-06T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the actual Session and request zones when implicit local at needs confirmation', async () => {
|
||||
const mismatch = await harness(true, 'Asia/Shanghai')
|
||||
mismatch.agent.session.append('turn/start', { turn: 1 })
|
||||
mismatch.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(mismatch.agent, ['America/New_York'])
|
||||
expect(value(await execute(mismatch, 'schedule_create', {
|
||||
prompt: 'mismatch', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toEqual({
|
||||
code: 'timezone_confirmation_required',
|
||||
message: 'Local at requires an explicit time_zone for this request.',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: ['America/New_York'],
|
||||
})
|
||||
|
||||
const mixed = await harness(true, 'Asia/Shanghai')
|
||||
mixed.agent.session.append('turn/start', { turn: 1 })
|
||||
mixed.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(mixed.agent, ['Asia/Shanghai', 'America/New_York'])
|
||||
expect(value(await execute(mixed, 'schedule_create', {
|
||||
prompt: 'mixed', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: ['America/New_York', 'Asia/Shanghai'],
|
||||
})
|
||||
|
||||
const unavailable = await harness()
|
||||
unavailable.agent.session.append('turn/start', { turn: 1 })
|
||||
unavailable.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(unavailable.agent, ['America/New_York'])
|
||||
expect(value(await execute(unavailable, 'schedule_create', {
|
||||
prompt: 'legacy', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'unavailable',
|
||||
clientTimeZones: ['America/New_York'],
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses a same-turn snapshot marker across an empty continuation and ignores a malformed source', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 2 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'malformed authority' }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
authority: { turn: 1, step: 2, session: { kind: 'unavailable' }, client: { kind: 'future' } },
|
||||
} as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'same-turn local', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
kind: 'at',
|
||||
scheduledAt: '2026-08-06T01:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let an array-like snapshot marker authorize an implicit local at', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: { kind: 'user', rpcId: 'array-like-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
const text = 'time context'
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: {
|
||||
kind: 'plugin',
|
||||
plugin: 'time-context',
|
||||
form: 'snapshot',
|
||||
sections: { 0: { name: 'time-context', text }, length: 1 },
|
||||
} as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-object text block', 7, [{ name: 'time-context', text: 'time context' }]],
|
||||
['matched non-string text', { type: 'text', text: 7 }, [{ name: 'time-context', text: 7 }]],
|
||||
['extra text-block field', { type: 'text', text: 'time context', extra: true }, [{ name: 'time-context', text: 'time context' }]],
|
||||
['extra section field', { type: 'text', text: 'time context' }, [{ name: 'time-context', text: 'time context', extra: true }]],
|
||||
] as const)(
|
||||
'does not let snapshot provenance with %s authorize an implicit local at',
|
||||
async (_name, block, sections) => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'request' }],
|
||||
source: { kind: 'user', rpcId: 'malformed-marker-request', clientTimeZone: 'Asia/Shanghai' } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
test.agent.session.append('user/message', createUserMessage({
|
||||
content: [block as never],
|
||||
source: { kind: 'plugin', plugin: 'time-context', form: 'snapshot', sections } as never,
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'malformed marker', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
code: 'timezone_confirmation_required',
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['step/end', 'turn/end'] as const)(
|
||||
'fails closed after the current %s boundary',
|
||||
async (boundary) => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('turn/start', { turn: 1 })
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
test.agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
if (boundary === 'turn/end') {
|
||||
test.agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
}
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: `closed ${boundary}`,
|
||||
at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('fails closed when an open step has no owning turn boundary', async () => {
|
||||
const test = await harness(true, 'Asia/Shanghai')
|
||||
test.agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
appendRequestContext(test.agent, ['Asia/Shanghai'])
|
||||
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
prompt: 'missing turn', at: { date: '2026-08-06', time: '09:00:00' },
|
||||
}))).toMatchObject({
|
||||
sessionTimeZone: 'Asia/Shanghai',
|
||||
clientTimeZones: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns stable at validation errors after persistence preflight', async () => {
|
||||
const test = await harness()
|
||||
expect(value(await execute(test, 'schedule_create', {
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../context/time-context"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user