dsh-cherry-bridge 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +30 -16
- package/README.md +19 -11
- package/dist/index.mjs +111 -20
- package/package.json +1 -1
- package/src/defaults.ts +5 -0
- package/src/tools.ts +200 -51
package/README.en.md
CHANGED
|
@@ -38,13 +38,13 @@ flowchart TB
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
# From npm (recommended, pinned)
|
|
41
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.
|
|
41
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
|
|
42
42
|
|
|
43
43
|
# Install the latest npm release
|
|
44
44
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
45
45
|
|
|
46
46
|
# From GitHub source (same commit as the npm release)
|
|
47
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.
|
|
47
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
|
|
@@ -103,15 +103,15 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
|
|
|
103
103
|
| Tool | What it does |
|
|
104
104
|
| --- | --- |
|
|
105
105
|
| `cherry_devices` | List every paired device and its connection state (`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`). |
|
|
106
|
-
| `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling)
|
|
107
|
-
| `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason
|
|
108
|
-
| `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`)
|
|
109
|
-
| `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
|
|
106
|
+
| `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and filters to continue from that stop point (`limit` then bounds the next answer). A token from another tool, device or filter set is refused **locally**, never forwarded. |
|
|
107
|
+
| `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` with the same filters to continue. |
|
|
108
|
+
| `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and **identical filters** to continue (`limit` then bounds the next answer); a token from another tool, device or filter set is refused **locally**. |
|
|
109
|
+
| `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. When the stop point cannot be named the token is **absent** (`truncated` stays `true`, and the absence does **not** mean the history ended); a wider `limit` is not a general remedy — it moves a `limit` stop only, while an `aggregate-bytes` stop leaves the older messages unreachable in that answer (see [Errors and limits](#errors-and-limits)). Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
|
|
110
110
|
| `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. The answer is `{events, nextCursor}`; pass that `nextCursor` back as `cursor` to continue from it. |
|
|
111
111
|
| `cherry_create` | Create a session in an agent workspace (workspace selection is **required**; identity is read back). |
|
|
112
112
|
| `cherry_send` | Send a message, by default waiting for a terminal state and returning the assistant text; `wait: false` returns the command receipt immediately. `messagesTruncated`/`messagesTruncationReason` report a **message**-level cut (older messages dropped); `deadline` means the **answer's own assembly budget** ran out (see below), with the content read so far still returned. |
|
|
113
113
|
| `cherry_wait` | Wait only for the session's in-flight execution to settle (sends nothing). |
|
|
114
|
-
| `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason
|
|
114
|
+
| `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue (`limit` counts the peer's pre-filter rows). |
|
|
115
115
|
| `cherry_respond` | Answer an interaction card: questions must be answered in full; approvals take `decision`. |
|
|
116
116
|
| `cherry_cancel` | Cancel the session's in-flight execution (`expectedExecutionId` required). |
|
|
117
117
|
| `cherry_pair` | Pair with a desktop using the pairing information shown by the scanned page. |
|
|
@@ -124,12 +124,12 @@ The `device` argument accepts an **exact `deviceId`**, or a **prefix of a
|
|
|
124
124
|
`cherry_send(wait: true)` / `cherry_wait` results carry one of **8 `status`
|
|
125
125
|
values**: the four protocol terminal states
|
|
126
126
|
(`completed`/`failed`/`cancelled`/`interrupted`), plus `awaiting-interaction`
|
|
127
|
-
(a pending card appeared while the run is still alive), `timeout` (the window
|
|
128
|
-
out, **not a failure** — partial content plus a stop-point
|
|
129
|
-
`idle` (`cherry_wait` met a session with nothing
|
|
130
|
-
snapshot immediately), and `subscription-lost` (the
|
|
131
|
-
could not be rebuilt, so the outcome is
|
|
132
|
-
with it).
|
|
127
|
+
(a pending card appeared while the run is still alive), `timeout` (the window
|
|
128
|
+
ran out, **not a failure** — partial content comes back, plus a stop-point
|
|
129
|
+
cursor when one can be named), `idle` (`cherry_wait` met a session with nothing
|
|
130
|
+
in flight and returned the snapshot immediately), and `subscription-lost` (the
|
|
131
|
+
subscription was lost and could not be rebuilt, so the outcome is
|
|
132
|
+
**unknowable**; repair guidance ships with it).
|
|
133
133
|
|
|
134
134
|
## Errors and limits
|
|
135
135
|
|
|
@@ -152,9 +152,23 @@ with it).
|
|
|
152
152
|
- A truncated history walk always says so (`truncationReason`; `limit`,
|
|
153
153
|
`aggregate-bytes` and `deadline` mean different things, see above) and,
|
|
154
154
|
whenever a stop point can be named honestly, carries a `messagesResumeCursor`
|
|
155
|
-
resume token
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
resume token. **When it cannot be named the token is absent**: the peer's
|
|
156
|
+
position is empty, carries leading/trailing whitespace or a control character,
|
|
157
|
+
looks like a retired v1 token, the token would exceed the 4,096-character
|
|
158
|
+
ceiling, or there is no message id to anchor on. The absence does **not** mean
|
|
159
|
+
the history ended (`truncated` stays `true`); the remedy follows the reason —
|
|
160
|
+
a `limit` stop answers to a wider `limit` only where the caller owns that
|
|
161
|
+
ceiling (`cherry_read` and the four list tools; a **wait answer's** `limit` is
|
|
162
|
+
its own fixed allowance, so read further with `cherry_read`), an
|
|
163
|
+
`aggregate-bytes` stop does not (nothing reaches past it in that answer), a
|
|
164
|
+
`safety-bound` stop is an internal circuit breaker no caller can move, and a
|
|
165
|
+
wait answer cut by its deadline is continued with `cherry_read`, which carries
|
|
166
|
+
no deadline. **Both faces obey one rule**:
|
|
167
|
+
the message face's `messagesResumeCursor` and the four list tools'
|
|
168
|
+
`resumeCursor` are emitted only when this tool would accept them back; an
|
|
169
|
+
interior plain space is legal and is not a reason to withhold one.
|
|
170
|
+
A message whose own parts could not be fully read carries `incomplete: true`
|
|
171
|
+
(inside a wait answer, a message cut by the deadline carries
|
|
158
172
|
`partsTruncated: "deadline"`).
|
|
159
173
|
- **A wait answer has an assembly budget of its own.** After the window ends,
|
|
160
174
|
`cherry_send(wait: true)` / `cherry_wait` still read some history (the message
|
package/README.md
CHANGED
|
@@ -36,13 +36,13 @@ flowchart TB
|
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# 从 npm 安装(推荐,钉住版本)
|
|
39
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.
|
|
39
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
|
|
40
40
|
|
|
41
41
|
# 安装 npm 最新发布版
|
|
42
42
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
43
43
|
|
|
44
44
|
# 从 GitHub 源码安装(与 npm 发布同一提交)
|
|
45
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.
|
|
45
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
|
|
@@ -94,15 +94,15 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
94
94
|
| 工具 | 作用 |
|
|
95
95
|
| --- | --- |
|
|
96
96
|
| `cherry_devices` | 列出已配对设备及连接状态(`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`)。 |
|
|
97
|
-
| `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000
|
|
98
|
-
| `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason
|
|
99
|
-
| `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound
|
|
100
|
-
| `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit`
|
|
97
|
+
| `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000 条内部上限);停点可命名时还给出 `resumeCursor`——用同一 device 与过滤条件把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**,不会转发给对端。 |
|
|
98
|
+
| `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用相同过滤条件回传为 `cursor` 即可续读。 |
|
|
99
|
+
| `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`),停点可命名时还给出 `resumeCursor`——用同一 device 与**相同过滤条件**把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**。 |
|
|
100
|
+
| `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit` 此时约束的是**从停点起**本次最多返回多少条),长历史分几次回答读完。停点不可命名时**没有**这个令牌(`truncated` 仍为 `true`,**缺失本身不代表历史已到底**;补救按 `truncationReason` 分:`limit` 可以提高 `limit`,`aggregate-bytes` 不行——被挡住的旧消息在这一回答里拿不到,详见[错误与限制](#错误与限制))。两种令牌:`cursor:<peer position>`(整窗读完后的 peer 分页位置)与 `after:<messageId>`(停在窗内,续读从最新端扫到该消息、返回它更旧的那些)。不是本工具发出的令牌会被**本地拒绝**(不转发给桌面端——桌面端会静默回退到最新页);`after:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
|
|
101
101
|
| `cherry_events` | 读取该会话缓冲区内比 `cursor` 更新的事件,并在投递后 ack。返回 `{events, nextCursor}`;把 `nextCursor` 作为下次的 `cursor` 传回即可续读。 |
|
|
102
102
|
| `cherry_create` | 在 agent 工作区创建会话(工作区选择**必填**;创建后回读身份)。 |
|
|
103
103
|
| `cherry_send` | 向会话发消息,默认等待运行到终态并返回助手文本;`wait: false` 立即返回命令回执。内容被截断时 `messagesTruncated`/`messagesTruncationReason` 说的是**消息**被截断(丢更旧的);`deadline` 表示**回答自身的装配时限**到了(见下),已读到的内容照常返回。 |
|
|
104
104
|
| `cherry_wait` | 只等待该会话在途执行收敛(不发送新消息)。 |
|
|
105
|
-
| `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason
|
|
105
|
+
| `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
|
|
106
106
|
| `cherry_respond` | 应答交互卡:提问必须全量作答,审批走 `decision`。 |
|
|
107
107
|
| `cherry_cancel` | 取消会话在途执行(`expectedExecutionId` 必填)。 |
|
|
108
108
|
| `cherry_pair` | 用「扫码后页面显示的配对信息」建立与桌面端的配对。 |
|
|
@@ -113,7 +113,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
113
113
|
|
|
114
114
|
`cherry_send(wait: true)` / `cherry_wait` 的 `status` 有 **8 个取值**:四个协议终态
|
|
115
115
|
(`completed`/`failed`/`cancelled`/`interrupted`),加上 `awaiting-interaction`(出现待答
|
|
116
|
-
交互卡,运行仍活着)、`timeout
|
|
116
|
+
交互卡,运行仍活着)、`timeout`(窗口用尽,**不是失败**,随附部分内容,停点可命名时再带游标)、
|
|
117
117
|
`idle`(`cherry_wait` 遇到没有在途执行的会话,立即返回快照)、`subscription-lost`
|
|
118
118
|
(事件订阅丢失且重建失败,运行结局**不可知**,随附修复指引)。
|
|
119
119
|
|
|
@@ -128,9 +128,17 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
128
128
|
不在这个乘积里(每次并发读最多再带一整条消息,因为输出预算只在消息之间检查),parts 缓存另计。
|
|
129
129
|
等待槽位也计入等待类回答的时限:排到时限就放弃并把这一 part 标 `unavailable`。
|
|
130
130
|
- 历史走查被截断时一定说明(`truncationReason`,`limit` / `aggregate-bytes` / `deadline` 三种含义不同,见上),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`
|
|
131
|
+
并在停点可命名时给出 `messagesResumeCursor` 续读令牌。**停点不可命名时就不给**:对端位置为空、
|
|
132
|
+
带首尾空白或任意控制字符、形如已停用的 v1 令牌、令牌会超过 4096 字符上限、或没有可当锚点的
|
|
133
|
+
消息 id——此时 `truncated` 仍为 `true`,**缺失本身不代表历史已到底**。补救按 `truncationReason`
|
|
134
|
+
分:`limit` 停点只有在调用方自己握着上限时才靠更宽的 `limit`(`cherry_read` 与四个列表工具);
|
|
135
|
+
**wait 类回答的 `limit` 是它自己的固定条数额度**,更旧的历史要用 `cherry_read` 去读。
|
|
136
|
+
`aggregate-bytes` 停点不行——预算是它停下的原因,更宽的 `limit` 只会多付往返,被挡住的旧消息
|
|
137
|
+
在这一回答里拿不到;`safety-bound` 停点是内部熔断,调用方动不了它;`deadline`(只出现在 wait
|
|
138
|
+
类回答)同样改用 `cherry_read` 续读,它没有时限。
|
|
139
|
+
**两个面是同一条规则**:消息面的 `messagesResumeCursor` 与四个列表工具的
|
|
140
|
+
`resumeCursor` 都只发出本工具会收回的令牌;位置内部的普通空格合法,不会因此被拒。
|
|
141
|
+
某条消息自身 parts 没读全时带 `incomplete: true`(wait 类回答里被时限截断的那条消息会带 `partsTruncated: "deadline"`)。
|
|
134
142
|
- **等待类回答有自己的装配时限**:`cherry_send(wait: true)` / `cherry_wait` 在窗口结束后仍要读一段历史
|
|
135
143
|
(消息页 + 每条消息的 parts + 每个外置 `{ref}` 内容),这一步现在是**有时限的**——截至「你的窗口 +
|
|
136
144
|
同样长度的时间」,下限 1 秒、上限 15 秒(即窗口 1 秒 → 最多再等 1 秒;窗口 20 秒 → 最多再等 15 秒)。
|
package/dist/index.mjs
CHANGED
|
@@ -2274,6 +2274,11 @@ const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
|
|
|
2274
2274
|
* BOUND instead of being split, hashed and carried to the peer, and so both
|
|
2275
2275
|
* parameters can declare `maxLength` honestly. ONE number for both faces: it
|
|
2276
2276
|
* bounds caller INPUT, not what a token means.
|
|
2277
|
+
*
|
|
2278
|
+
* The emit side enforces it too (#76): a stop point whose token would exceed
|
|
2279
|
+
* this is not named at all, because a token this tool refuses to replay is
|
|
2280
|
+
* worse than no token — `truncated` still says the walk was cut. One number,
|
|
2281
|
+
* judged in both directions.
|
|
2277
2282
|
*/
|
|
2278
2283
|
const MAX_RESUME_TOKEN_CHARS = 4096;
|
|
2279
2284
|
//#endregion
|
|
@@ -5477,12 +5482,13 @@ var CherryToolEngine = class {
|
|
|
5477
5482
|
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
5478
5483
|
*/
|
|
5479
5484
|
#listPage(page, binding) {
|
|
5480
|
-
const position = optionalString(page.nextCursor);
|
|
5485
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
5486
|
+
const token = !page.truncated || position === void 0 ? void 0 : withinResumeTokenCeiling(encodeListResumeCursor(binding, position));
|
|
5481
5487
|
return {
|
|
5482
5488
|
items: page.items,
|
|
5483
5489
|
truncated: page.truncated,
|
|
5484
5490
|
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
|
|
5485
|
-
...
|
|
5491
|
+
...token === void 0 ? {} : { resumeCursor: token }
|
|
5486
5492
|
};
|
|
5487
5493
|
}
|
|
5488
5494
|
#devicesTool() {
|
|
@@ -5561,7 +5567,7 @@ var CherryToolEngine = class {
|
|
|
5561
5567
|
#agentsTool() {
|
|
5562
5568
|
return defineCherryTool({
|
|
5563
5569
|
name: "cherry_agents",
|
|
5564
|
-
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5570
|
+
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5565
5571
|
parameters: {
|
|
5566
5572
|
type: "object",
|
|
5567
5573
|
properties: {
|
|
@@ -5604,7 +5610,7 @@ var CherryToolEngine = class {
|
|
|
5604
5610
|
#workspacesTool() {
|
|
5605
5611
|
return defineCherryTool({
|
|
5606
5612
|
name: "cherry_workspaces",
|
|
5607
|
-
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
5613
|
+
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` with the same filters to continue.",
|
|
5608
5614
|
parameters: {
|
|
5609
5615
|
type: "object",
|
|
5610
5616
|
properties: {
|
|
@@ -5659,7 +5665,7 @@ var CherryToolEngine = class {
|
|
|
5659
5665
|
#sessionsTool() {
|
|
5660
5666
|
return defineCherryTool({
|
|
5661
5667
|
name: "cherry_sessions",
|
|
5662
|
-
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5668
|
+
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5663
5669
|
parameters: {
|
|
5664
5670
|
type: "object",
|
|
5665
5671
|
properties: {
|
|
@@ -5716,7 +5722,7 @@ var CherryToolEngine = class {
|
|
|
5716
5722
|
#readTool() {
|
|
5717
5723
|
return defineCherryTool({
|
|
5718
5724
|
name: "cherry_read",
|
|
5719
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
5725
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
5720
5726
|
parameters: {
|
|
5721
5727
|
type: "object",
|
|
5722
5728
|
properties: {
|
|
@@ -5732,7 +5738,7 @@ var CherryToolEngine = class {
|
|
|
5732
5738
|
type: "integer",
|
|
5733
5739
|
minimum: 1,
|
|
5734
5740
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5735
|
-
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token is how older messages are reached."
|
|
5741
|
+
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token, when one is returned, is how older messages are reached."
|
|
5736
5742
|
},
|
|
5737
5743
|
messagesResumeCursor: {
|
|
5738
5744
|
type: "string",
|
|
@@ -6491,7 +6497,7 @@ var CherryToolEngine = class {
|
|
|
6491
6497
|
#interactionsTool() {
|
|
6492
6498
|
return defineCherryTool({
|
|
6493
6499
|
name: "cherry_interactions",
|
|
6494
|
-
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
6500
|
+
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue.",
|
|
6495
6501
|
parameters: {
|
|
6496
6502
|
type: "object",
|
|
6497
6503
|
properties: {
|
|
@@ -7674,9 +7680,11 @@ var CherryToolEngine = class {
|
|
|
7674
7680
|
}
|
|
7675
7681
|
/**
|
|
7676
7682
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
7677
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7678
|
-
* the unread history begins
|
|
7679
|
-
* or
|
|
7683
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7684
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
7685
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
7686
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
7687
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7680
7688
|
*
|
|
7681
7689
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
7682
7690
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -7903,9 +7911,10 @@ var CherryToolEngine = class {
|
|
|
7903
7911
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
7904
7912
|
* and continues strictly after it.
|
|
7905
7913
|
*
|
|
7906
|
-
* The token is
|
|
7907
|
-
*
|
|
7908
|
-
*
|
|
7914
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
7915
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
7916
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
7917
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7909
7918
|
*/
|
|
7910
7919
|
async #readTerminalMessages(connection, sessionId, session, deadline) {
|
|
7911
7920
|
const historyRevision = optionalString(session["historyRevision"]);
|
|
@@ -8135,14 +8144,90 @@ function parseQuestionInput(input) {
|
|
|
8135
8144
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
8136
8145
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
8137
8146
|
* peer's page position is exactly where the unread history begins
|
|
8138
|
-
* (`cursor:<pos>`)
|
|
8139
|
-
*
|
|
8147
|
+
* (`cursor:<pos>`).
|
|
8148
|
+
*
|
|
8149
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
8150
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
8151
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
8152
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
8153
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
8154
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
8155
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
8156
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
8157
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
8158
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
8159
|
+
* id really can reach the anchor.
|
|
8160
|
+
*
|
|
8161
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
8162
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
8163
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
8164
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
8165
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
8166
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
8167
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
8168
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
8169
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
8170
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
8171
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
8140
8172
|
*/
|
|
8141
8173
|
function messageResumeToken(page, returned, lastReturnedId) {
|
|
8142
|
-
const anchor = optionalString(lastReturnedId);
|
|
8143
|
-
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : `${RESUME_ANCHOR_PREFIX}${anchor}
|
|
8144
|
-
const position = optionalString(page.nextCursor);
|
|
8145
|
-
return position === void 0 ? void 0 : `${RESUME_CURSOR_PREFIX}${position}
|
|
8174
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId));
|
|
8175
|
+
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`);
|
|
8176
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
8177
|
+
return position === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`);
|
|
8178
|
+
}
|
|
8179
|
+
/**
|
|
8180
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
8181
|
+
* when it must not name one.
|
|
8182
|
+
*
|
|
8183
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
8184
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
8185
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
8186
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
8187
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
8188
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
8189
|
+
* is legal and round-trips.
|
|
8190
|
+
*
|
|
8191
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
8192
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
8193
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
8194
|
+
*/
|
|
8195
|
+
function nameableRemainder(value) {
|
|
8196
|
+
return value === void 0 || mangledRemainder(value) ? void 0 : value;
|
|
8197
|
+
}
|
|
8198
|
+
/**
|
|
8199
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
8200
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
8201
|
+
*
|
|
8202
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
8203
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
8204
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
8205
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
8206
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
8207
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
8208
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
8209
|
+
*
|
|
8210
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
8211
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
8212
|
+
* shape is a token it accepts.
|
|
8213
|
+
*/
|
|
8214
|
+
function nameablePeerPosition(value) {
|
|
8215
|
+
const remainder = nameableRemainder(value);
|
|
8216
|
+
return remainder === void 0 || looksLikeV1ListToken(remainder) ? void 0 : remainder;
|
|
8217
|
+
}
|
|
8218
|
+
/**
|
|
8219
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
8220
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
8221
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
8222
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
8223
|
+
* stop point whose token would push past
|
|
8224
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
8225
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
8226
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
8227
|
+
* the parser takes back.
|
|
8228
|
+
*/
|
|
8229
|
+
function withinResumeTokenCeiling(token) {
|
|
8230
|
+
return token.length <= 4096 ? token : void 0;
|
|
8146
8231
|
}
|
|
8147
8232
|
/**
|
|
8148
8233
|
* The message-walk verdict, projected off an aggregate page.
|
|
@@ -8240,6 +8325,12 @@ function messageIdOf(raw) {
|
|
|
8240
8325
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
8241
8326
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
8242
8327
|
* foreign token: an empty anchor could never match a message.
|
|
8328
|
+
*
|
|
8329
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
8330
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
8331
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
8332
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
8333
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
8243
8334
|
*/
|
|
8244
8335
|
function parseMessageResume(value) {
|
|
8245
8336
|
if (value === void 0 || value === null) return void 0;
|
package/package.json
CHANGED
package/src/defaults.ts
CHANGED
|
@@ -151,5 +151,10 @@ export const LIST_PAGE_CLAMP = 16
|
|
|
151
151
|
* BOUND instead of being split, hashed and carried to the peer, and so both
|
|
152
152
|
* parameters can declare `maxLength` honestly. ONE number for both faces: it
|
|
153
153
|
* bounds caller INPUT, not what a token means.
|
|
154
|
+
*
|
|
155
|
+
* The emit side enforces it too (#76): a stop point whose token would exceed
|
|
156
|
+
* this is not named at all, because a token this tool refuses to replay is
|
|
157
|
+
* worse than no token — `truncated` still says the walk was cut. One number,
|
|
158
|
+
* judged in both directions.
|
|
154
159
|
*/
|
|
155
160
|
export const MAX_RESUME_TOKEN_CHARS = 4_096
|
package/src/tools.ts
CHANGED
|
@@ -391,9 +391,15 @@ export interface CherryReadSnapshot {
|
|
|
391
391
|
*
|
|
392
392
|
* Present exactly when `truncated` is true and a stop point can be named
|
|
393
393
|
* honestly; because the walk runs newest-first, it marks OLDER messages as
|
|
394
|
-
* still unread — the only direction a truncation leaves behind.
|
|
395
|
-
*
|
|
396
|
-
*
|
|
394
|
+
* still unread — the only direction a truncation leaves behind.
|
|
395
|
+
*
|
|
396
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
|
|
397
|
+
* apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
|
|
398
|
+
* TRUNCATED walk whose stop point could not be named (`truncated: true` plus
|
|
399
|
+
* `truncationReason` — older messages still unread). The absence therefore
|
|
400
|
+
* never means "nothing older left" on its own. WHICH stops can be named is
|
|
401
|
+
* decided in ONE place ({@link messageResumeToken}); the refusal cases are not
|
|
402
|
+
* restated here.
|
|
397
403
|
*
|
|
398
404
|
* WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
|
|
399
405
|
* the caller's own ceiling: raise `limit`, or resume with this token to walk
|
|
@@ -403,6 +409,12 @@ export interface CherryReadSnapshot {
|
|
|
403
409
|
* messages are reached. From the resume point `limit` bounds how many messages
|
|
404
410
|
* ONE answer carries, so a long history is walked in successive answers, each
|
|
405
411
|
* carrying the next stop point.
|
|
412
|
+
*
|
|
413
|
+
* WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
|
|
414
|
+
* still reachable by widening `limit`; a `'safety-bound'` stop is the internal
|
|
415
|
+
* circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
|
|
416
|
+
* stop has no remedy in that answer at all — the budget, not the ceiling, is
|
|
417
|
+
* what stopped it. The loss is stated rather than repaired.
|
|
406
418
|
*/
|
|
407
419
|
messagesResumeCursor?: string
|
|
408
420
|
}
|
|
@@ -447,10 +459,19 @@ export interface CherryMessageWalkVerdict {
|
|
|
447
459
|
* `cursor:<peer position>` when the whole collected window was returned;
|
|
448
460
|
* `after:<last returned message id>` when the byte stop happened INSIDE that
|
|
449
461
|
* window, because the peer's position sits past messages this walk trimmed
|
|
450
|
-
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
462
|
+
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
463
|
+
*
|
|
464
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
|
|
465
|
+
* them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
|
|
466
|
+
* older left), and a TRUNCATED walk whose stop point could not be named
|
|
467
|
+
* (`messagesTruncated: true` plus `messagesTruncationReason` — older history
|
|
468
|
+
* still unread). The absence never means "nothing older left" on its own.
|
|
469
|
+
* WHICH stops can be named is decided in ONE place
|
|
470
|
+
* ({@link messageResumeToken}) and is the same rule as
|
|
471
|
+
* {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
|
|
472
|
+
* ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
|
|
473
|
+
* allowance — these tools take no `limit` argument — so the way further is
|
|
474
|
+
* `cherry_read`, which also carries no deadline.
|
|
454
475
|
*/
|
|
455
476
|
messagesResumeCursor?: string
|
|
456
477
|
}
|
|
@@ -2100,19 +2121,31 @@ class CherryToolEngine {
|
|
|
2100
2121
|
#listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
|
|
2101
2122
|
// A position this face would REFUSE is not a position it may name. The
|
|
2102
2123
|
// round-trip symmetry (a token a tool emits is a token that tool accepts) is
|
|
2103
|
-
// the whole point of the token, so a peer answering `nextCursor: ''
|
|
2104
|
-
//
|
|
2105
|
-
//
|
|
2106
|
-
//
|
|
2107
|
-
//
|
|
2108
|
-
|
|
2124
|
+
// the whole point of the token, so a peer answering `nextCursor: ''`, one
|
|
2125
|
+
// carrying whitespace/control characters, or one shaped like our retired v1
|
|
2126
|
+
// token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
|
|
2127
|
+
// and a TRUNCATED one whose stop could not be named — and only `truncated`
|
|
2128
|
+
// tells them apart. The remedy follows the REASON: a `'limit'` stop answers
|
|
2129
|
+
// to a wider `limit`; a `'safety-bound'` stop does not, because it is an
|
|
2130
|
+
// internal circuit breaker a caller cannot move. The rule is NOT restated
|
|
2131
|
+
// here or in the message face:
|
|
2132
|
+
// `nameablePeerPosition` is the one place it lives, so the emitters and
|
|
2133
|
+
// their parsers cannot drift (issue #85).
|
|
2134
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
2135
|
+
// The same refusal one dimension further: an encoded token over
|
|
2136
|
+
// {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
|
|
2137
|
+
// it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
|
|
2138
|
+
// on the arm that NAMES it — a complete walk has nothing to name, so it must
|
|
2139
|
+
// not pay for the encoding.
|
|
2140
|
+
const token =
|
|
2141
|
+
!page.truncated || position === undefined
|
|
2142
|
+
? undefined
|
|
2143
|
+
: withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
|
|
2109
2144
|
return {
|
|
2110
2145
|
items: page.items,
|
|
2111
2146
|
truncated: page.truncated,
|
|
2112
2147
|
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
2113
|
-
...(
|
|
2114
|
-
? { resumeCursor: encodeListResumeCursor(binding, position) }
|
|
2115
|
-
: {}),
|
|
2148
|
+
...(token === undefined ? {} : { resumeCursor: token }),
|
|
2116
2149
|
}
|
|
2117
2150
|
}
|
|
2118
2151
|
|
|
@@ -2215,9 +2248,10 @@ class CherryToolEngine {
|
|
|
2215
2248
|
'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
|
|
2216
2249
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2217
2250
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2218
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2219
|
-
'
|
|
2220
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2251
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2252
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2253
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2254
|
+
'answer).',
|
|
2221
2255
|
parameters: {
|
|
2222
2256
|
type: 'object',
|
|
2223
2257
|
properties: {
|
|
@@ -2258,8 +2292,9 @@ class CherryToolEngine {
|
|
|
2258
2292
|
'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
|
|
2259
2293
|
'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
|
|
2260
2294
|
'is only observable by creating a session in it and reading it back. A truncated ' +
|
|
2261
|
-
'answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
2262
|
-
'`resumeCursor
|
|
2295
|
+
'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
|
|
2296
|
+
'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
|
|
2297
|
+
'filters to continue.',
|
|
2263
2298
|
parameters: {
|
|
2264
2299
|
type: 'object',
|
|
2265
2300
|
properties: {
|
|
@@ -2310,9 +2345,10 @@ class CherryToolEngine {
|
|
|
2310
2345
|
'`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
|
|
2311
2346
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2312
2347
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2313
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2314
|
-
'
|
|
2315
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2348
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2349
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2350
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2351
|
+
'answer).',
|
|
2316
2352
|
parameters: {
|
|
2317
2353
|
type: 'object',
|
|
2318
2354
|
properties: {
|
|
@@ -2382,8 +2418,9 @@ class CherryToolEngine {
|
|
|
2382
2418
|
'"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
|
|
2383
2419
|
'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
|
|
2384
2420
|
'(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
|
|
2385
|
-
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
2386
|
-
'stop point can be named
|
|
2421
|
+
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
|
|
2422
|
+
'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
|
|
2423
|
+
'token past the length ceiling, or no anchor id — and ' +
|
|
2387
2424
|
'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
|
|
2388
2425
|
'bounds how many messages are returned FROM that point). The two token forms are ' +
|
|
2389
2426
|
'`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
|
|
@@ -2405,7 +2442,8 @@ class CherryToolEngine {
|
|
|
2405
2442
|
description:
|
|
2406
2443
|
'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
|
|
2407
2444
|
'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
|
|
2408
|
-
'which a larger `limit` does not extend — the resume token is
|
|
2445
|
+
'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
|
|
2446
|
+
'older messages are reached.',
|
|
2409
2447
|
},
|
|
2410
2448
|
messagesResumeCursor: {
|
|
2411
2449
|
type: 'string',
|
|
@@ -2586,11 +2624,30 @@ class CherryToolEngine {
|
|
|
2586
2624
|
...(truncationReason === undefined ? {} : { truncationReason }),
|
|
2587
2625
|
// Only a TRUNCATED walk has unread history, and the token then marks the
|
|
2588
2626
|
// stop point (OLDER messages remain unread) —
|
|
2589
|
-
// {@link CherryReadSnapshot.messagesResumeCursor}.
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
2627
|
+
// {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
|
|
2628
|
+
// be NAMED is not the walk's verdict: it depends on the peer's payload,
|
|
2629
|
+
// and {@link messageResumeToken} is the ONE place that judges it — content
|
|
2630
|
+
// through `nameableRemainder`, length through `withinResumeTokenCeiling`,
|
|
2631
|
+
// and, for a POSITION only, the retired-v1 check
|
|
2632
|
+
// ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
|
|
2633
|
+
// exactly as `parseMessageResume`'s `after:` branch does). The refusal
|
|
2634
|
+
// cases are therefore not enumerated again here.
|
|
2635
|
+
//
|
|
2636
|
+
// The key is OMITTED rather than emitted `undefined`, and its absence only
|
|
2637
|
+
// means something once read together with `truncated`: the two cases have
|
|
2638
|
+
// opposite meanings —
|
|
2639
|
+
//
|
|
2640
|
+
// - a COMPLETE walk (nothing older left): `truncated: false`, no token —
|
|
2641
|
+
// the absence really does say "nothing older left";
|
|
2642
|
+
// - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
|
|
2643
|
+
// plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
|
|
2644
|
+
// and the absence never means they ran out. The remedy follows the
|
|
2645
|
+
// REASON: only a `limit` stop answers to a wider `limit`; a
|
|
2646
|
+
// `safety-bound` stop is the internal circuit breaker, and an
|
|
2647
|
+
// `aggregate-bytes` stop is this answer's own output budget, which a
|
|
2648
|
+
// wider `limit` does not move (more round trips, same budget). A
|
|
2649
|
+
// `deadline` stop is a WAIT face's reason — `cherry_read` carries no
|
|
2650
|
+
// deadline — so it is never this literal's.
|
|
2594
2651
|
...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
|
|
2595
2652
|
}
|
|
2596
2653
|
}
|
|
@@ -3753,8 +3810,8 @@ class CherryToolEngine {
|
|
|
3753
3810
|
'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
|
|
3754
3811
|
'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
|
|
3755
3812
|
'`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
|
|
3756
|
-
'peer ran out
|
|
3757
|
-
'continue.',
|
|
3813
|
+
'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
|
|
3814
|
+
'`cursor` from the same `sessionId` to continue.',
|
|
3758
3815
|
parameters: {
|
|
3759
3816
|
type: 'object',
|
|
3760
3817
|
properties: {
|
|
@@ -5547,9 +5604,11 @@ class CherryToolEngine {
|
|
|
5547
5604
|
|
|
5548
5605
|
/**
|
|
5549
5606
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
5550
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5551
|
-
* the unread history begins
|
|
5552
|
-
* or
|
|
5607
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5608
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
5609
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
5610
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
5611
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5553
5612
|
*
|
|
5554
5613
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
5555
5614
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -5824,9 +5883,10 @@ class CherryToolEngine {
|
|
|
5824
5883
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
5825
5884
|
* and continues strictly after it.
|
|
5826
5885
|
*
|
|
5827
|
-
* The token is
|
|
5828
|
-
*
|
|
5829
|
-
*
|
|
5886
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
5887
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
5888
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
5889
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5830
5890
|
*/
|
|
5831
5891
|
async #readTerminalMessages(
|
|
5832
5892
|
connection: DeviceConnection,
|
|
@@ -6096,28 +6156,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
|
|
|
6096
6156
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
6097
6157
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
6098
6158
|
* peer's page position is exactly where the unread history begins
|
|
6099
|
-
* (`cursor:<pos>`)
|
|
6100
|
-
*
|
|
6159
|
+
* (`cursor:<pos>`).
|
|
6160
|
+
*
|
|
6161
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
6162
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
6163
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
6164
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
6165
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
6166
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
6167
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
6168
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
6169
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
6170
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
6171
|
+
* id really can reach the anchor.
|
|
6172
|
+
*
|
|
6173
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
6174
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
6175
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
6176
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
6177
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
6178
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
6179
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
6180
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
6181
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
6182
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
6183
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
6101
6184
|
*/
|
|
6102
|
-
function messageResumeToken(
|
|
6185
|
+
export function messageResumeToken(
|
|
6103
6186
|
page: CherryPage<unknown>,
|
|
6104
6187
|
returned: number,
|
|
6105
6188
|
lastReturnedId: string | undefined,
|
|
6106
6189
|
): string | undefined {
|
|
6107
|
-
const anchor = optionalString(lastReturnedId)
|
|
6190
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId))
|
|
6108
6191
|
// TWO forms of "the walk stopped inside what it was given": it returned fewer
|
|
6109
6192
|
// items than it collected, or the PEER overshot the requested page and items
|
|
6110
6193
|
// were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
|
|
6111
6194
|
// position sits past messages this walk never returned, so only the anchor is
|
|
6112
6195
|
// exact.
|
|
6113
6196
|
if (returned < page.items.length || page.pageOverflow === true) {
|
|
6114
|
-
|
|
6197
|
+
// The ANCHOR takes the content predicate only — NOT the v1 detector, and
|
|
6198
|
+
// that is not an oversight: `parseMessageResume`'s `after:` branch runs
|
|
6199
|
+
// `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
|
|
6200
|
+
// a token it ACCEPTS back. Applying the detector here would be the mirror
|
|
6201
|
+
// image of this issue — refusing to name a token the parser takes.
|
|
6202
|
+
return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
|
|
6115
6203
|
}
|
|
6116
|
-
//
|
|
6117
|
-
//
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6204
|
+
// A POSITION takes both: the content predicate, and the retired-format
|
|
6205
|
+
// detector its own parser applies before anything else (issue #85).
|
|
6206
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
6207
|
+
return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
|
|
6208
|
+
}
|
|
6209
|
+
|
|
6210
|
+
/**
|
|
6211
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
6212
|
+
* when it must not name one.
|
|
6213
|
+
*
|
|
6214
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
6215
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
6216
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
6217
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
6218
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
6219
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
6220
|
+
* is legal and round-trips.
|
|
6221
|
+
*
|
|
6222
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
6223
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
6224
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
6225
|
+
*/
|
|
6226
|
+
function nameableRemainder(value: string | undefined): string | undefined {
|
|
6227
|
+
return value === undefined || mangledRemainder(value) ? undefined : value
|
|
6228
|
+
}
|
|
6229
|
+
|
|
6230
|
+
/**
|
|
6231
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
6232
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
6233
|
+
*
|
|
6234
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
6235
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
6236
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
6237
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
6238
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
6239
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
6240
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
6241
|
+
*
|
|
6242
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
6243
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
6244
|
+
* shape is a token it accepts.
|
|
6245
|
+
*/
|
|
6246
|
+
function nameablePeerPosition(value: string | undefined): string | undefined {
|
|
6247
|
+
const remainder = nameableRemainder(value)
|
|
6248
|
+
return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
|
|
6249
|
+
}
|
|
6250
|
+
|
|
6251
|
+
/**
|
|
6252
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
6253
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
6254
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
6255
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
6256
|
+
* stop point whose token would push past
|
|
6257
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
6258
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
6259
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
6260
|
+
* the parser takes back.
|
|
6261
|
+
*/
|
|
6262
|
+
function withinResumeTokenCeiling(token: string): string | undefined {
|
|
6263
|
+
return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
|
|
6121
6264
|
}
|
|
6122
6265
|
|
|
6123
6266
|
/**
|
|
@@ -6224,8 +6367,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
|
|
|
6224
6367
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
6225
6368
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
6226
6369
|
* foreign token: an empty anchor could never match a message.
|
|
6370
|
+
*
|
|
6371
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
6372
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
6373
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
6374
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
6375
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
6227
6376
|
*/
|
|
6228
|
-
function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6377
|
+
export function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6229
6378
|
if (value === undefined || value === null) return undefined
|
|
6230
6379
|
const refuse = (): never => {
|
|
6231
6380
|
throw new Error(
|