dsh-agent-message 1.4.0 → 1.5.1
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 +37 -44
- package/README.md +38 -45
- package/cordis.patch.yml +0 -8
- package/lib/client.js +290 -15
- package/lib/index.js +215 -162
- package/package.json +6 -2
package/README.en.md
CHANGED
|
@@ -12,8 +12,8 @@ English | [中文](./README.md)
|
|
|
12
12
|
|
|
13
13
|
In DeepSeek Harness, a single process hosts multiple Agent sessions at once. This plugin equips each session with three tools so they can "message" each other:
|
|
14
14
|
|
|
15
|
-
- Before sending, first **list every sendable session** (
|
|
16
|
-
- Once found, **deliver the message to the target session** —
|
|
15
|
+
- Before sending, first **list every sendable independent session** (non-archived, excluding actual subagents, including offline sessions that have not been reopened), and find the target by its title;
|
|
16
|
+
- Once found, **deliver the message to the target session** — ordinary messages always enter a new independent turn; if the target is offline (not loaded since the last process restart), the plugin resumes it through Harness's public API, delivers the message, and keeps the handle loaded for later communication until plugin teardown;
|
|
17
17
|
- When needed, **query the delivery status of a message on demand** (queued / claimed / discarded / unknown), with the target runtime status reported separately for supervision scenarios.
|
|
18
18
|
|
|
19
19
|
Typical scenarios: an orchestrator Agent dispatching work to a developer Agent, two Agents collaborating in a relay, a main session sending instructions to a test session, or a supervisor Agent watching over several workers.
|
|
@@ -22,47 +22,29 @@ Typical scenarios: an orchestrator Agent dispatching work to a developer Agent,
|
|
|
22
22
|
|
|
23
23
|
| Capability | Description |
|
|
24
24
|
|---|---|
|
|
25
|
-
| `list_peer_agents` | List all **sendable
|
|
26
|
-
| `send_agent_message` | Send a message to a session id;
|
|
27
|
-
| `check_delivery` | Query receipts on demand (
|
|
28
|
-
|
|
|
25
|
+
| `list_peer_agents` | List all **sendable independent sessions**: non-archived, excluding actual subagents while retaining ordinary forks; returns id, title, working directory, and runtime status |
|
|
26
|
+
| `send_agent_message` | Send a message to a session id; `followup` creates an independent turn by default and offline targets are resumed automatically; explicit modes are `followup`, `inject`, and `steer` |
|
|
27
|
+
| `check_delivery` | Query receipts on demand (pending/claimed/discarded/unknown); a successful send first returns accepted, while pre-admission failure is a tool error; explicit message ids remain queryable after restart |
|
|
28
|
+
| `@` session locator | Type `@` at the beginning of the composer and choose a target; candidates show only the session title and `Running`/`Idle`, excluding blank placeholders and subagents. The user sees a readable title while the current Agent receives the stable session id; `@` only locates the session, and the full-sentence intent determines whether to send, read, or analyze |
|
|
29
|
+
| Visible Agent message card | Relay keeps its true plugin provenance while the Client presents it as a left-aligned Agent message card; `From Session · <name>:` opens the sender by click or keyboard |
|
|
29
30
|
| Copy session id | A "Copy ID" button is added to the session header for one-click copying of the current session id |
|
|
30
31
|
|
|
31
32
|
### Sender navigation example
|
|
32
33
|
|
|
33
34
|

|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
Current relay messages are displayed as visible Agent message cards; clicking the header opens the sender session. The persisted source remains plugin `relay` provenance rather than impersonating human input. The full session id remains in typed source metadata and in a Host-generated model-visible protocol header, so the receiving Agent never has to guess the sender.
|
|
36
37
|
|
|
37
38
|
### Delivery modes (the `mode` parameter of `send_agent_message`)
|
|
38
39
|
|
|
39
40
|
| mode | Meaning |
|
|
40
41
|
|---|---|
|
|
41
|
-
| (default, omitted) |
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `inject` |
|
|
45
|
-
| `leave` | Leave a note: write into the target's inbox without waking it; for offline sessions this is a "mailbox" that appears when the session is next opened |
|
|
46
|
-
| `wake` | Activate: resume an offline session first, then deliver; equivalent to `followup` when online |
|
|
42
|
+
| (default, omitted) | `followup`: create an independent turn; an offline target is resumed automatically before delivery |
|
|
43
|
+
| `followup` | Same as the default; queue directly when online, or resume and queue when offline |
|
|
44
|
+
| `steer` | Intervene in the target's current work immediately (`running` sessions only) |
|
|
45
|
+
| `inject` | Add next-step context without interrupting the current goal (`running` sessions only) |
|
|
47
46
|
|
|
48
|
-
**Archived sessions are always rejected
|
|
49
|
-
|
|
50
|
-
### Configuring the message form (`form`)
|
|
51
|
-
|
|
52
|
-
The two forms differ not only visually — they carry different **intents**:
|
|
53
|
-
|
|
54
|
-
| form | Rendering | Intent |
|
|
55
|
-
|---|---|---|
|
|
56
|
-
| `user` (default) | Ordinary message bubble | **Conversational**: like a human chat, a reply is expected |
|
|
57
|
-
| `relay` | Collapsible context block | **Directive**: injected as context that quietly shapes the agent's later behavior — for instruction-style messages where no reply is expected |
|
|
58
|
-
|
|
59
|
-
Override the plugin entry in your profile's `cordis.patch.yml` (takes effect on hot reload, no restart needed):
|
|
60
|
-
|
|
61
|
-
```yaml
|
|
62
|
-
- id: agent-message
|
|
63
|
-
config:
|
|
64
|
-
form: relay
|
|
65
|
-
```
|
|
47
|
+
**Archived sessions and actual subagents are always rejected**; ordinary forks remain independent and sendable. Sending to yourself is also rejected.
|
|
66
48
|
|
|
67
49
|
## Installation
|
|
68
50
|
|
|
@@ -95,11 +77,12 @@ The plugin ships a `cordis.patch.yml` (pointed to by `dsh.bundle.patch` in `pack
|
|
|
95
77
|
|
|
96
78
|
## Usage
|
|
97
79
|
|
|
98
|
-
1.
|
|
99
|
-
2.
|
|
100
|
-
3.
|
|
101
|
-
4.
|
|
102
|
-
5.
|
|
80
|
+
1. Type `@` at the beginning of session A's composer and choose the target from the native candidate menu; each candidate shows its title and `Running`/`Idle` activity;
|
|
81
|
+
2. `@` only tells A where the relevant session is; it does not mean send. A calls `send_agent_message` only when the current request or an orchestration responsibility already granted by the user requires cross-session communication. For example, `@B tell it to stop after opening the draft PR` sends, while `@B analyze its latest conversation result` must not send a message to B;
|
|
82
|
+
3. For an explicit forwarding request, A only delivers and reports whether the message was accepted or failed. It must not execute the forwarded task itself or ask B for an extra acknowledgement. B sends a message to `senderSessionId` only when the body explicitly asks it to return business content;
|
|
83
|
+
4. You can still ask the Agent to call `list_peer_agents` and send directly with a full session id;
|
|
84
|
+
5. Session B receives a native `UserMessage` with a typed relay source plus a minimal Host-generated source header on the first body line, so B does not have to guess the sender. The Client presents it as a visible Agent message card whose header opens the sender session;
|
|
85
|
+
6. (Supervision) Say "check the status of my messages to `<session id>`" — it calls `check_delivery`.
|
|
103
86
|
|
|
104
87
|
## How it works
|
|
105
88
|
|
|
@@ -110,13 +93,21 @@ Each Agent has an inbox `Inbox` containing two FIFO queues:
|
|
|
110
93
|
|
|
111
94
|
Delivery paths of `send_agent_message`:
|
|
112
95
|
|
|
113
|
-
- **
|
|
114
|
-
- **
|
|
115
|
-
- **
|
|
96
|
+
- **Ordinary online message**: find the target Agent through the `agents` registry and call `followup()` so it enters an independent `next-turn`;
|
|
97
|
+
- **Running-mode semantics**: users do not need to name a mode. The Agent selects `steer()` when the full request clearly asks for immediate intervention, or `inject()` when it clearly asks to add context without interrupting the current task. The target must actually be `running`; when intent is unclear, the Agent keeps the default `followup()`;
|
|
98
|
+
- **Ordinary offline message**: first read and validate one logical-session snapshot through `sessionQuery.readSession()`, then restore it through the public `agents.resume()` API and call `followup()`. The plugin retains and reuses the returned handle, keeps the target loaded after it becomes idle, and releases the handle only when the plugin unloads. Resume failures are returned directly; the plugin does not forge core Inbox events as a fallback note.
|
|
99
|
+
|
|
100
|
+
Session enumeration, batched titles, and offline log reads use Harness's `sessionQuery.listSessions()`, `readTitleSnapshots()`, and `readSession()` respectively. `SessionId` is the only address; `parentSession` records fork lineage only, and only `origin: subagent` identifies an actual subagent. The plugin does not scan `sessionPersistence` directly to rebuild a parallel session directory.
|
|
101
|
+
|
|
102
|
+
After `send_agent_message` submits the native message to the target Inbox, it immediately returns `accepted` with the native `messageId`; the model receives only a terse “delivered” projection while the complete result stays in tool presentation metadata. `check_delivery` then derives `pending` (still queued), `claimed` (claimed by a turn), `discarded` (cancelled), or `unknown` from Inbox events on demand. `claimed` is transport evidence only: it does not prove that the message was read, answered, or completed. Pre-admission failure remains a Harness tool error and writes nothing to the target Inbox. Runtime state is reported separately as `targetRuntimeStatus`, so unrelated Agent activity never changes the message state. A known `messageId` remains queryable from the target Inbox log after a process restart.
|
|
103
|
+
|
|
104
|
+
Every cross-session message is created by Harness `createUserMessage()`, and `UserMessage.id` is its only message identity. Its source always uses `kind: dsh-agent-message` and `form: relay`, plus the protocol version, sender/target Session ids, and display title. Because current Harness model requests do not expand custom source fields, the Host also writes a minimal `<dsh-agent-message>` header containing only `senderSessionId` on the first body line. The typed source is the durable/UI truth; the header is only the model-visible projection needed for reply addressing. The plugin registers no global system prompt; send admission lives only in the `send_agent_message` tool contract. The Client only projects relay as a visible Agent message card and never rewrites an Agent message as human `user` provenance.
|
|
105
|
+
|
|
106
|
+
Relay only means “a message addressed by another session”; it neither requires nor forbids a reply. When the body explicitly requests business content in return, the receiving Agent may send a message to `senderSessionId` with the same tool. Otherwise it must not send a transport acknowledgement or a bare “received.” The plugin does not automatically correlate requests and replies or forward ordinary Agent answers.
|
|
116
107
|
|
|
117
|
-
|
|
108
|
+
The composer-side `@` session locator reuses Harness's native `inputTriggers` command marker. The visible selected title is capped at 40 Unicode characters with an ellipsis; submission replaces it with the full stable `@session-...` id for the current Agent. The sent bubble still projects that id with a chat icon and the live session title, so renaming a session does not change the locator target.
|
|
118
109
|
|
|
119
|
-
|
|
110
|
+
See [`docs/architecture-v2.md`](./docs/architecture-v2.md) for the current architecture contract.
|
|
120
111
|
|
|
121
112
|
## Directory structure
|
|
122
113
|
|
|
@@ -124,10 +115,10 @@ For `user` messages, the raw body header contains the sender title and full sess
|
|
|
124
115
|
dsh-agent-message/
|
|
125
116
|
├── lib/
|
|
126
117
|
│ ├── index.js # host half: list_peer_agents / send_agent_message / check_delivery
|
|
127
|
-
│ └── client.js # client half:
|
|
118
|
+
│ └── client.js # client half: @session references, session navigation and copy-session-id button
|
|
128
119
|
├── cordis.patch.yml # self-registration patch (pointed to by dsh.bundle.patch)
|
|
129
120
|
├── package.json # DSH plugin manifest (dsh.bundle / dsh.client / dshx.contributes)
|
|
130
|
-
├── docs/ #
|
|
121
|
+
├── docs/ # current architecture and README example screenshot
|
|
131
122
|
├── README.md # Chinese documentation
|
|
132
123
|
└── README.en.md # English documentation
|
|
133
124
|
```
|
|
@@ -135,7 +126,9 @@ dsh-agent-message/
|
|
|
135
126
|
## Limitations
|
|
136
127
|
|
|
137
128
|
- The target session must be **non-archived** and present in local persistence; archived sessions are always rejected.
|
|
138
|
-
-
|
|
129
|
+
- These tools are only for communication between independent sessions; actual subagents are neither listed as targets nor allowed to call them.
|
|
130
|
+
- One Session pair, regardless of direction, may receive at most 10 deliveries in a rolling 60-second window. The 11th is rejected before the target Inbox is changed. This window belongs to the current Harness process and resets on restart.
|
|
131
|
+
- Automatically resuming an offline session uses the **default model** (it does not inherit a model manually selected earlier in that session); if resume fails, the message is not written to the target Inbox.
|
|
139
132
|
- Bulk receipt queries without `messageId` rely on in-memory bookkeeping and cover only the most recent 1000 sends in the current process (FIFO eviction). After a restart, a known `messageId` remains queryable, but the volatile `sentAt` and `mode` fields are no longer returned.
|
|
140
133
|
- Cross-process / cross-machine communication is out of scope.
|
|
141
134
|
|
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
在 DeepSeek Harness 里,一个进程会同时挂着多个 Agent 会话。本插件给每个会话装上三个工具,让它们能互相"发消息":
|
|
14
14
|
|
|
15
|
-
-
|
|
16
|
-
-
|
|
15
|
+
- 发消息前,先**列出所有可发送的独立会话**(未归档、排除真实子代理,含离线未打开的),按标题找到目标;
|
|
16
|
+
- 找到后,**把消息投递到目标会话**——普通消息统一进入独立的新 turn;目标离线(进程重启后还没打开)时,插件通过 Harness 公开接口恢复会话、投递,并保持加载供后续通信,插件卸载时再释放 handle;
|
|
17
17
|
- 需要时,可以**按需查询**某条消息的送达状态(排队中/已认领/被丢弃/未知),并单独查看目标是否正在运行,供监督场景使用。
|
|
18
18
|
|
|
19
19
|
典型场景:编排者 Agent 给开发 Agent 派活、两个 Agent 协作接力、主会话给测试会话发指令、监督者 Agent 盯梢多个 worker。
|
|
@@ -22,47 +22,29 @@
|
|
|
22
22
|
|
|
23
23
|
| 能力 | 说明 |
|
|
24
24
|
|---|---|
|
|
25
|
-
| `list_peer_agents` |
|
|
26
|
-
| `send_agent_message` | 给指定会话 ID
|
|
27
|
-
| `check_delivery` | 按需查询消息回执(
|
|
28
|
-
|
|
|
25
|
+
| `list_peer_agents` | 列出所有**可发送**的独立会话:未归档、排除真实子代理;普通 fork 保留。返回 id、标题、工作目录和运行状态 |
|
|
26
|
+
| `send_agent_message` | 给指定会话 ID 发消息;默认使用 `followup` 创建独立的新 turn,离线时自动恢复后投递;显式支持 `followup`、`inject`、`steer` |
|
|
27
|
+
| `check_delivery` | 按需查询消息回执(pending/claimed/discarded/unknown);发送成功时先返回 accepted,接纳前失败由工具错误表示;指定消息 ID 时支持重启后恢复查询 |
|
|
28
|
+
| `@` 会话定位 | 在输入框开头键入 `@` 选择目标;候选只显示会话标题和“运行中/空闲”,空白占位和子代理不混入。选择后用户看到可读标题,当前 Agent 收到稳定 session ID;`@` 只定位,发送、读取或分析由整句意图决定 |
|
|
29
|
+
| 可见的 Agent 消息卡片 | relay 仍保留真实的插件来源,但 Client 将它显示为左侧 Agent 消息卡片;`From Session · <名称>:` 可点击或通过键盘打开发送方会话 |
|
|
29
30
|
| 复制会话 ID | 会话头部新增「复制ID」按钮,一键复制当前会话 ID |
|
|
30
31
|
|
|
31
|
-
###
|
|
32
|
+
### 发送方导航示例
|
|
32
33
|
|
|
33
34
|

|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
当前 relay 消息显示为可见的 Agent 消息卡片;点击消息头即可跳转到发送方会话。持久化来源仍是插件 `relay`,不会伪装成人类输入。完整会话 ID 同时保留在 typed source 和 Host 生成的模型可见协议头中,避免接收 Agent 猜测发送方。
|
|
36
37
|
|
|
37
38
|
### 投递模式(`send_agent_message` 的 `mode` 参数)
|
|
38
39
|
|
|
39
40
|
| mode | 含义 |
|
|
40
41
|
|---|---|
|
|
41
|
-
| (默认,不传) |
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `inject` |
|
|
45
|
-
| `leave` | 留言:写进对方收件箱但不唤醒;离线会话就是"留言板" |
|
|
46
|
-
| `wake` | 激活:离线会话先 `resume` 再投递;在线等价于 `followup` |
|
|
42
|
+
| (默认,不传) | `followup`:给目标创建独立的新 turn;离线时自动 `resume` 后投递 |
|
|
43
|
+
| `followup` | 与默认相同;在线直接排队,离线自动恢复后排队 |
|
|
44
|
+
| `steer` | 立即介入对方当前工作(仅 `running` 会话) |
|
|
45
|
+
| `inject` | 不打断当前目标,静默补充下一步上下文(仅 `running` 会话) |
|
|
47
46
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
### 配置:消息渲染形态(`form`)
|
|
51
|
-
|
|
52
|
-
两种形态不只是视觉差异,**承载的意图也不同**:
|
|
53
|
-
|
|
54
|
-
| form | 渲染 | 意图 |
|
|
55
|
-
|---|---|---|
|
|
56
|
-
| `user`(默认) | 普通消息气泡 | **对话式**:像人发消息一样,期待代理回复 |
|
|
57
|
-
| `relay` | 折叠的上下文块 | **引导式**:作为上下文注入,静默影响代理的后续行为,适合指令类消息,不期待回复 |
|
|
58
|
-
|
|
59
|
-
在你的 profile 的 `cordis.patch.yml` 里覆盖插件条目即可(热更新即时生效,无需重启):
|
|
60
|
-
|
|
61
|
-
```yaml
|
|
62
|
-
- id: agent-message
|
|
63
|
-
config:
|
|
64
|
-
form: relay
|
|
65
|
-
```
|
|
47
|
+
**归档会话和真实子代理一律拒绝发送**;普通 fork 仍是独立会话,可以发送。发给自己也会被拒绝。
|
|
66
48
|
|
|
67
49
|
## 安装
|
|
68
50
|
|
|
@@ -96,11 +78,12 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
|
|
|
96
78
|
|
|
97
79
|
## 使用
|
|
98
80
|
|
|
99
|
-
1.
|
|
100
|
-
2.
|
|
101
|
-
3.
|
|
102
|
-
4.
|
|
103
|
-
5.
|
|
81
|
+
1. 在会话 A 的输入框开头键入 `@`,从原生候选菜单中选择目标会话;候选会显示标题和“运行中/空闲”;
|
|
82
|
+
2. `@` 只告诉 A 信息或操作的目标在哪里,不代表发送。当前请求或用户已授予的编排职责要求跨会话传递信息时,A 才调用 `send_agent_message`。例如 `@B 告诉他最后提交 PR draft 就停止` 会发送,`@B 帮我分析他最新的对话结果` 则不应向 B 发消息;
|
|
83
|
+
3. 显式要求转告时,A 只负责投递并报告“已接受”或失败,不代为执行被转发的任务,也不要求 B 额外回复“收到”;如果正文明确要求 B 把业务内容返回 A,B 才向 `senderSessionId` 发送消息;
|
|
84
|
+
4. 也可以让 Agent 调 `list_peer_agents`,再用完整会话 ID 直接发送;
|
|
85
|
+
5. 会话 B 收到的是带 typed relay source 的原生 `UserMessage`;正文首行还有 Host 生成的最小来源协议,B 不需要猜测发送方;Client 将其显示为可见 Agent 消息卡片,并可从消息头打开发送方会话;
|
|
86
|
+
6. (监督场景)说「查一下我发给 `<会话ID>` 的消息状态」——它会调 `check_delivery`。
|
|
104
87
|
|
|
105
88
|
## 原理
|
|
106
89
|
|
|
@@ -111,13 +94,21 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
|
|
|
111
94
|
|
|
112
95
|
`send_agent_message` 的投递路径:
|
|
113
96
|
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
97
|
+
- **在线普通消息**:通过 `agents` 注册表找到目标 Agent,调用 `followup()` 进入独立的 `next-turn`;
|
|
98
|
+
- **运行中高级语义**:用户无需说出模式名;Agent 根据整句话判断,明确要求立即介入时使用 `steer()`,明确要求不打断当前任务、只补充上下文时使用 `inject()`;目标必须确实为 `running`,判断不清时仍使用默认 `followup()`;
|
|
99
|
+
- **离线普通消息**:先由 `sessionQuery.readSession()` 读取同一份逻辑会话快照并校验目标,再通过公开 `agents.resume()` 恢复、调用 `followup()`;插件持有并复用恢复得到的 handle,目标回到 idle 后仍保持加载,只在插件卸载时释放。恢复失败直接返回失败,不伪造核心 Inbox 事件作为留言。
|
|
100
|
+
|
|
101
|
+
会话枚举、批量标题和离线日志读取分别使用 Harness 的 `sessionQuery.listSessions()`、`readTitleSnapshots()` 与 `readSession()`。`SessionId` 是唯一地址;`parentSession` 只记录分叉血缘,只有 `origin: subagent` 才会被识别为真实子代理。插件不直接扫描 `sessionPersistence` 重建另一份会话目录。
|
|
102
|
+
|
|
103
|
+
`send_agent_message` 成功把原生消息提交给目标 Inbox 后立即返回 `accepted` 和该消息的原生 `messageId`;模型只接收简短的“已投递”,完整结果保留在工具呈现元数据中。`check_delivery` 根据 Inbox 事件按需返回 `pending`(仍在排队)、`claimed`(已被某轮认领)、`discarded`(被取消)或 `unknown`。`claimed` 只是传输证据,不表示已读、回复或任务完成。接纳前失败由 Harness 工具错误表示,不写入目标 Inbox。目标是否正在运行通过独立的 `targetRuntimeStatus` 返回,不把 Agent 的整体运行状态误当成某条消息正在处理。指定 `messageId` 时可从目标现有 Inbox 日志恢复状态,因此进程重启后仍可查询。
|
|
104
|
+
|
|
105
|
+
所有跨会话消息都由 Harness `createUserMessage()` 创建,`UserMessage.id` 是唯一消息身份。`source.kind` 固定为 `dsh-agent-message`,`form` 固定为 `relay`,并携带协议版本、发送/目标 Session 和显示标题。由于当前 Harness 不会把自定义 source 字段展开给模型,Host 还会在正文首行写入只含 `senderSessionId` 的最小 `<dsh-agent-message>` 协议头;source 是持久化/UI 真相,协议头只是回复寻址所需的模型可见投影。插件不注册全局系统提示词,发送准入只存在于 `send_agent_message` 的工具合同中。Client 只把 relay 投影为可见的 Agent 消息卡片,不会反向把 Agent 消息伪装成人类 `user` 来源。
|
|
106
|
+
|
|
107
|
+
relay 只表达“另一会话发来的消息”,本身不等于必须回复或禁止回复。正文明确要求返回业务内容时,接收 Agent 可用同一工具向 `senderSessionId` 发送消息;没有明确要求时不回传 transport ack 或单纯的“收到”。插件不自动关联请求与回复,也不自动转发 Agent 的普通回答。
|
|
117
108
|
|
|
118
|
-
|
|
109
|
+
输入框的 `@` 会话定位复用 Harness 原生 `inputTriggers` 命令标记:选择后的可见标题最多 40 个 Unicode 字符,超出用省略号;提交给当前 Agent 时换成完整 `@session-...` 稳定 ID。发送后的气泡依然用聊天图标和实时会话标题投影该 ID,显示名称变化不会改变定位目标。
|
|
119
110
|
|
|
120
|
-
|
|
111
|
+
完整的现役架构合同见 [`docs/architecture-v2.md`](./docs/architecture-v2.md)。
|
|
121
112
|
|
|
122
113
|
## 目录结构
|
|
123
114
|
|
|
@@ -125,10 +116,10 @@ Agent 会用 bash 执行这条命令,装完自动挂载、所有会话立即
|
|
|
125
116
|
dsh-agent-message/
|
|
126
117
|
├── lib/
|
|
127
118
|
│ ├── index.js # host 半区:list_peer_agents / send_agent_message / check_delivery
|
|
128
|
-
│ └── client.js # client
|
|
119
|
+
│ └── client.js # client 半区:@会话引用、会话导航与复制会话ID按钮
|
|
129
120
|
├── cordis.patch.yml # 自注册补丁(dsh.bundle.patch 指向它)
|
|
130
121
|
├── package.json # DSH 插件清单(dsh.bundle / dsh.client / dshx.contributes)
|
|
131
|
-
├── docs/ #
|
|
122
|
+
├── docs/ # 现役架构与 README 示例截图
|
|
132
123
|
├── README.md # 中文文档
|
|
133
124
|
└── README.en.md # English documentation
|
|
134
125
|
```
|
|
@@ -136,7 +127,9 @@ dsh-agent-message/
|
|
|
136
127
|
## 限制
|
|
137
128
|
|
|
138
129
|
- 目标会话必须**未归档**且存在于本机持久化里;归档会话一律拒绝发送。
|
|
139
|
-
-
|
|
130
|
+
- 工具只用于独立 Session 之间通信;真实子代理既不会出现在目标列表中,也不能作为调用方使用这些工具。
|
|
131
|
+
- 同一对 Session(不分发送方向)在滚动 60 秒内最多投递 10 条消息;第 11 条会在写入目标 Inbox 前被拒绝。该窗口只属于当前 Harness 进程,重启后清空。
|
|
132
|
+
- 自动恢复离线会话时会使用**默认模型**(不继承它上次手动切换的模型选择);恢复失败时消息不会被写入目标 Inbox。
|
|
140
133
|
- 不指定 `messageId` 的批量回执依赖内存记账,只覆盖本进程最近 1000 条发送记录(FIFO 淘汰);进程重启后仍可凭已知 `messageId` 查询,但不再返回易失的 `sentAt` 和 `mode`。
|
|
141
134
|
- 跨进程/跨机器通信不在本插件范围内。
|
|
142
135
|
|
package/cordis.patch.yml
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
# dsh-agent-message bundle patch:把插件挂进 profile 的宿主组合(自注册)。
|
|
2
2
|
# 由 package.json 的 dsh.bundle.patch 指向本文件,安装后自动挂载、全局可用。
|
|
3
|
-
#
|
|
4
|
-
# 可选配置:消息渲染形态 form —— user=对话式气泡、期待回复(默认);
|
|
5
|
-
# relay=上下文注入、静默影响代理行为、不期待回复。
|
|
6
|
-
# 需要切换时,在你的 profile 的 cordis.patch.yml 里覆盖此条目:
|
|
7
|
-
#
|
|
8
|
-
# - id: agent-message
|
|
9
|
-
# config:
|
|
10
|
-
# form: relay
|
|
11
3
|
- insert:
|
|
12
4
|
- id: agent-message
|
|
13
5
|
name: 'dsh-agent-message'
|
package/lib/client.js
CHANGED
|
@@ -5,12 +5,160 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
const React = require("react");
|
|
8
|
+
const { createRoot } = require("react-dom/client");
|
|
9
|
+
const { IconQueueOutline14, StateDot } = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
8
10
|
|
|
9
11
|
const name = "dsh-agent-message-client";
|
|
10
|
-
const inject = ["slots", "timer", "sessions"];
|
|
12
|
+
const inject = ["slots", "timer", "sessions", "inputTriggers", "workspaces"];
|
|
11
13
|
|
|
12
14
|
function apply(ctx) {
|
|
13
15
|
const senderSelector = '[data-ref-chip="subagent"], [data-context-relay-sender]';
|
|
16
|
+
const referenceSource = "agent-message-session";
|
|
17
|
+
const reactRootSelector = "[data-agent-msg-react-root]";
|
|
18
|
+
const candidateRows = new WeakMap();
|
|
19
|
+
const mountedRoots = new Map();
|
|
20
|
+
const sessionLinks = new Set();
|
|
21
|
+
const workspaces = ctx.get("workspaces");
|
|
22
|
+
|
|
23
|
+
function uiText(zh, en) {
|
|
24
|
+
const language = String(document.documentElement.lang || navigator.language || "zh").toLowerCase();
|
|
25
|
+
return language.startsWith("zh") ? zh : en;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sessionRows(includeArchived) {
|
|
29
|
+
const snapshot = ctx.sessions.list.getSnapshot();
|
|
30
|
+
const archived = new Set(workspaces?.list.getSnapshot().archivedSessionIds || []);
|
|
31
|
+
return snapshot.ids
|
|
32
|
+
.map(function (id) { return snapshot.byId[id]; })
|
|
33
|
+
.filter(function (row) {
|
|
34
|
+
return row && !row.blank && row.origin !== "subagent"
|
|
35
|
+
&& (includeArchived || !archived.has(row.id));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sessionRow(id) {
|
|
40
|
+
return ctx.sessions.list.getSnapshot().byId[id];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sessionTitle(row) {
|
|
44
|
+
return String(row?.displayTitle || row?.title || row?.id || "");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function compactSessionTitle(row) {
|
|
48
|
+
const chars = Array.from(sessionTitle(row));
|
|
49
|
+
return chars.length > 40 ? chars.slice(0, 39).join("") + "…" : chars.join("");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderInto(host, content) {
|
|
53
|
+
let root = mountedRoots.get(host);
|
|
54
|
+
if (!root) {
|
|
55
|
+
root = createRoot(host);
|
|
56
|
+
mountedRoots.set(host, root);
|
|
57
|
+
host.setAttribute("data-agent-msg-react-root", "true");
|
|
58
|
+
}
|
|
59
|
+
root.render(content);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cleanupRoots(node) {
|
|
63
|
+
if (!(node instanceof Element)) return;
|
|
64
|
+
const hosts = node.matches(reactRootSelector)
|
|
65
|
+
? [node].concat(Array.from(node.querySelectorAll(reactRootSelector)))
|
|
66
|
+
: Array.from(node.querySelectorAll(reactRootSelector));
|
|
67
|
+
hosts.forEach(function (host) {
|
|
68
|
+
const root = mountedRoots.get(host);
|
|
69
|
+
if (!root) return;
|
|
70
|
+
root.unmount();
|
|
71
|
+
mountedRoots.delete(host);
|
|
72
|
+
});
|
|
73
|
+
const links = node.matches(".agent-msg-session-link")
|
|
74
|
+
? [node].concat(Array.from(node.querySelectorAll(".agent-msg-session-link")))
|
|
75
|
+
: Array.from(node.querySelectorAll(".agent-msg-session-link"));
|
|
76
|
+
links.forEach(function (element) { sessionLinks.delete(element); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function SessionActivity(props) {
|
|
80
|
+
return props.running
|
|
81
|
+
? React.createElement(StateDot, { state: "done", size: 10, className: "agent-msg-status-dot" })
|
|
82
|
+
: React.createElement("span", { className: "agent-msg-status-dot agent-msg-status-dot-idle", "aria-hidden": "true" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function SessionReference(props) {
|
|
86
|
+
return React.createElement("span", { className: "agent-msg-reference-content" },
|
|
87
|
+
React.createElement(IconQueueOutline14, { size: 14, className: "agent-msg-session-icon" }),
|
|
88
|
+
React.createElement("span", { className: "agent-msg-reference-title" }, "@" + sessionTitle(props.row)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function RelaySender(props) {
|
|
92
|
+
return React.createElement("span", { className: "agent-msg-relay-sender-content" },
|
|
93
|
+
React.createElement(IconQueueOutline14, { size: 14, className: "agent-msg-session-icon" }),
|
|
94
|
+
React.createElement("span", null, uiText("来自会话 · ", "From Session · ") + props.title + ":"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function enhanceReferenceHost(element, row) {
|
|
98
|
+
let host = element.querySelector(":scope > .agent-msg-reference-host");
|
|
99
|
+
if (!host) {
|
|
100
|
+
element.textContent = "";
|
|
101
|
+
host = document.createElement("span");
|
|
102
|
+
host.className = "agent-msg-reference-host";
|
|
103
|
+
element.appendChild(host);
|
|
104
|
+
}
|
|
105
|
+
renderInto(host, React.createElement(SessionReference, { row: row }));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const inputTriggers = ctx.get("inputTriggers");
|
|
109
|
+
if (inputTriggers !== undefined) {
|
|
110
|
+
ctx.effect(() => inputTriggers.registerSource({
|
|
111
|
+
trigger: "@",
|
|
112
|
+
name: referenceSource,
|
|
113
|
+
order: 2,
|
|
114
|
+
async candidates(session, request) {
|
|
115
|
+
if (request.position !== "leading") return [];
|
|
116
|
+
const query = String(request.query || "").toLowerCase();
|
|
117
|
+
return sessionRows(false)
|
|
118
|
+
.filter(function (row) {
|
|
119
|
+
return String(row.id) !== String(session.sessionId)
|
|
120
|
+
&& (query === "" || sessionTitle(row).toLowerCase().includes(query));
|
|
121
|
+
})
|
|
122
|
+
.sort(function (a, b) {
|
|
123
|
+
if (a.running !== b.running) return a.running ? -1 : 1;
|
|
124
|
+
return sessionTitle(a).localeCompare(sessionTitle(b));
|
|
125
|
+
})
|
|
126
|
+
.map(function (row) {
|
|
127
|
+
const candidate = {
|
|
128
|
+
name: sessionTitle(row),
|
|
129
|
+
description: row.running ? uiText("运行中", "Running") : uiText("空闲", "Idle"),
|
|
130
|
+
icon: "",
|
|
131
|
+
};
|
|
132
|
+
candidateRows.set(candidate, row);
|
|
133
|
+
return candidate;
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
onPick({ candidate, session }) {
|
|
137
|
+
const row = candidateRows.get(candidate);
|
|
138
|
+
if (!row) return undefined;
|
|
139
|
+
const label = "@" + compactSessionTitle(row);
|
|
140
|
+
return {
|
|
141
|
+
claim: {
|
|
142
|
+
token: label + " ",
|
|
143
|
+
hint: uiText("输入要处理的内容", "Describe what to do"),
|
|
144
|
+
async submit(args) {
|
|
145
|
+
const content = String(args || "").trim();
|
|
146
|
+
if (content === "") return { kind: "error", text: uiText("请输入要处理的内容", "Describe what to do") };
|
|
147
|
+
const binding = ctx.sessions.binding(session.sessionId);
|
|
148
|
+
if (!binding) return { kind: "error", text: uiText("当前会话不可用", "Current session is unavailable") };
|
|
149
|
+
const result = await binding.session.prompt([{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: "@" + String(row.id) + " " + content,
|
|
152
|
+
}], "queue");
|
|
153
|
+
return result.ok
|
|
154
|
+
? { kind: "success" }
|
|
155
|
+
: { kind: "error", text: result.error.message };
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
}), "dsh-agent-message: @ session source");
|
|
161
|
+
}
|
|
14
162
|
|
|
15
163
|
function senderLink(target) {
|
|
16
164
|
if (!(target instanceof Element)) return null;
|
|
@@ -25,7 +173,7 @@ window.__ModuleLoader__.load({
|
|
|
25
173
|
|
|
26
174
|
function titleFrom(text) {
|
|
27
175
|
const line = String(text || "").split("\n", 1)[0].trim();
|
|
28
|
-
const current = line.match(/^From (?:Session|Agent)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
|
|
176
|
+
const current = line.match(/^(?:From (?:Session|Agent)|来自会话)(?: ·)? (.+?):(?: @session-[\w-]+)?$/);
|
|
29
177
|
const previous = line.match(/^来自 Agent · (.+)$/);
|
|
30
178
|
const legacy = line.match(/^来自 Agent「(.+)」\s*[·::]?$/);
|
|
31
179
|
return (current || previous || legacy)?.[1]?.trim() || "";
|
|
@@ -36,7 +184,6 @@ window.__ModuleLoader__.load({
|
|
|
36
184
|
const label = element.previousElementSibling;
|
|
37
185
|
const title = titleFrom(label?.textContent);
|
|
38
186
|
if (title && label) {
|
|
39
|
-
label.textContent = "";
|
|
40
187
|
label.classList.add("agent-msg-sender-prefix");
|
|
41
188
|
}
|
|
42
189
|
return title;
|
|
@@ -51,16 +198,116 @@ window.__ModuleLoader__.load({
|
|
|
51
198
|
const link = senderLink(element);
|
|
52
199
|
if (!link) return;
|
|
53
200
|
const relay = element.matches("[data-context-relay-sender]");
|
|
54
|
-
const title = relay ? "" : senderTitle(element);
|
|
201
|
+
const title = relay ? sessionTitle(sessionRow(link.sessionId)) || "@" + link.sessionId : senderTitle(element);
|
|
55
202
|
if (!relay && !title) return;
|
|
56
203
|
element.dataset.agentMsgSessionId = link.sessionId;
|
|
57
204
|
if (title) element.dataset.agentMsgSenderTitle = title;
|
|
58
|
-
|
|
59
|
-
element.
|
|
205
|
+
if (relay) renderInto(element, React.createElement(RelaySender, { title: title }));
|
|
206
|
+
else element.textContent = uiText("来自会话 · ", "From Session · ") + title + ":";
|
|
207
|
+
element.classList.add("agent-msg-session-link", "agent-msg-sender-link");
|
|
208
|
+
sessionLinks.add(element);
|
|
209
|
+
element.setAttribute("role", "link");
|
|
210
|
+
element.setAttribute("tabindex", "0");
|
|
211
|
+
element.setAttribute("title", uiText("打开发送方会话", "Open sender session"));
|
|
212
|
+
element.setAttribute("aria-label", uiText("打开发送方会话:", "Open sender session: ") + (title || link.sessionId));
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function prepareRelayCards(root) {
|
|
217
|
+
if (!(root instanceof Element)) return;
|
|
218
|
+
const disclosures = root.matches("[data-disclosure-row]")
|
|
219
|
+
? [root].concat(Array.from(root.querySelectorAll("[data-disclosure-row]")))
|
|
220
|
+
: Array.from(root.querySelectorAll("[data-disclosure-row]"));
|
|
221
|
+
disclosures.forEach(function (row) {
|
|
222
|
+
if (!String(row.textContent || "").includes("dsh-agent-message")) return;
|
|
223
|
+
if (row.getAttribute("aria-expanded") !== "true") row.click();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const bodies = root.matches('[data-context-form="relay"]')
|
|
227
|
+
? [root].concat(Array.from(root.querySelectorAll('[data-context-form="relay"]')))
|
|
228
|
+
: Array.from(root.querySelectorAll('[data-context-form="relay"]'));
|
|
229
|
+
bodies.forEach(function (body) {
|
|
230
|
+
const text = body.querySelector("[data-context-text]");
|
|
231
|
+
const raw = String(text?.textContent || "");
|
|
232
|
+
const end = raw.indexOf("</dsh-agent-message>");
|
|
233
|
+
if (!raw.startsWith("<dsh-agent-message>") || end < 0) return;
|
|
234
|
+
const card = body.closest('[data-chat-flow-kind="context"]') || body.parentElement;
|
|
235
|
+
card?.setAttribute("data-agent-msg-relay-card", "true");
|
|
236
|
+
if (text.dataset.agentMsgRelayBody !== "true") {
|
|
237
|
+
text.textContent = raw.slice(end + "</dsh-agent-message>".length).trimStart();
|
|
238
|
+
text.dataset.agentMsgRelayBody = "true";
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function prepareSessionReferences(root) {
|
|
244
|
+
if (!(root instanceof Element)) return;
|
|
245
|
+
const elements = root.matches('[data-ref-chip="subagent"]')
|
|
246
|
+
? [root].concat(Array.from(root.querySelectorAll('[data-ref-chip="subagent"]')))
|
|
247
|
+
: Array.from(root.querySelectorAll('[data-ref-chip="subagent"]'));
|
|
248
|
+
elements.forEach(function (element) {
|
|
249
|
+
if (element.classList.contains("agent-msg-sender-link")) return;
|
|
250
|
+
const id = element.dataset.agentMsgSessionId
|
|
251
|
+
|| String(element.textContent || "").trim().match(/^@(session-[\w-]+)$/)?.[1];
|
|
252
|
+
const row = id ? sessionRow(id) : undefined;
|
|
253
|
+
if (!id || !row) return;
|
|
254
|
+
element.dataset.agentMsgSessionId = id;
|
|
255
|
+
element.classList.add("agent-msg-session-link", "agent-msg-reference-link");
|
|
256
|
+
sessionLinks.add(element);
|
|
60
257
|
element.setAttribute("role", "link");
|
|
61
258
|
element.setAttribute("tabindex", "0");
|
|
62
|
-
element.setAttribute("title", "
|
|
63
|
-
element.setAttribute("aria-label", "
|
|
259
|
+
element.setAttribute("title", uiText("打开引用会话", "Open referenced session"));
|
|
260
|
+
element.setAttribute("aria-label", uiText("打开引用会话:", "Open referenced session: ") + sessionTitle(row));
|
|
261
|
+
enhanceReferenceHost(element, row);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function prepareSessionMenu(root) {
|
|
266
|
+
if (!(root instanceof Element)) return;
|
|
267
|
+
const menu = root.matches('[role="listbox"]') ? root : root.closest('[role="listbox"]') || root.querySelector('[role="listbox"]');
|
|
268
|
+
if (!menu) return;
|
|
269
|
+
const group = menu.querySelector('[data-source="' + referenceSource + '"]');
|
|
270
|
+
if (!group) return;
|
|
271
|
+
group.textContent = uiText("会话", "Sessions");
|
|
272
|
+
let element = group.nextElementSibling;
|
|
273
|
+
while (element && !element.hasAttribute("data-source")) {
|
|
274
|
+
if (element.matches('button[role="option"]')) {
|
|
275
|
+
element.classList.add("agent-msg-session-candidate");
|
|
276
|
+
const spans = element.querySelectorAll(":scope > span");
|
|
277
|
+
const icon = spans[0];
|
|
278
|
+
const description = spans[2];
|
|
279
|
+
if (icon) {
|
|
280
|
+
icon.classList.add("agent-msg-candidate-icon");
|
|
281
|
+
renderInto(icon, React.createElement(IconQueueOutline14, { size: 14 }));
|
|
282
|
+
}
|
|
283
|
+
if (description) {
|
|
284
|
+
const running = /^(运行中|Running)$/.test(String(description.textContent || "").trim())
|
|
285
|
+
|| element.dataset.agentMsgRunning === "true";
|
|
286
|
+
element.dataset.agentMsgRunning = String(running);
|
|
287
|
+
renderInto(description, React.createElement("span", { className: "agent-msg-status-label" },
|
|
288
|
+
React.createElement(SessionActivity, { running: running }),
|
|
289
|
+
React.createElement("span", null, running ? uiText("运行中", "Running") : uiText("空闲", "Idle"))));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
element = element.nextElementSibling;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function prepare(root) {
|
|
297
|
+
prepareRelayCards(root);
|
|
298
|
+
prepareSenderLinks(root);
|
|
299
|
+
prepareSessionReferences(root);
|
|
300
|
+
prepareSessionMenu(root);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function refreshSessionLinks() {
|
|
304
|
+
sessionLinks.forEach(function (element) {
|
|
305
|
+
if (!element.isConnected) {
|
|
306
|
+
sessionLinks.delete(element);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (element.classList.contains("agent-msg-sender-link")) prepareSenderLinks(element);
|
|
310
|
+
else prepareSessionReferences(element);
|
|
64
311
|
});
|
|
65
312
|
}
|
|
66
313
|
|
|
@@ -122,14 +369,18 @@ window.__ModuleLoader__.load({
|
|
|
122
369
|
type: "button",
|
|
123
370
|
onClick: onClick,
|
|
124
371
|
onMouseLeave: onMouseLeave,
|
|
125
|
-
title: "复制会话 ID",
|
|
126
|
-
"aria-label": "复制会话 ID",
|
|
372
|
+
title: uiText("复制会话 ID", "Copy session ID"),
|
|
373
|
+
"aria-label": uiText("复制会话 ID", "Copy session ID"),
|
|
127
374
|
className: "agent-msg-copy-id"
|
|
128
|
-
}, copyState === "copied"
|
|
375
|
+
}, copyState === "copied"
|
|
376
|
+
? uiText("已复制", "Copied")
|
|
377
|
+
: copyState === "failed"
|
|
378
|
+
? uiText("复制失败", "Copy failed")
|
|
379
|
+
: uiText("复制ID", "Copy ID"));
|
|
129
380
|
}
|
|
130
381
|
|
|
131
382
|
ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register(
|
|
132
|
-
{ name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: "复制会话ID" },
|
|
383
|
+
{ name: "conversation.session.header.actions", id: "copy-session-id", order: 30, label: uiText("复制会话ID", "Copy session ID") },
|
|
133
384
|
(props) => React.createElement(CopyButton, { sessionId: props.sessionId })
|
|
134
385
|
));
|
|
135
386
|
|
|
@@ -138,26 +389,50 @@ window.__ModuleLoader__.load({
|
|
|
138
389
|
".agent-msg-copy-id:hover { opacity: 1; border-color: rgba(127,127,127,.7); } " +
|
|
139
390
|
".agent-msg-sender-prefix { display: none !important; } " +
|
|
140
391
|
".agent-msg-session-link { cursor: pointer; text-decoration: none; } " +
|
|
141
|
-
".agent-msg-
|
|
392
|
+
".agent-msg-sender-link[data-ref-chip=\"subagent\"] { display: block; width: fit-content; padding: 0; background: transparent; color: inherit; font-size: 13px; font-weight: 600; line-height: 1.5; } " +
|
|
393
|
+
".agent-msg-reference-link { display: inline-flex !important; align-items: center; color: var(--dsw-alias-state-business-primary) !important; } " +
|
|
394
|
+
".agent-msg-reference-host, .agent-msg-reference-content { display: inline-flex; min-width: 0; align-items: center; } " +
|
|
395
|
+
".agent-msg-reference-content { gap: 5px; max-width: 100%; } " +
|
|
396
|
+
".agent-msg-reference-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } " +
|
|
397
|
+
".agent-msg-session-icon, .agent-msg-candidate-icon { display: inline-flex; flex: none; color: var(--dsw-alias-state-business-primary); } " +
|
|
398
|
+
".agent-msg-relay-sender-content { display: inline-flex; align-items: center; gap: 5px; } " +
|
|
399
|
+
"[data-agent-msg-relay-card] [data-disclosure-row] { display: none !important; } " +
|
|
400
|
+
"[data-agent-msg-relay-card] [data-context-form=\"relay\"] { width: fit-content; max-width: min(78%, 760px); max-height: none; margin: 0; padding: 14px 16px; overflow: visible; border: 1px solid rgba(127,127,127,.24); border-radius: 16px 16px 16px 4px; background: var(--dsw-alias-markdown-code-block); color: var(--dsw-alias-label-primary); font: inherit; } " +
|
|
401
|
+
"[data-agent-msg-relay-card] [data-context-relay-sender] { display: block; width: fit-content; margin: 0 0 8px; color: var(--dsw-alias-state-business-primary); font-size: 13px; font-weight: 600; line-height: 1.5; } " +
|
|
402
|
+
"[data-agent-msg-relay-card] [data-context-text] { margin: 0; overflow: visible; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; font: inherit; } " +
|
|
403
|
+
".agent-msg-status-dot { flex: none; } " +
|
|
404
|
+
".agent-msg-status-dot-idle { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-label-tertiary); opacity: .7; } " +
|
|
405
|
+
".agent-msg-status-label { display: inline-flex; align-items: center; gap: 6px; } " +
|
|
406
|
+
".agent-msg-session-candidate { border-radius: 0 !important; } " +
|
|
407
|
+
".agent-msg-session-candidate > span:nth-child(2) { flex: 1; min-width: 0; max-width: none; } " +
|
|
408
|
+
".agent-msg-session-candidate > span:nth-child(3) { display: flex; flex: none; justify-content: flex-end; margin-left: auto; } " +
|
|
142
409
|
".agent-msg-session-link:hover { text-decoration: underline; text-underline-offset: 3px; } " +
|
|
143
410
|
".agent-msg-session-link:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; border-radius: 2px; }";
|
|
144
411
|
const tag = document.createElement("style");
|
|
145
412
|
tag.setAttribute("data-plugin", name);
|
|
146
413
|
tag.textContent = css;
|
|
147
414
|
document.head.appendChild(tag);
|
|
148
|
-
|
|
415
|
+
prepare(document.body);
|
|
149
416
|
const observer = new MutationObserver(function (records) {
|
|
150
417
|
records.forEach(function (record) {
|
|
151
|
-
record.
|
|
418
|
+
record.removedNodes.forEach(cleanupRoots);
|
|
419
|
+
record.addedNodes.forEach(prepare);
|
|
152
420
|
});
|
|
153
421
|
});
|
|
154
422
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
423
|
+
const unsubscribeSessions = ctx.sessions.list.subscribe(refreshSessionLinks);
|
|
424
|
+
const unsubscribeWorkspaces = workspaces?.list.subscribe(refreshSessionLinks);
|
|
155
425
|
document.addEventListener("click", openSender);
|
|
156
426
|
document.addEventListener("keydown", onKeyDown);
|
|
157
427
|
ctx.effect(() => () => {
|
|
158
428
|
observer.disconnect();
|
|
429
|
+
unsubscribeSessions();
|
|
430
|
+
unsubscribeWorkspaces?.();
|
|
159
431
|
document.removeEventListener("click", openSender);
|
|
160
432
|
document.removeEventListener("keydown", onKeyDown);
|
|
433
|
+
mountedRoots.forEach(function (root) { root.unmount(); });
|
|
434
|
+
mountedRoots.clear();
|
|
435
|
+
sessionLinks.clear();
|
|
161
436
|
tag.remove();
|
|
162
437
|
});
|
|
163
438
|
}
|
package/lib/index.js
CHANGED
|
@@ -1,30 +1,73 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
2
|
+
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm/message'
|
|
2
3
|
|
|
3
4
|
export const name = 'dsh-agent-message'
|
|
4
|
-
export const inject = ['agents', 'tools']
|
|
5
|
+
export const inject = ['agents', 'tools', 'sessionQuery']
|
|
5
6
|
|
|
6
|
-
export function apply(ctx
|
|
7
|
+
export function apply(ctx) {
|
|
7
8
|
const agents = ctx.agents
|
|
8
|
-
/** 消息渲染形态:'user'=对话式气泡、期待回复(默认)|'relay'=上下文注入、静默影响代理行为、不期待回复。 */
|
|
9
|
-
const form = config?.form === 'relay' ? 'relay' : 'user'
|
|
10
|
-
let seq = 0
|
|
11
9
|
/** messageId -> { to, at, mode };发送成功即记账,供批量查询及补充本进程发送信息。 */
|
|
12
10
|
const sent = new Map()
|
|
13
11
|
/** 记账表 FIFO 上限:超过则淘汰最老记录,内存恒定。 */
|
|
14
12
|
const SENT_MAX = 1000
|
|
13
|
+
const PAIR_MESSAGE_LIMIT = 10
|
|
14
|
+
const PAIR_WINDOW_MS = 60_000
|
|
15
|
+
const receiptMeaning = 'claimed 仅表示目标 turn 已从 Inbox 认领消息;传输回执不表示对方已读、回复或完成。'
|
|
16
|
+
/** 插件恢复的 Session handle;保留到插件卸载,避免 idle dispose 移除 Harness store 投影。 */
|
|
17
|
+
const resumedHandles = new Map()
|
|
18
|
+
/** 无向 Session 对 -> 最近成功或正在进行的投递预留;只保护当前 Harness 进程。 */
|
|
19
|
+
const pairSends = new Map()
|
|
20
|
+
let lastPairPruneAt = 0
|
|
21
|
+
|
|
22
|
+
ctx.effect(() => async () => {
|
|
23
|
+
const handles = [...resumedHandles.values()]
|
|
24
|
+
resumedHandles.clear()
|
|
25
|
+
const settled = await Promise.allSettled(handles.map(async (pending) => (await pending).dispose()))
|
|
26
|
+
for (const result of settled) {
|
|
27
|
+
if (result.status === 'rejected') ctx.logger?.warn?.('释放恢复会话失败:' + String(result.reason))
|
|
28
|
+
}
|
|
29
|
+
}, 'dsh-agent-message: resumed agent handles')
|
|
30
|
+
|
|
31
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
32
|
+
const key = String(agent.id)
|
|
33
|
+
const pending = resumedHandles.get(key)
|
|
34
|
+
if (pending === undefined) return
|
|
35
|
+
pending.then((handle) => {
|
|
36
|
+
if (handle.agent === agent && resumedHandles.get(key) === pending) resumedHandles.delete(key)
|
|
37
|
+
}, () => {})
|
|
38
|
+
})
|
|
15
39
|
|
|
16
40
|
function rememberSent(messageId, to, mode) {
|
|
17
41
|
sent.set(messageId, { to, at: Date.now(), mode })
|
|
18
42
|
if (sent.size > SENT_MAX) sent.delete(sent.keys().next().value)
|
|
19
43
|
}
|
|
20
44
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
45
|
+
function reservePairSend(from, to) {
|
|
46
|
+
const now = Date.now()
|
|
47
|
+
if (now < lastPairPruneAt || now - lastPairPruneAt >= PAIR_WINDOW_MS) {
|
|
48
|
+
for (const [key, entries] of pairSends) {
|
|
49
|
+
const active = entries.filter((entry) => now - entry.at < PAIR_WINDOW_MS)
|
|
50
|
+
if (active.length === 0) pairSends.delete(key)
|
|
51
|
+
else pairSends.set(key, active)
|
|
52
|
+
}
|
|
53
|
+
lastPairPruneAt = now
|
|
54
|
+
}
|
|
55
|
+
const key = JSON.stringify([String(from), String(to)].sort())
|
|
56
|
+
const recent = (pairSends.get(key) ?? []).filter((entry) => now - entry.at < PAIR_WINDOW_MS)
|
|
57
|
+
if (recent.length >= PAIR_MESSAGE_LIMIT) {
|
|
58
|
+
pairSends.set(key, recent)
|
|
59
|
+
throw new Error('同一对会话 60 秒内最多投递 10 条消息,请稍后再试')
|
|
60
|
+
}
|
|
61
|
+
const reservation = { at: now }
|
|
62
|
+
recent.push(reservation)
|
|
63
|
+
pairSends.set(key, recent)
|
|
64
|
+
return () => {
|
|
65
|
+
const current = pairSends.get(key)
|
|
66
|
+
if (current === undefined) return
|
|
67
|
+
const index = current.indexOf(reservation)
|
|
68
|
+
if (index !== -1) current.splice(index, 1)
|
|
69
|
+
if (current.length === 0) pairSends.delete(key)
|
|
70
|
+
}
|
|
28
71
|
}
|
|
29
72
|
|
|
30
73
|
function titleOf(agent) {
|
|
@@ -57,72 +100,75 @@ export function apply(ctx, config) {
|
|
|
57
100
|
return new Set((workspace !== undefined ? workspace.archivedSessionIds : []).map((id) => String(id)))
|
|
58
101
|
}
|
|
59
102
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const { meta, events } = await persistence.readFrom(id, 0)
|
|
68
|
-
if (agents.get(id) !== undefined) throw new Error('target went live')
|
|
69
|
-
const state = foldInbox(events.slice(meta.seedLength ?? 0))
|
|
70
|
-
const start = state['next-turn'].length
|
|
71
|
-
const nextSeq = events.length === 0 ? 0 : events[events.length - 1].seq + 1
|
|
72
|
-
try {
|
|
73
|
-
await persistence.append(id, [{
|
|
74
|
-
seq: nextSeq,
|
|
75
|
-
type: 'agent/inbox/spliced',
|
|
76
|
-
time: Date.now(),
|
|
77
|
-
data: { target: 'next-turn', start, inserted: [message] },
|
|
78
|
-
}])
|
|
79
|
-
return
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (attempt === 0 && error instanceof Error && error.message.includes('append seq mismatch')) continue
|
|
82
|
-
throw error
|
|
83
|
-
}
|
|
103
|
+
function isSubagentSession(header) {
|
|
104
|
+
return header?.origin === 'subagent'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function assertPeerCaller(agent) {
|
|
108
|
+
if (agent !== undefined && isSubagentSession(agent.session.header)) {
|
|
109
|
+
throw new Error('子代理不能使用独立会话通信工具')
|
|
84
110
|
}
|
|
85
111
|
}
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
function sessionQuery() {
|
|
114
|
+
const query = ctx.get('sessionQuery')
|
|
115
|
+
if (query === undefined) throw new Error('本部署缺少 sessionQuery,无法查询逻辑会话')
|
|
116
|
+
return query
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function readLogicalSession(id) {
|
|
89
120
|
try {
|
|
90
|
-
await
|
|
91
|
-
return { usedMode: 'leave', fallback: '' }
|
|
121
|
+
return await sessionQuery().readSession(id)
|
|
92
122
|
} catch (error) {
|
|
93
|
-
|
|
94
|
-
if (nowLive !== undefined) {
|
|
95
|
-
nowLive.followup(message)
|
|
96
|
-
return { usedMode: 'followup', fallback: '留言写入时目标恰好上线,已改为在线投递' }
|
|
97
|
-
}
|
|
123
|
+
if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return undefined
|
|
98
124
|
throw error
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
|
|
102
|
-
/**
|
|
103
|
-
async function
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
/** 冷会话投递:公开 resume + followup;同一 Session 复用 handle,仅在插件卸载时释放。 */
|
|
129
|
+
async function resumeAndFollowup(id, message, inspected) {
|
|
130
|
+
const existing = agents.get(id)
|
|
131
|
+
if (existing !== undefined) {
|
|
132
|
+
existing.followup(message)
|
|
133
|
+
return existing
|
|
134
|
+
}
|
|
135
|
+
const key = String(id)
|
|
136
|
+
let pending = resumedHandles.get(key)
|
|
137
|
+
if (pending === undefined) {
|
|
138
|
+
let presetId = inspected.session.agentPreset
|
|
139
|
+
const events = inspected.events ?? []
|
|
140
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
141
|
+
const ev = events[i]
|
|
142
|
+
if (ev && ev.type === 'agent-preset/selected') {
|
|
143
|
+
presetId = ev.data.agentPreset
|
|
144
|
+
break
|
|
145
|
+
}
|
|
114
146
|
}
|
|
147
|
+
const presets = ctx.get('agentPresets')
|
|
148
|
+
const defaultModel = ctx.get('agentDefaultModel')
|
|
149
|
+
const selection = defaultModel !== undefined ? defaultModel.currentSelection() : { provider: '', model: '' }
|
|
150
|
+
pending = agents.resume({
|
|
151
|
+
resumeSessionId: id,
|
|
152
|
+
agentOptions: { provider: selection.provider ?? '', model: selection.model ?? '' },
|
|
153
|
+
...(presets !== undefined && presetId !== undefined
|
|
154
|
+
? { setup: async (agentCtx) => { await presets.mount(agentCtx, presetId) } }
|
|
155
|
+
: {}),
|
|
156
|
+
})
|
|
157
|
+
resumedHandles.set(key, pending)
|
|
158
|
+
pending.catch(() => {
|
|
159
|
+
if (resumedHandles.get(key) === pending) resumedHandles.delete(key)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let handle
|
|
164
|
+
try {
|
|
165
|
+
handle = await pending
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const concurrent = agents.get(id)
|
|
168
|
+
if (concurrent === undefined) throw error
|
|
169
|
+
concurrent.followup(message)
|
|
170
|
+
return concurrent
|
|
115
171
|
}
|
|
116
|
-
const presets = ctx.get('agentPresets')
|
|
117
|
-
const defaultModel = ctx.get('agentDefaultModel')
|
|
118
|
-
const selection = defaultModel !== undefined ? defaultModel.currentSelection() : { provider: '', model: '' }
|
|
119
|
-
const handle = await agents.resume({
|
|
120
|
-
resumeSessionId: id,
|
|
121
|
-
agentOptions: { provider: selection.provider ?? '', model: selection.model ?? '' },
|
|
122
|
-
...(presets !== undefined && presetId !== undefined
|
|
123
|
-
? { setup: async (agentCtx) => { await presets.mount(agentCtx, presetId) } }
|
|
124
|
-
: {}),
|
|
125
|
-
})
|
|
126
172
|
handle.agent.followup(message)
|
|
127
173
|
return handle.agent
|
|
128
174
|
}
|
|
@@ -138,10 +184,9 @@ export function apply(ctx, config) {
|
|
|
138
184
|
discarded: state.discarded,
|
|
139
185
|
}
|
|
140
186
|
}
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
const
|
|
144
|
-
const state = foldInbox(events.slice(meta.seedLength ?? 0))
|
|
187
|
+
const inspected = await readLogicalSession(to)
|
|
188
|
+
if (inspected === undefined) return { pending: new Set(), claimed: new Set(), discarded: new Set() }
|
|
189
|
+
const state = foldInbox(inspected.events.slice(inspected.session.seedLength ?? 0))
|
|
145
190
|
return {
|
|
146
191
|
pending: new Set(state['next-turn'].concat(state['next-step']).map((message) => message.id)),
|
|
147
192
|
claimed: state.claimed,
|
|
@@ -149,29 +194,21 @@ export function apply(ctx, config) {
|
|
|
149
194
|
}
|
|
150
195
|
}
|
|
151
196
|
|
|
152
|
-
/** 回执状态:
|
|
197
|
+
/** 回执状态:pending 排队中 / claimed 已认领 / discarded 被丢弃 / unknown 查无此消息。 */
|
|
153
198
|
function deliveryStateOf(messageId, snapshot) {
|
|
154
|
-
if (snapshot.pending.has(messageId)) return '
|
|
199
|
+
if (snapshot.pending.has(messageId)) return 'pending'
|
|
155
200
|
if (snapshot.discarded.has(messageId)) return 'discarded'
|
|
156
201
|
if (snapshot.claimed.has(messageId)) return 'claimed'
|
|
157
202
|
return 'unknown'
|
|
158
203
|
}
|
|
159
204
|
|
|
160
|
-
/** 会话恢复(用户打开)时,若有排队中的留言,唤醒它开始处理——留言"打开即达"。 */
|
|
161
|
-
ctx.on('agent/session-start', ({ agent }) => {
|
|
162
|
-
if (agent !== undefined && agent.inbox !== undefined
|
|
163
|
-
&& agent.inbox.nextTurn.some((message) => message.source?.plugin === name)) {
|
|
164
|
-
agent.wakeDriver()
|
|
165
|
-
}
|
|
166
|
-
})
|
|
167
|
-
|
|
168
205
|
ctx.tools.register(defineTool({
|
|
169
206
|
name: 'list_peer_agents',
|
|
170
207
|
description:
|
|
171
|
-
'
|
|
208
|
+
'列出逻辑会话目录中所有可发送(未归档且不是子代理)的 DeepSeek Harness 会话,用于跨会话通信。' +
|
|
172
209
|
'每条含:id(会话 ID)、标题、工作目录、status(offline=进程里未加载、重启后未打开;其余为在线)、' +
|
|
173
|
-
'kind
|
|
174
|
-
'找到目标会话后,用它的 id 调用 send_agent_message
|
|
210
|
+
'kind(固定为 peer,作为兼容字段)。在线在前、按标题排序。普通 fork 即使有 parentSession 也仍是独立会话。' +
|
|
211
|
+
'找到目标会话后,用它的 id 调用 send_agent_message 发送消息(目标离线时自动恢复后投递,恢复失败则返回错误)。' +
|
|
175
212
|
'注意:它不同于 list_agents(后者列的是你的后台子代理)。',
|
|
176
213
|
parameters: {},
|
|
177
214
|
output: {
|
|
@@ -182,42 +219,32 @@ export function apply(ctx, config) {
|
|
|
182
219
|
},
|
|
183
220
|
async execute(_args, exec) {
|
|
184
221
|
const me = exec.agent
|
|
222
|
+
assertPeerCaller(me)
|
|
185
223
|
const archived = archivedIds()
|
|
186
|
-
const
|
|
224
|
+
const query = sessionQuery()
|
|
187
225
|
const live = new Map()
|
|
188
226
|
for (const agent of agents.list()) live.set(String(agent.id), agent)
|
|
189
227
|
|
|
190
|
-
const ids = new Set()
|
|
191
|
-
for (const id of live.keys()) ids.add(id)
|
|
192
|
-
const headers = persistence !== undefined ? await persistence.list() : []
|
|
193
|
-
const headerMap = new Map(headers.map((header) => [String(header.id), header]))
|
|
194
|
-
for (const id of headerMap.keys()) ids.add(id)
|
|
195
|
-
|
|
196
228
|
const rows = []
|
|
197
|
-
|
|
229
|
+
const records = await query.listSessions()
|
|
230
|
+
for (const record of records) {
|
|
231
|
+
const header = record.header
|
|
232
|
+
const id = String(header.id)
|
|
198
233
|
if (archived.has(id)) continue
|
|
234
|
+
if (isSubagentSession(header)) continue
|
|
199
235
|
const agent = live.get(id)
|
|
200
|
-
const header = headerMap.get(id)
|
|
201
236
|
const status = agent !== undefined ? agent.status : 'offline'
|
|
202
|
-
|
|
203
|
-
|| (header !== undefined ? header.cwd : '') || ''
|
|
204
|
-
const kind = ((header !== undefined && header.parentSession !== undefined)
|
|
205
|
-
|| (agent !== undefined && agent.session !== undefined && agent.session.header !== undefined && agent.session.header.parentSession !== undefined))
|
|
206
|
-
? 'subagent' : 'peer'
|
|
207
|
-
rows.push({ id, title: '', cwd, status, kind, self: me !== undefined && String(me.id) === id })
|
|
237
|
+
rows.push({ id, title: '', cwd: header.cwd ?? '', status, kind: 'peer', self: me !== undefined && String(me.id) === id })
|
|
208
238
|
}
|
|
209
239
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (t.status === 'fulfilled' && t.value !== undefined && t.value.title !== undefined && typeof t.value.title.title === 'string') {
|
|
216
|
-
titleMap.set(String(t.sessionId), t.value.title.title)
|
|
217
|
-
}
|
|
240
|
+
const snapshots = await query.readTitleSnapshots(rows.map((r) => r.id))
|
|
241
|
+
const titleMap = new Map()
|
|
242
|
+
for (const t of snapshots) {
|
|
243
|
+
if (t.status === 'fulfilled' && t.value !== undefined && t.value.title !== undefined && typeof t.value.title.title === 'string') {
|
|
244
|
+
titleMap.set(String(t.sessionId), t.value.title.title)
|
|
218
245
|
}
|
|
219
|
-
for (const row of rows) row.title = titleMap.get(row.id) ?? ''
|
|
220
246
|
}
|
|
247
|
+
for (const row of rows) row.title = titleMap.get(row.id) ?? ''
|
|
221
248
|
|
|
222
249
|
const rank = (row) => (row.status === 'running' ? 0 : row.status === 'idle' ? 1 : 2)
|
|
223
250
|
rows.sort((a, b) => {
|
|
@@ -236,79 +263,102 @@ export function apply(ctx, config) {
|
|
|
236
263
|
ctx.tools.register(defineTool({
|
|
237
264
|
name: 'send_agent_message',
|
|
238
265
|
description:
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'
|
|
266
|
+
'向指定 Agent 或 Session 投递消息,用于执行当前请求或用户已授予的编排职责中的跨会话通信。' +
|
|
267
|
+
'@session 只提供目标,不代表发送。' +
|
|
268
|
+
'收到 relay 消息时,只有正文明确要求向发送方返回内容时才使用本工具回复 senderSessionId;' +
|
|
269
|
+
'不要回传单纯的 transport ack 或“收到”。' +
|
|
270
|
+
'默认使用 followup 创建独立的新 turn;' +
|
|
271
|
+
'目标离线(进程里未加载)时自动恢复该会话后投递。' +
|
|
272
|
+
'用户无需说出模式名:目标为 running 且整句明确要求立即介入时用 steer,明确要求不打断当前任务、只补充上下文时用 inject;不确定时使用 followup。' +
|
|
273
|
+
'同一对 Session 双向合计 60 秒内最多投递 10 条消息,超过时拒绝本次投递。' +
|
|
274
|
+
'归档会话和子代理一律拒绝。' +
|
|
242
275
|
'注意:它不同于 send_message(后者是给你的后台子代理续聊)。',
|
|
243
276
|
parameters: {
|
|
244
277
|
to: { type: 'string', required: true, description: '目标会话/Agent ID,来自 list_peer_agents 或复制到的会话 ID。' },
|
|
245
|
-
content: { type: 'string', required: true, description: '
|
|
246
|
-
mode: { type: 'string', enum: ['
|
|
278
|
+
content: { type: 'string', required: true, description: '只填用户要求转达的消息文本,不要自行追加“收到”、“请确认”或其他 transport ack 要求。' },
|
|
279
|
+
mode: { type: 'string', enum: ['followup', 'inject', 'steer'], description: '不确定时省略并使用 followup;steer=立即介入 running 任务;inject=不打断地补充 running 任务上下文。' },
|
|
247
280
|
},
|
|
248
281
|
output: {
|
|
249
282
|
schema: { type: 'json' },
|
|
250
|
-
render(
|
|
251
|
-
return [{ type: 'text', text:
|
|
283
|
+
render() {
|
|
284
|
+
return [{ type: 'text', text: '已投递。' }]
|
|
285
|
+
},
|
|
286
|
+
presentationMeta(_args, value) {
|
|
287
|
+
return value
|
|
252
288
|
},
|
|
253
289
|
},
|
|
290
|
+
presentResult(_args, result) {
|
|
291
|
+
const meta = result.meta
|
|
292
|
+
if (result.isError || meta === null || typeof meta !== 'object' || Array.isArray(meta)) return
|
|
293
|
+
return {
|
|
294
|
+
card: 'generic',
|
|
295
|
+
title: '消息已投递',
|
|
296
|
+
content: [{ type: 'text', text: meta.text || JSON.stringify(meta, null, 2) }],
|
|
297
|
+
}
|
|
298
|
+
},
|
|
254
299
|
async execute(args, exec) {
|
|
255
300
|
const me = exec.agent
|
|
256
301
|
if (me === undefined) throw new Error('no calling agent')
|
|
302
|
+
assertPeerCaller(me)
|
|
257
303
|
const to = args.to
|
|
258
304
|
if (to === '' || String(to) === String(me.id)) throw new Error('不能给自己发消息')
|
|
305
|
+
if (args.content.includes('<dsh-agent-message>') || args.content.includes('</dsh-agent-message>')) {
|
|
306
|
+
throw new Error('消息正文不能包含保留协议标签 dsh-agent-message')
|
|
307
|
+
}
|
|
259
308
|
|
|
260
309
|
const archived = archivedIds()
|
|
261
310
|
if (archived.has(String(to))) throw new Error('对方会话已归档,无法发送(请先取消归档)')
|
|
311
|
+
const mode = args.mode ?? 'followup'
|
|
312
|
+
const target = agents.get(to)
|
|
313
|
+
if (target !== undefined && isSubagentSession(target.session.header)) {
|
|
314
|
+
throw new Error('目标是子代理,不能通过会话通信插件直接发送')
|
|
315
|
+
}
|
|
316
|
+
if (target !== undefined && mode !== 'followup' && target.status !== 'running') {
|
|
317
|
+
throw new Error(mode + ' 仅用于 running 会话;目标当前状态:' + target.status)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let inspected
|
|
321
|
+
if (target === undefined) {
|
|
322
|
+
inspected = await readLogicalSession(to)
|
|
323
|
+
if (inspected === undefined) throw new Error('会话不存在:' + to)
|
|
324
|
+
if (isSubagentSession(inspected.session)) throw new Error('目标是子代理,不能通过会话通信插件直接发送')
|
|
325
|
+
if (mode !== 'followup') throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于 running 会话')
|
|
326
|
+
}
|
|
262
327
|
|
|
263
328
|
const myTitle = titleOf(me) || String(me.id)
|
|
264
329
|
const source = {
|
|
265
|
-
kind:
|
|
266
|
-
|
|
267
|
-
|
|
330
|
+
kind: name,
|
|
331
|
+
form: 'relay',
|
|
332
|
+
protocolVersion: 1,
|
|
268
333
|
senderSessionId: String(me.id),
|
|
334
|
+
targetSessionId: String(to),
|
|
269
335
|
senderTitle: myTitle,
|
|
270
336
|
}
|
|
271
|
-
const
|
|
337
|
+
const identified = createUserMessage({ content: [{ type: 'text', text: args.content }], source })
|
|
338
|
+
const relayHeader = JSON.stringify({ senderSessionId: source.senderSessionId })
|
|
339
|
+
const message = freezeMessage({
|
|
340
|
+
...identified,
|
|
341
|
+
content: [{ type: 'text', text: '<dsh-agent-message>' + relayHeader + '</dsh-agent-message>\n\n' + args.content }],
|
|
342
|
+
})
|
|
272
343
|
|
|
273
|
-
const target = agents.get(to)
|
|
274
344
|
let usedMode = ''
|
|
275
|
-
let
|
|
345
|
+
let targetRuntimeStatus = 'offline'
|
|
276
346
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const persistence = ctx.get('sessionPersistence')
|
|
285
|
-
const headers = persistence !== undefined ? await persistence.list() : []
|
|
286
|
-
const header = headers.find((h) => String(h.id) === String(to))
|
|
287
|
-
if (header === undefined) throw new Error('会话不存在:' + to)
|
|
288
|
-
const mode = args.mode ?? 'wake'
|
|
289
|
-
if (mode === 'steer' || mode === 'followup' || mode === 'inject') {
|
|
290
|
-
throw new Error('目标离线(进程里未加载):' + mode + ' 仅用于在线会话;不传 mode(自动激活)或用 wake/leave')
|
|
291
|
-
}
|
|
292
|
-
if (mode === 'wake') {
|
|
293
|
-
try {
|
|
294
|
-
await wakeOffline(to, message)
|
|
295
|
-
usedMode = 'wake'
|
|
296
|
-
} catch (error) {
|
|
297
|
-
try {
|
|
298
|
-
const result = await deliverLeave(to, message)
|
|
299
|
-
usedMode = result.usedMode
|
|
300
|
-
fallback = '激活失败:' + (error instanceof Error ? error.message : String(error))
|
|
301
|
-
+ (result.fallback !== '' ? ';' + result.fallback : '(已改为留言)')
|
|
302
|
-
} catch (leaveError) {
|
|
303
|
-
throw new Error('激活失败(' + (error instanceof Error ? error.message : String(error))
|
|
304
|
-
+ '),且留言也失败(' + (leaveError instanceof Error ? leaveError.message : String(leaveError)) + ')')
|
|
305
|
-
}
|
|
306
|
-
}
|
|
347
|
+
const rollbackPairSend = reservePairSend(me.id, to)
|
|
348
|
+
try {
|
|
349
|
+
if (target !== undefined) {
|
|
350
|
+
if (mode === 'followup') { target.followup(message); usedMode = mode }
|
|
351
|
+
else if (mode === 'inject') { target.inject(message); usedMode = 'inject' }
|
|
352
|
+
else { target.steer(message); usedMode = 'steer' }
|
|
353
|
+
targetRuntimeStatus = target.status
|
|
307
354
|
} else {
|
|
308
|
-
const
|
|
309
|
-
usedMode =
|
|
310
|
-
|
|
355
|
+
const resumed = await resumeAndFollowup(to, message, inspected)
|
|
356
|
+
usedMode = 'followup'
|
|
357
|
+
targetRuntimeStatus = resumed.status
|
|
311
358
|
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
rollbackPairSend()
|
|
361
|
+
throw error
|
|
312
362
|
}
|
|
313
363
|
|
|
314
364
|
rememberSent(message.id, String(to), usedMode)
|
|
@@ -317,8 +367,9 @@ export function apply(ctx, config) {
|
|
|
317
367
|
to: String(to),
|
|
318
368
|
mode: usedMode,
|
|
319
369
|
messageId: message.id,
|
|
320
|
-
|
|
321
|
-
|
|
370
|
+
state: 'accepted',
|
|
371
|
+
targetRuntimeStatus,
|
|
372
|
+
text: '会话 ' + String(to) + ' 已接受投递(' + usedMode + ')。这不表示对方已读、回复或完成。',
|
|
322
373
|
}
|
|
323
374
|
},
|
|
324
375
|
}))
|
|
@@ -327,7 +378,8 @@ export function apply(ctx, config) {
|
|
|
327
378
|
name: 'check_delivery',
|
|
328
379
|
description:
|
|
329
380
|
'按需查询发给某会话的消息状态(跨会话回执,默认安静——只有监督场景主动调用时才返回,不做任何自动播报)。' +
|
|
330
|
-
'状态:
|
|
381
|
+
'状态:pending=仍在目标 Inbox 排队;claimed=已被对方认领;discarded=被丢弃;unknown=查无此消息。' +
|
|
382
|
+
'claimed 不表示已读、已回复或任务完成。' +
|
|
331
383
|
'传 messageId 时可在进程重启后从目标 Inbox 日志恢复状态;不传则只返回本进程内发给该会话的全部已记账消息。',
|
|
332
384
|
parameters: {
|
|
333
385
|
to: { type: 'string', required: true, description: '目标会话 ID。' },
|
|
@@ -339,13 +391,14 @@ export function apply(ctx, config) {
|
|
|
339
391
|
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
340
392
|
},
|
|
341
393
|
},
|
|
342
|
-
async execute(args) {
|
|
394
|
+
async execute(args, exec) {
|
|
395
|
+
assertPeerCaller(exec?.agent)
|
|
343
396
|
const target = agents.get(args.to)
|
|
344
|
-
const
|
|
397
|
+
const targetRuntimeStatus = target !== undefined ? target.status : 'offline'
|
|
345
398
|
if (args.messageId !== undefined) {
|
|
346
399
|
const entry = sent.get(args.messageId)
|
|
347
400
|
if (entry !== undefined && String(entry.to) !== String(args.to)) {
|
|
348
|
-
return { to: args.to, entries: [{ messageId: args.messageId, state: 'unknown',
|
|
401
|
+
return { to: args.to, receiptMeaning, entries: [{ messageId: args.messageId, state: 'unknown', targetRuntimeStatus }] }
|
|
349
402
|
}
|
|
350
403
|
}
|
|
351
404
|
const wanted = args.messageId !== undefined
|
|
@@ -361,10 +414,10 @@ export function apply(ctx, config) {
|
|
|
361
414
|
messageId,
|
|
362
415
|
...(entry !== undefined ? { sentAt: entry.at, mode: entry.mode } : {}),
|
|
363
416
|
state,
|
|
364
|
-
|
|
417
|
+
targetRuntimeStatus,
|
|
365
418
|
})
|
|
366
419
|
}
|
|
367
|
-
return { to: args.to, entries }
|
|
420
|
+
return { to: args.to, receiptMeaning, entries }
|
|
368
421
|
},
|
|
369
422
|
}))
|
|
370
423
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agent-message",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"packageManager": "pnpm@11.1.1",
|
|
5
5
|
"description": "跨会话 Agent 通信:让 DeepSeek Harness 里不同的 Agent 会话互相收发消息。",
|
|
6
6
|
"keywords": ["deepseek", "deepseek-harness", "agent", "multi-agent", "messaging"],
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"inject": [
|
|
28
28
|
"@deepseek-ai/dsh-client-runtime",
|
|
29
29
|
"@deepseek-ai/dsh-client-ui-slots",
|
|
30
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
30
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
31
|
+
"@deepseek-ai/dsh-client-ui-input-trigger",
|
|
32
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
31
33
|
]
|
|
32
34
|
}
|
|
33
35
|
},
|
|
@@ -39,9 +41,11 @@
|
|
|
39
41
|
},
|
|
40
42
|
"peerDependencies": {
|
|
41
43
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
44
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6 <0.2.0",
|
|
42
45
|
"@deepseek-ai/dsh-tools": ">=0.1.0-rc.6 <0.2.0"
|
|
43
46
|
},
|
|
44
47
|
"devDependencies": {
|
|
48
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6",
|
|
45
49
|
"@deepseek-ai/dsh-tools": "0.1.0-rc.6"
|
|
46
50
|
},
|
|
47
51
|
"engines": {
|