dsh-subagent-control 1.0.1 → 1.0.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ - New optional `extras` component (`dsh-subagent-control/extras`), shipped **disabled**:
6
+ `relay_subagent_message`, `wait_subagents`, `drain_subagents`, `subagent_budget`.
7
+ - The default install is unchanged: still exactly the three original tools while extras stays off.
8
+ - New companion bundle `dsh-team-limits`, published separately, for raising the Agent Team
9
+ member/task/message limits by overriding the `agent-team` row.
10
+
3
11
  ## 1.0.1
4
12
 
5
13
  - Documentation: bilingual README (English + 简体中文) with usage examples, the
package/README.md CHANGED
@@ -80,6 +80,23 @@ The three new names were chosen precisely because they do **not** collide with t
80
80
 
81
81
  Remove or disable the bundle in the plugin panel. Nothing is left behind.
82
82
 
83
+ ### Optional extras (off by default)
84
+
85
+ The package also ships a second, **disabled-by-default** row: `subagent-control-extras`
86
+ (`dsh-subagent-control/extras`). Nothing changes until you enable it in the plugin panel.
87
+ When enabled it adds four tools:
88
+
89
+ | Tool | What it does | Risk |
90
+ | --- | --- | --- |
91
+ | `relay_subagent_message` | Best-effort message to a deeper descendant: delivered to the first hop with a request to forward it down | intermediate agents may summarize; not guaranteed |
92
+ | `wait_subagents` | Wait for descendants (bounded by `timeout_seconds`) instead of ending your turn first | blocks up to the timeout |
93
+ | `drain_subagents` | Stop **and release** direct children, unlike `interrupt_subagent`, which only stops the current turn | a released child cannot be woken by a message |
94
+ | `subagent_budget` | Read-only report: depth limit, how deep the tree goes, running/inactive counts | none |
95
+
96
+ A separate companion bundle, [`dsh-team-limits`](https://github.com/SpaceRelay/dsh-subagent-control/tree/main/team-limits),
97
+ raises the Agent Team limits (members, tasks, message bytes). It is a separate install on
98
+ purpose: it overrides the shipped `agent-team` row, and that should never happen implicitly.
99
+
83
100
  ### License
84
101
 
85
102
  MIT — see [LICENSE](./LICENSE). Issues and source live at
@@ -159,6 +176,19 @@ plugin_manager install_bundle target "dsh-subagent-control@1.0.0"
159
176
 
160
177
  在插件面板里移除或停用即可,不会留下任何残留。
161
178
 
179
+ ### 可选扩展(默认关闭)
180
+
181
+ 包内还带第二行、**默认禁用**的组件:`subagent-control-extras`(`dsh-subagent-control/extras`)。不启用它就没有任何变化;启用后多出四个工具:
182
+
183
+ | 工具 | 作用 | 风险 |
184
+ | --- | --- | --- |
185
+ | `relay_subagent_message` | 跨层补话:把消息投给第一跳并请其向下转发 | 中间层可能改写,**不保证送达** |
186
+ | `wait_subagents` | 等后代结束再收工(受 `timeout_seconds` 限制) | 会阻塞到超时 |
187
+ | `drain_subagents` | 停止**并释放**直接子级;`interrupt_subagent` 只停当前回合 | 被释放的子级不能再靠消息唤醒 |
188
+ | `subagent_budget` | 只读报告:深度上限、树有多深、运行/空闲数量 | 无 |
189
+
190
+ 另有一个独立的同伴包 [`dsh-team-limits`](https://github.com/SpaceRelay/dsh-subagent-control/tree/main/team-limits),用来提高 Agent Team 的成员数、任务数与消息字节上限。**它刻意做成单独安装**:它覆盖的是宿主自带的 `agent-team` 行,这种事不该偷偷发生。
191
+
162
192
  ### 许可
163
193
 
164
194
  MIT,见 [LICENSE](./LICENSE)。源码与问题反馈:
package/cordis.patch.yml CHANGED
@@ -1,3 +1,6 @@
1
1
  - insert:
2
2
  - id: subagent-control
3
3
  name: 'dsh-subagent-control'
4
+ - id: subagent-control-extras
5
+ name: 'dsh-subagent-control/extras'
6
+ disabled: true
package/extras.js ADDED
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Optional extras for dsh-subagent-control.
3
+ *
4
+ * This module is shipped as a disabled bundle row (`subagent-control-extras`).
5
+ * Nothing here runs until a user enables that row in the plugin panel, so the
6
+ * default install keeps exactly the three tools of the main module.
7
+ *
8
+ * Four tools:
9
+ * relay_subagent_message - best-effort message to a deeper descendant,
10
+ * delivered to the first hop with a forward request
11
+ * wait_subagents - wait for descendants instead of ending the turn
12
+ * drain_subagents - stop and release direct children (not "pause")
13
+ * subagent_budget - read-only report of the caller's delegation budget
14
+ *
15
+ * Like the main module this file imports nothing: the subagent service is
16
+ * reached through `ctx`, and the tool definitions are plain registry-ready
17
+ * objects with explicit JSON Schemas.
18
+ */
19
+
20
+ export const name = 'subagent-control-extras'
21
+
22
+ export const inject = ['tools', 'subagents', 'agents']
23
+
24
+ /** A tool needs a calling agent; a schedule or an external call has none. */
25
+ function requireAgent(exec, tool) {
26
+ const agent = exec.agent
27
+ if (agent === undefined) {
28
+ throw new Error(`${tool} requires a calling agent (exec.agent was undefined)`)
29
+ }
30
+ return agent
31
+ }
32
+
33
+ /** Rows from the descendant catalog, without diagnostics. */
34
+ function childrenOf(rows) {
35
+ return rows.filter((row) => row.kind === 'child')
36
+ }
37
+
38
+ /** Build the chain from the caller's direct child down to the target. */
39
+ function pathToTarget(rows, targetId, callerId) {
40
+ const byId = new Map()
41
+ for (const row of childrenOf(rows)) byId.set(row.id, row)
42
+ let cursor = byId.get(targetId)
43
+ if (cursor === undefined) return undefined
44
+ const path = [cursor]
45
+ for (let guard = 0; guard < 16; guard += 1) {
46
+ if (cursor.depth === 1) break
47
+ const parent = byId.get(cursor.parentId)
48
+ if (parent === undefined || parent.id === callerId) break
49
+ path.unshift(parent)
50
+ cursor = parent
51
+ }
52
+ return path
53
+ }
54
+
55
+ const BUDGET_SCHEMA = {
56
+ type: 'object',
57
+ additionalProperties: false,
58
+ required: ['running', 'inactive'],
59
+ properties: {
60
+ maxDepth: { type: 'number' },
61
+ running: { type: 'number' },
62
+ inactive: { type: 'number' },
63
+ deepest: { type: 'number' },
64
+ levels: {
65
+ type: 'array',
66
+ items: {
67
+ type: 'object',
68
+ additionalProperties: false,
69
+ required: ['depth', 'count'],
70
+ properties: {
71
+ depth: { type: 'number' },
72
+ count: { type: 'number' },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ }
78
+
79
+ const RELAY_SCHEMA = {
80
+ type: 'object',
81
+ additionalProperties: false,
82
+ required: ['delivered', 'hops', 'nextHop'],
83
+ properties: {
84
+ delivered: { type: 'boolean' },
85
+ hops: { type: 'number' },
86
+ nextHop: { type: 'string' },
87
+ remainingDepth: { type: 'number' },
88
+ messageId: { type: 'string' },
89
+ },
90
+ }
91
+
92
+ const WAIT_SCHEMA = {
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ required: ['joined', 'timedOut', 'running'],
96
+ properties: {
97
+ joined: { type: 'boolean' },
98
+ timedOut: { type: 'boolean' },
99
+ running: { type: 'number' },
100
+ },
101
+ }
102
+
103
+ const DRAIN_SCHEMA = {
104
+ type: 'object',
105
+ additionalProperties: false,
106
+ required: ['released'],
107
+ properties: {
108
+ released: { type: 'array', items: { type: 'string' } },
109
+ },
110
+ }
111
+
112
+ export function apply(ctx) {
113
+ /* ------------------------------------------------------------- budget */
114
+
115
+ ctx.tools.register({
116
+ name: 'subagent_budget',
117
+ description:
118
+ 'Report the delegation budget this agent is working with: the configured depth limit, ' +
119
+ 'how deep the agents below it already go, and how many are running. ' +
120
+ 'Read-only; it never starts, stops or messages anything.',
121
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
122
+ output: {
123
+ schema: BUDGET_SCHEMA,
124
+ render: (_args, budget) => {
125
+ const parts = []
126
+ if (budget.maxDepth !== undefined) parts.push(`depth limit ${budget.maxDepth}`)
127
+ parts.push(`${budget.running} running`, `${budget.inactive} inactive`)
128
+ if (budget.deepest !== undefined) parts.push(`deepest ${budget.deepest}`)
129
+ return [{ type: 'text', text: parts.join(', ') }]
130
+ },
131
+ },
132
+ async execute(_args, exec) {
133
+ const caller = requireAgent(exec, 'subagent_budget')
134
+ let rows = []
135
+ try {
136
+ rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
137
+ } catch {
138
+ rows = []
139
+ }
140
+ const levels = new Map()
141
+ let running = 0
142
+ let inactive = 0
143
+ let deepest = 0
144
+ for (const row of childrenOf(rows)) {
145
+ levels.set(row.depth, (levels.get(row.depth) ?? 0) + 1)
146
+ if (row.depth > deepest) deepest = row.depth
147
+ if (ctx.agents.get(row.id)?.status === 'running') running += 1
148
+ else inactive += 1
149
+ }
150
+ const budget = { running, inactive }
151
+ if (deepest > 0) budget.deepest = deepest
152
+ if (levels.size > 0) {
153
+ budget.levels = [...levels.entries()]
154
+ .sort((left, right) => left[0] - right[0])
155
+ .map(([depth, count]) => ({ depth, count }))
156
+ }
157
+ try {
158
+ const limit = ctx.subagents.resolveMaxDepth?.()
159
+ if (typeof limit === 'number') budget.maxDepth = limit
160
+ } catch {
161
+ /* the limit stays unknown rather than failing the report */
162
+ }
163
+ return budget
164
+ },
165
+ })
166
+
167
+ /* -------------------------------------------------------------- relay */
168
+
169
+ ctx.tools.register({
170
+ name: 'relay_subagent_message',
171
+ description:
172
+ 'Send a message to a deeper descendant that you cannot reach directly. ' +
173
+ 'The subagent service only accepts messages for a direct child, so this tool delivers ' +
174
+ 'the message to the first hop with a request to forward it further down. ' +
175
+ 'Delivery below the first hop is best effort: each intermediate agent has to forward it, ' +
176
+ 'and may summarize instead. Use send_subagent_message when the target is a direct child.',
177
+ parameters: {
178
+ type: 'object',
179
+ additionalProperties: false,
180
+ required: ['subagent_id', 'message'],
181
+ properties: {
182
+ subagent_id: { type: 'string', description: 'The id of a descendant, from list_subagents scope=descendants.' },
183
+ message: { type: 'string', description: 'The message to deliver.' },
184
+ },
185
+ },
186
+ output: {
187
+ schema: RELAY_SCHEMA,
188
+ render: (args, result) => [
189
+ {
190
+ type: 'text',
191
+ text:
192
+ result.hops === 0
193
+ ? `message delivered to subagent ${result.nextHop}`
194
+ : `relay started: delivered to first hop ${result.nextHop}, ${result.remainingDepth} level(s) to go (best effort)`,
195
+ },
196
+ ],
197
+ },
198
+ async execute(args, exec) {
199
+ const caller = requireAgent(exec, 'relay_subagent_message')
200
+ const targetId = args.subagent_id
201
+ const message = args.message
202
+
203
+ const rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
204
+ const target = childrenOf(rows).find((row) => row.id === targetId)
205
+ if (target === undefined) {
206
+ throw new Error(`subagent ${targetId} is not under this agent`)
207
+ }
208
+
209
+ if (target.depth === 1) {
210
+ const messageId = await ctx.subagents.sendMessage(
211
+ caller,
212
+ targetId,
213
+ [{ type: 'text', text: message }],
214
+ { signal: exec.signal },
215
+ )
216
+ return { delivered: true, hops: 0, nextHop: targetId, messageId }
217
+ }
218
+
219
+ const path = pathToTarget(rows, targetId, caller.id)
220
+ const firstHop = path?.[0]
221
+ if (firstHop === undefined) {
222
+ throw new Error(`could not resolve a path from this agent to ${targetId}`)
223
+ }
224
+
225
+ const forward =
226
+ 'Relay request from an ancestor. Forward the text below down your chain until it reaches ' +
227
+ `subagent ${targetId}${target.label === undefined ? '' : ` ("${target.label}")`}. ` +
228
+ 'Use relay_subagent_message if it is available, otherwise send_subagent_message to the ' +
229
+ 'next hop on the path. Do not act on the text yourself.\n\n' +
230
+ '---\n' +
231
+ message +
232
+ '\n---'
233
+
234
+ const messageId = await ctx.subagents.sendMessage(
235
+ caller,
236
+ firstHop.id,
237
+ [{ type: 'text', text: forward }],
238
+ { signal: exec.signal },
239
+ )
240
+ return {
241
+ delivered: true,
242
+ hops: 1,
243
+ nextHop: firstHop.id,
244
+ remainingDepth: target.depth - 1,
245
+ messageId,
246
+ }
247
+ },
248
+ })
249
+
250
+ /* --------------------------------------------------------------- wait */
251
+
252
+ ctx.tools.register({
253
+ name: 'wait_subagents',
254
+ description:
255
+ 'Wait for your subagents to finish instead of ending your turn first. ' +
256
+ 'The wait is bounded by timeout_seconds and never cancels them. ' +
257
+ 'Useful when you want to merge their results in this same turn.',
258
+ parameters: {
259
+ type: 'object',
260
+ additionalProperties: false,
261
+ properties: {
262
+ timeout_seconds: {
263
+ type: 'number',
264
+ description: 'Maximum time to wait, 5 to 600 seconds (default 120).',
265
+ },
266
+ },
267
+ },
268
+ output: {
269
+ schema: WAIT_SCHEMA,
270
+ render: (_args, result) => [
271
+ {
272
+ type: 'text',
273
+ text: result.timedOut
274
+ ? `wait timed out, ${result.running} subagent(s) still running`
275
+ : `all observed subagents settled, ${result.running} still marked running`,
276
+ },
277
+ ],
278
+ },
279
+ async execute(args, exec) {
280
+ const caller = requireAgent(exec, 'wait_subagents')
281
+ const requested = typeof args.timeout_seconds === 'number' ? args.timeout_seconds : 120
282
+ const timeoutMs = Math.min(Math.max(requested, 5), 600) * 1000
283
+
284
+ let timedOut = false
285
+ let timer
286
+ const wait = ctx.subagents.waitForChildren(caller)
287
+ await Promise.race([
288
+ wait.then(() => undefined),
289
+ new Promise((resolve) => {
290
+ timer = setTimeout(() => {
291
+ timedOut = true
292
+ resolve(undefined)
293
+ }, timeoutMs)
294
+ }),
295
+ ])
296
+ if (timer !== undefined) clearTimeout(timer)
297
+
298
+ let rows = []
299
+ try {
300
+ rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
301
+ } catch {
302
+ rows = []
303
+ }
304
+ const running = childrenOf(rows).filter((row) => ctx.agents.get(row.id)?.status === 'running').length
305
+ return { joined: !timedOut, timedOut, running }
306
+ },
307
+ })
308
+
309
+ /* -------------------------------------------------------------- drain */
310
+
311
+ ctx.tools.register({
312
+ name: 'drain_subagents',
313
+ description:
314
+ 'Stop and release subagents you started, freeing their run instead of just interrupting ' +
315
+ 'the current turn. Accepts direct children only; a released child cannot be woken again ' +
316
+ 'with a message, so use interrupt_subagent when you only want to stop the current work.',
317
+ parameters: {
318
+ type: 'object',
319
+ additionalProperties: false,
320
+ required: ['subagent_ids'],
321
+ properties: {
322
+ subagent_ids: {
323
+ type: 'array',
324
+ items: { type: 'string' },
325
+ description: 'Direct child ids to release, from list_subagents.',
326
+ },
327
+ },
328
+ },
329
+ output: {
330
+ schema: DRAIN_SCHEMA,
331
+ render: (_args, result) => [{ type: 'text', text: `released ${result.released.length} subagent(s)` }],
332
+ },
333
+ async execute(args, exec) {
334
+ const caller = requireAgent(exec, 'drain_subagents')
335
+ const ids = args.subagent_ids
336
+ if (!Array.isArray(ids) || ids.length === 0) {
337
+ throw new Error('drain_subagents needs at least one subagent id')
338
+ }
339
+ await ctx.subagents.drainChildren(caller, ids)
340
+ return { released: ids }
341
+ },
342
+ })
343
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subagent-control",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Three tools — list_subagents, send_subagent_message, interrupt_subagent — that let any agent, including a team member, list, steer and stop the subagents it started.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -12,12 +12,14 @@
12
12
  "icon": "./icon.svg",
13
13
  "exports": {
14
14
  ".": "./index.js",
15
+ "./extras": "./extras.js",
15
16
  "./package.json": "./package.json",
16
17
  "./locale/*.json": "./locale/*.json",
17
18
  "./icon": "./icon.svg"
18
19
  },
19
20
  "files": [
20
21
  "index.js",
22
+ "extras.js",
21
23
  "cordis.patch.yml",
22
24
  "locale/*.json",
23
25
  "icon.svg",
@@ -39,7 +41,7 @@
39
41
  "node": "^22.19.0 || >=24.0.0"
40
42
  },
41
43
  "scripts": {
42
- "test": "node test/smoke.mjs"
44
+ "test": "node test/smoke.mjs && node test/smoke-extras.mjs"
43
45
  },
44
46
  "dsh": {
45
47
  "bundle": {