dsh-subagent-control 1.0.2 → 1.0.4

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.4
4
+
5
+ - The single optional `extras` row is split into four independent components, each with its own
6
+ switch in the plugin panel and its own localized title and description: Cross-layer relay,
7
+ Wait for subagents, Release subagents, Delegation budget. Enabling one registers exactly one
8
+ tool instead of all four.
9
+ - Removed the `dsh-subagent-control/extras` export in favour of `./relay`, `./wait`, `./drain`
10
+ and `./budget`, each also exporting `package.json` and `locale/*.json` for the panel.
11
+ - Unchanged: the base row still registers exactly the three original tools, and every component
12
+ still starts disabled.
13
+
14
+ ## 1.0.3
15
+
16
+ - `list_subagents` marks Team members, which are continuable subagents: each row carries
17
+ `team=teammate` (or `team=lead`) when the Agent Teams service can resolve it.
18
+ - New `include_teammates` parameter (default `true`); set it to `false` to keep only plain
19
+ subagents. Existing behaviour is unchanged for callers that pass nothing.
20
+
3
21
  ## 1.0.2
4
22
 
5
23
  - New optional `extras` component (`dsh-subagent-control/extras`), shipped **disabled**:
package/README.md CHANGED
@@ -45,6 +45,8 @@ interrupt_subagent subagent_id=3b0f9c1e
45
45
 
46
46
  Each listing line is `id status "label" depth=`. `status` is `running` or `inactive` — **inactive means no turn is executing, not that the subagent is gone**. Use `send_subagent_message` to wake it.
47
47
 
48
+ Team members are continuable subagents too, so they also appear in this list, marked `team=teammate` (or `team=lead`). Pass `include_teammates: false` when you want only the plain subagents you started.
49
+
48
50
  ### Behaviour and limits
49
51
 
50
52
  - **One-shot subagents are hidden.** They cannot accept a follow-up message, so listing them would only invite a call that is guaranteed to fail.
@@ -80,18 +82,19 @@ The three new names were chosen precisely because they do **not** collide with t
80
82
 
81
83
  Remove or disable the bundle in the plugin panel. Nothing is left behind.
82
84
 
83
- ### Optional extras (off by default)
85
+ ### Optional components (each off by default)
84
86
 
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:
87
+ Four extra tools ship as **separate components**, each with its own row and switch in the plugin
88
+ panel (`Cross-layer relay`, `Wait for subagents`, `Release subagents`, `Delegation budget`).
89
+ All four start disabled; turning one on registers exactly one tool, so you can enable only what
90
+ you need.
88
91
 
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 |
92
+ | Component | Tool | What it does | Risk |
93
+ | --- | --- | --- | --- |
94
+ | Cross-layer relay | `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 |
95
+ | Wait for subagents | `wait_subagents` | Wait for descendants (bounded by `timeout_seconds`) instead of ending your turn first | blocks up to the timeout |
96
+ | Release subagents | `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 |
97
+ | Delegation budget | `subagent_budget` | Read-only report: depth limit, how deep the tree goes, running/inactive counts | none |
95
98
 
96
99
  A separate companion bundle, [`dsh-team-limits`](https://github.com/SpaceRelay/dsh-subagent-control/tree/main/team-limits),
97
100
  raises the Agent Team limits (members, tasks, message bytes). It is a separate install on
@@ -141,6 +144,8 @@ interrupt_subagent subagent_id=3b0f9c1e
141
144
 
142
145
  每行格式是 `id status "label" depth=`。`status` 为 `running` 或 `inactive`——**inactive 表示当前没有在跑,不代表子代理已经消失**;用 `send_subagent_message` 可以把它叫醒。
143
146
 
147
+ 团队成员本身也是可续子代理,所以同样会出现在列表里,并带 `team=teammate`(或 `team=lead`)标记;只想看自己派的普通子代理时,传 `include_teammates: false`。
148
+
144
149
  ### 行为与边界
145
150
 
146
151
  - **一次性子代理不会被列出。** 它们无法接收后续消息,列出来只会诱导一次注定失败的调用。
@@ -176,16 +181,16 @@ plugin_manager install_bundle target "dsh-subagent-control@1.0.0"
176
181
 
177
182
  在插件面板里移除或停用即可,不会留下任何残留。
178
183
 
179
- ### 可选扩展(默认关闭)
184
+ ### 可选组件(各自默认关闭)
180
185
 
181
- 包内还带第二行、**默认禁用**的组件:`subagent-control-extras`(`dsh-subagent-control/extras`)。不启用它就没有任何变化;启用后多出四个工具:
186
+ 另外四个工具是**四个独立组件**,在插件面板里各有自己的一行和一个开关(中文界面显示:跨层补话 / 等待子代理 / 释放子代理 / 委派预算)。四个都默认关闭,打开哪一个就只注册那一个工具——只开你需要的。
182
187
 
183
- | 工具 | 作用 | 风险 |
184
- | --- | --- | --- |
185
- | `relay_subagent_message` | 跨层补话:把消息投给第一跳并请其向下转发 | 中间层可能改写,**不保证送达** |
186
- | `wait_subagents` | 等后代结束再收工(受 `timeout_seconds` 限制) | 会阻塞到超时 |
187
- | `drain_subagents` | 停止**并释放**直接子级;`interrupt_subagent` 只停当前回合 | 被释放的子级不能再靠消息唤醒 |
188
- | `subagent_budget` | 只读报告:深度上限、树有多深、运行/空闲数量 | 无 |
188
+ | 组件 | 工具 | 作用 | 风险 |
189
+ | --- | --- | --- | --- |
190
+ | 跨层补话 | `relay_subagent_message` | 把消息投给第一跳并请它继续向下转发 | 中间层可能改写,**不保证送达** |
191
+ | 等待子代理 | `wait_subagents` | 等后代结束再收工(受 `timeout_seconds` 限制) | 会阻塞到超时 |
192
+ | 释放子代理 | `drain_subagents` | 停止**并释放**直接子级;`interrupt_subagent` 只停当前回合 | 被释放的子级不能再靠消息唤醒 |
193
+ | 委派预算 | `subagent_budget` | 只读报告:深度上限、树有多深、运行/空闲数量 | 无 |
189
194
 
190
195
  另有一个独立的同伴包 [`dsh-team-limits`](https://github.com/SpaceRelay/dsh-subagent-control/tree/main/team-limits),用来提高 Agent Team 的成员数、任务数与消息字节上限。**它刻意做成单独安装**:它覆盖的是宿主自带的 `agent-team` 行,这种事不该偷偷发生。
191
196
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Delegation budget: a read-only look at what the caller has below it.
3
+ *
4
+ * Use it before planning a fan-out: how deep the chain already goes, how many
5
+ * agents sit on each level, and how many are running. It never starts, stops or
6
+ * messages anything.
7
+ */
8
+ import { childrenOf, requireAgent } from '../shared.js'
9
+
10
+ export const name = 'subagent-control-budget'
11
+
12
+ export const inject = ['tools', 'subagents', 'agents']
13
+
14
+ const BUDGET_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ required: ['running', 'inactive'],
18
+ properties: {
19
+ maxDepth: { type: 'number' },
20
+ running: { type: 'number' },
21
+ inactive: { type: 'number' },
22
+ deepest: { type: 'number' },
23
+ levels: {
24
+ type: 'array',
25
+ items: {
26
+ type: 'object',
27
+ additionalProperties: false,
28
+ required: ['depth', 'count'],
29
+ properties: {
30
+ depth: { type: 'number' },
31
+ count: { type: 'number' },
32
+ },
33
+ },
34
+ },
35
+ },
36
+ }
37
+
38
+ export function apply(ctx) {
39
+ ctx.tools.register({
40
+ name: 'subagent_budget',
41
+ description:
42
+ 'Report the delegation budget this agent is working with: the configured depth limit, ' +
43
+ 'how deep the agents below it already go, and how many are running. ' +
44
+ 'Read-only; it never starts, stops or messages anything.',
45
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
46
+ output: {
47
+ schema: BUDGET_SCHEMA,
48
+ render: (_args, budget) => {
49
+ const parts = []
50
+ if (budget.maxDepth !== undefined) parts.push(`depth limit ${budget.maxDepth}`)
51
+ parts.push(`${budget.running} running`, `${budget.inactive} inactive`)
52
+ if (budget.deepest !== undefined) parts.push(`deepest ${budget.deepest}`)
53
+ return [{ type: 'text', text: parts.join(', ') }]
54
+ },
55
+ },
56
+ async execute(_args, exec) {
57
+ const caller = requireAgent(exec, 'subagent_budget')
58
+ let rows = []
59
+ try {
60
+ rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
61
+ } catch {
62
+ rows = []
63
+ }
64
+ const levels = new Map()
65
+ let running = 0
66
+ let inactive = 0
67
+ let deepest = 0
68
+ for (const row of childrenOf(rows)) {
69
+ levels.set(row.depth, (levels.get(row.depth) ?? 0) + 1)
70
+ if (row.depth > deepest) deepest = row.depth
71
+ if (ctx.agents.get(row.id)?.status === 'running') running += 1
72
+ else inactive += 1
73
+ }
74
+ const budget = { running, inactive }
75
+ if (deepest > 0) budget.deepest = deepest
76
+ if (levels.size > 0) {
77
+ budget.levels = [...levels.entries()]
78
+ .sort((left, right) => left[0] - right[0])
79
+ .map(([depth, count]) => ({ depth, count }))
80
+ }
81
+ try {
82
+ const limit = ctx.subagents.resolveMaxDepth?.()
83
+ if (typeof limit === 'number') budget.maxDepth = limit
84
+ } catch {
85
+ /* the limit stays unknown rather than failing the report */
86
+ }
87
+ return budget
88
+ },
89
+ })
90
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Delegation budget",
4
+ "description": "Read-only report of the delegation budget: depth limit, levels, running and inactive counts."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "委派预算",
4
+ "description": "只读报告:深度上限、各层人数、最深一层、运行中与已停止的数量。规划扇出前先看一眼。"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Delegation budget",
3
+ "description": "Read-only report of the delegation budget: depth limit, levels, running and inactive counts.",
4
+ "type": "module"
5
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Drain: stop and release direct children, freeing their run.
3
+ *
4
+ * interrupt_subagent only ends the current turn and keeps the activation, so a
5
+ * stopped subagent stays in the catalog. Drain removes the run itself, which is
6
+ * why a released child can never be woken with another message.
7
+ */
8
+ import { requireAgent } from '../shared.js'
9
+
10
+ export const name = 'subagent-control-drain'
11
+
12
+ export const inject = ['tools', 'subagents', 'agents']
13
+
14
+ const DRAIN_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ required: ['released'],
18
+ properties: {
19
+ released: { type: 'array', items: { type: 'string' } },
20
+ },
21
+ }
22
+
23
+ export function apply(ctx) {
24
+ ctx.tools.register({
25
+ name: 'drain_subagents',
26
+ description:
27
+ 'Stop and release subagents you started, freeing their run instead of just interrupting ' +
28
+ 'the current turn. Accepts direct children only; a released child cannot be woken again ' +
29
+ 'with a message, so use interrupt_subagent when you only want to stop the current work.',
30
+ parameters: {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ required: ['subagent_ids'],
34
+ properties: {
35
+ subagent_ids: {
36
+ type: 'array',
37
+ items: { type: 'string' },
38
+ description: 'Direct child ids to release, from list_subagents.',
39
+ },
40
+ },
41
+ },
42
+ output: {
43
+ schema: DRAIN_SCHEMA,
44
+ render: (_args, result) => [{ type: 'text', text: `released ${result.released.length} subagent(s)` }],
45
+ },
46
+ async execute(args, exec) {
47
+ const caller = requireAgent(exec, 'drain_subagents')
48
+ const ids = args.subagent_ids
49
+ if (!Array.isArray(ids) || ids.length === 0) {
50
+ throw new Error('drain_subagents needs at least one subagent id')
51
+ }
52
+ await ctx.subagents.drainChildren(caller, ids)
53
+ return { released: ids }
54
+ },
55
+ })
56
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Release subagents",
4
+ "description": "Stop and release direct children, freeing their run; a released agent cannot be woken again."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "释放子代理",
4
+ "description": "停止并释放直接子代理,真正腾出运行实例;释放后不能再唤醒,只想停当前回合请用 interrupt_subagent。"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Release subagents",
3
+ "description": "Stop and release direct children, freeing their run; a released agent cannot be woken again.",
4
+ "type": "module"
5
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Cross-layer relay: hand a message to the first hop and ask it to forward.
3
+ *
4
+ * The subagent service only accepts messages for a direct child, so a deeper
5
+ * descendant is reached by a prompt-level relay. It is best effort by nature:
6
+ * the middle agent has to cooperate, and may summarize instead of forwarding.
7
+ */
8
+ import { childrenOf, pathToTarget, requireAgent } from '../shared.js'
9
+
10
+ export const name = 'subagent-control-relay'
11
+
12
+ export const inject = ['tools', 'subagents', 'agents']
13
+
14
+ const RELAY_SCHEMA = {
15
+ type: 'object',
16
+ additionalProperties: false,
17
+ required: ['delivered', 'hops', 'nextHop'],
18
+ properties: {
19
+ delivered: { type: 'boolean' },
20
+ hops: { type: 'number' },
21
+ nextHop: { type: 'string' },
22
+ remainingDepth: { type: 'number' },
23
+ messageId: { type: 'string' },
24
+ },
25
+ }
26
+
27
+ export function apply(ctx) {
28
+ ctx.tools.register({
29
+ name: 'relay_subagent_message',
30
+ description:
31
+ 'Send a message to a deeper descendant that you cannot reach directly. ' +
32
+ 'The subagent service only accepts messages for a direct child, so this tool delivers ' +
33
+ 'the message to the first hop with a request to forward it further down. ' +
34
+ 'Delivery below the first hop is best effort: each intermediate agent has to forward it, ' +
35
+ 'and may summarize instead. Use send_subagent_message when the target is a direct child.',
36
+ parameters: {
37
+ type: 'object',
38
+ additionalProperties: false,
39
+ required: ['subagent_id', 'message'],
40
+ properties: {
41
+ subagent_id: { type: 'string', description: 'The id of a descendant, from list_subagents scope=descendants.' },
42
+ message: { type: 'string', description: 'The message to deliver.' },
43
+ },
44
+ },
45
+ output: {
46
+ schema: RELAY_SCHEMA,
47
+ render: (_args, result) => [
48
+ {
49
+ type: 'text',
50
+ text:
51
+ result.hops === 0
52
+ ? `message delivered to subagent ${result.nextHop}`
53
+ : `relay started: delivered to first hop ${result.nextHop}, ${result.remainingDepth} level(s) to go (best effort)`,
54
+ },
55
+ ],
56
+ },
57
+ async execute(args, exec) {
58
+ const caller = requireAgent(exec, 'relay_subagent_message')
59
+ const targetId = args.subagent_id
60
+ const message = args.message
61
+
62
+ const rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
63
+ const target = childrenOf(rows).find((row) => row.id === targetId)
64
+ if (target === undefined) {
65
+ throw new Error(`subagent ${targetId} is not under this agent`)
66
+ }
67
+
68
+ if (target.depth === 1) {
69
+ const messageId = await ctx.subagents.sendMessage(
70
+ caller,
71
+ targetId,
72
+ [{ type: 'text', text: message }],
73
+ { signal: exec.signal },
74
+ )
75
+ return { delivered: true, hops: 0, nextHop: targetId, messageId }
76
+ }
77
+
78
+ const path = pathToTarget(rows, targetId, caller.id)
79
+ const firstHop = path?.[0]
80
+ if (firstHop === undefined) {
81
+ throw new Error(`could not resolve a path from this agent to ${targetId}`)
82
+ }
83
+
84
+ const forward =
85
+ 'Relay request from an ancestor. Forward the text below down your chain until it reaches ' +
86
+ `subagent ${targetId}${target.label === undefined ? '' : ` ("${target.label}")`}. ` +
87
+ 'Use relay_subagent_message if it is available, otherwise send_subagent_message to the ' +
88
+ 'next hop on the path. Do not act on the text yourself.\n\n' +
89
+ '---\n' +
90
+ message +
91
+ '\n---'
92
+
93
+ const messageId = await ctx.subagents.sendMessage(
94
+ caller,
95
+ firstHop.id,
96
+ [{ type: 'text', text: forward }],
97
+ { signal: exec.signal },
98
+ )
99
+ return {
100
+ delivered: true,
101
+ hops: 1,
102
+ nextHop: firstHop.id,
103
+ remainingDepth: target.depth - 1,
104
+ messageId,
105
+ }
106
+ },
107
+ })
108
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Cross-layer relay",
4
+ "description": "Best-effort message relay to a deeper descendant; the middle hop must forward it, and may summarize instead."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "跨层补话",
4
+ "description": "把消息交给中间层,请它继续传给更深的后代。送达属于尽力而为,中间层可能概括内容。"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Cross-layer relay",
3
+ "description": "Best-effort message relay to a deeper descendant; the middle hop must forward it.",
4
+ "type": "module"
5
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Helpers shared by the optional components.
3
+ *
4
+ * Plain relative module: this package imports no bare specifier, so a linked
5
+ * checkout and an npm install resolve exactly the same way.
6
+ */
7
+
8
+ /** A tool needs a calling agent; a schedule or an external call has none. */
9
+ export function requireAgent(exec, tool) {
10
+ const agent = exec.agent
11
+ if (agent === undefined) {
12
+ throw new Error(`${tool} requires a calling agent (exec.agent was undefined)`)
13
+ }
14
+ return agent
15
+ }
16
+
17
+ /** Rows from the descendant catalog, without diagnostics. */
18
+ export function childrenOf(rows) {
19
+ return rows.filter((row) => row.kind === 'child')
20
+ }
21
+
22
+ /** Build the chain from the caller's direct child down to the target. */
23
+ export function pathToTarget(rows, targetId, callerId) {
24
+ const byId = new Map()
25
+ for (const row of childrenOf(rows)) byId.set(row.id, row)
26
+ let cursor = byId.get(targetId)
27
+ if (cursor === undefined) return undefined
28
+ const path = [cursor]
29
+ for (let guard = 0; guard < 16; guard += 1) {
30
+ if (cursor.depth === 1) break
31
+ const parent = byId.get(cursor.parentId)
32
+ if (parent === undefined || parent.id === callerId) break
33
+ path.unshift(parent)
34
+ cursor = parent
35
+ }
36
+ return path
37
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Wait for subagents inside the current turn.
3
+ *
4
+ * The wait is bounded and never cancels anything: it only decides whether this
5
+ * turn keeps going or reports a timeout with what is still running.
6
+ */
7
+ import { childrenOf, requireAgent } from '../shared.js'
8
+
9
+ export const name = 'subagent-control-wait'
10
+
11
+ export const inject = ['tools', 'subagents', 'agents']
12
+
13
+ const WAIT_SCHEMA = {
14
+ type: 'object',
15
+ additionalProperties: false,
16
+ required: ['joined', 'timedOut', 'running'],
17
+ properties: {
18
+ joined: { type: 'boolean' },
19
+ timedOut: { type: 'boolean' },
20
+ running: { type: 'number' },
21
+ },
22
+ }
23
+
24
+ export function apply(ctx) {
25
+ ctx.tools.register({
26
+ name: 'wait_subagents',
27
+ description:
28
+ 'Wait for your subagents to finish instead of ending your turn first. ' +
29
+ 'The wait is bounded by timeout_seconds and never cancels them. ' +
30
+ 'Useful when you want to merge their results in this same turn.',
31
+ parameters: {
32
+ type: 'object',
33
+ additionalProperties: false,
34
+ properties: {
35
+ timeout_seconds: {
36
+ type: 'number',
37
+ description: 'Maximum time to wait, 5 to 600 seconds (default 120).',
38
+ },
39
+ },
40
+ },
41
+ output: {
42
+ schema: WAIT_SCHEMA,
43
+ render: (_args, result) => [
44
+ {
45
+ type: 'text',
46
+ text: result.timedOut
47
+ ? `wait timed out, ${result.running} subagent(s) still running`
48
+ : `all observed subagents settled, ${result.running} still marked running`,
49
+ },
50
+ ],
51
+ },
52
+ async execute(args, exec) {
53
+ const caller = requireAgent(exec, 'wait_subagents')
54
+ const requested = typeof args.timeout_seconds === 'number' ? args.timeout_seconds : 120
55
+ const timeoutMs = Math.min(Math.max(requested, 5), 600) * 1000
56
+
57
+ let timedOut = false
58
+ let timer
59
+ const wait = ctx.subagents.waitForChildren(caller)
60
+ await Promise.race([
61
+ wait.then(() => undefined),
62
+ new Promise((resolve) => {
63
+ timer = setTimeout(() => {
64
+ timedOut = true
65
+ resolve(undefined)
66
+ }, timeoutMs)
67
+ }),
68
+ ])
69
+ if (timer !== undefined) clearTimeout(timer)
70
+
71
+ let rows = []
72
+ try {
73
+ rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
74
+ } catch {
75
+ rows = []
76
+ }
77
+ const running = childrenOf(rows).filter((row) => ctx.agents.get(row.id)?.status === 'running').length
78
+ return { joined: !timedOut, timedOut, running }
79
+ },
80
+ })
81
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Wait for subagents",
4
+ "description": "Wait for your subagents in this turn instead of ending it; a timeout never cancels them."
5
+ }
6
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "等待子代理",
4
+ "description": "在当前回合里等子代理跑完再继续,而不是先结束回合;超时也不会取消它们。"
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "Wait for subagents",
3
+ "description": "Wait for your subagents in this turn instead of ending it; a timeout never cancels them.",
4
+ "type": "module"
5
+ }
package/cordis.patch.yml CHANGED
@@ -1,6 +1,18 @@
1
+ # Four opt-in components, each with its own switch in the plugin panel.
2
+ # The base row is the three core tools; every component below starts disabled,
3
+ # so a default install registers nothing beyond those three.
1
4
  - insert:
2
5
  - id: subagent-control
3
6
  name: 'dsh-subagent-control'
4
- - id: subagent-control-extras
5
- name: 'dsh-subagent-control/extras'
7
+ - id: subagent-control-relay
8
+ name: 'dsh-subagent-control/relay'
9
+ disabled: true
10
+ - id: subagent-control-wait
11
+ name: 'dsh-subagent-control/wait'
12
+ disabled: true
13
+ - id: subagent-control-drain
14
+ name: 'dsh-subagent-control/drain'
15
+ disabled: true
16
+ - id: subagent-control-budget
17
+ name: 'dsh-subagent-control/budget'
6
18
  disabled: true
package/index.js CHANGED
@@ -37,6 +37,7 @@ const CHILD_ITEM = {
37
37
  status: { type: 'string', enum: ['running', 'inactive'] },
38
38
  parent: { type: 'string' },
39
39
  depth: { type: 'number' },
40
+ team: { type: 'string', enum: ['lead', 'teammate'] },
40
41
  },
41
42
  }
42
43
 
@@ -83,13 +84,42 @@ function project(agents, entry, position) {
83
84
  }
84
85
  }
85
86
 
87
+ /**
88
+ * Team members are continuable subagents, so the subagent catalog cannot tell a
89
+ * teammate from a worker on its own. Resolve the Team role when the Agent Teams
90
+ * service is present, and answer `undefined` when the agent is not in a Team.
91
+ */
92
+ function teamRoleResolver(ctx, caller) {
93
+ const teams = typeof ctx.get === 'function' ? ctx.get('agentTeams') : undefined
94
+ if (teams === undefined) return () => undefined
95
+ const roster = new Map()
96
+ try {
97
+ for (const member of teams.listMembers(caller) ?? []) roster.set(member.id, member.role)
98
+ } catch {
99
+ /* the caller is not part of a Team; the per-agent lookup below still works */
100
+ }
101
+ return (id) => {
102
+ const known = roster.get(id)
103
+ if (known !== undefined) return known
104
+ const agent = ctx.agents.get(id)
105
+ if (agent === undefined) return undefined
106
+ try {
107
+ const membership = teams.tryMembership?.(agent)
108
+ return membership === undefined ? undefined : membership.role
109
+ } catch {
110
+ return undefined
111
+ }
112
+ }
113
+ }
114
+
86
115
  function renderEntry(entry) {
87
116
  if (entry.kind === 'diagnostic') {
88
117
  const at = entry.parent === undefined ? '' : ` parent=${entry.parent}`
89
118
  return `[${entry.reason}] ${entry.id}${at}`
90
119
  }
91
120
  const depth = entry.depth === undefined ? '' : ` depth=${entry.depth}`
92
- return `${entry.id} ${entry.status} ${JSON.stringify(entry.label)}${depth}`
121
+ const team = entry.team === undefined ? '' : ` team=${entry.team}`
122
+ return `${entry.id} ${entry.status} ${JSON.stringify(entry.label)}${depth}${team}`
93
123
  }
94
124
 
95
125
  export function apply(ctx) {
@@ -100,6 +130,8 @@ export function apply(ctx) {
100
130
  description:
101
131
  'List the subagents you started, with their ids, labels and status. ' +
102
132
  'running means it is working; inactive means it is not working right now, which does not mean it is gone. ' +
133
+ 'Team members are continuable subagents too, so they appear here marked team=teammate or team=lead; ' +
134
+ 'use include_teammates:false when you only want the workers you started. ' +
103
135
  'You are notified when a subagent finishes, so there is no need to poll. ' +
104
136
  'Use send_subagent_message to continue a conversation with one of them.',
105
137
  parameters: {
@@ -114,6 +146,11 @@ export function apply(ctx) {
114
146
  'descendants lists the whole tree below you with each entry parent and depth; ' +
115
147
  'entries deeper than one level accept only interrupt_subagent.',
116
148
  },
149
+ include_teammates: {
150
+ type: 'boolean',
151
+ description:
152
+ 'When false, leave out agents that belong to the current Team, keeping only plain subagents. Default true.',
153
+ },
117
154
  },
118
155
  },
119
156
  output: {
@@ -127,12 +164,22 @@ export function apply(ctx) {
127
164
  },
128
165
  async execute(args, exec) {
129
166
  const caller = requireAgent(exec, 'list_subagents')
167
+ const roleOf = teamRoleResolver(ctx, caller)
168
+ const wantsTeammates = args.include_teammates !== false
169
+ const emit = (entry, position) => {
170
+ const item = project(ctx.agents, entry, position)
171
+ if (item === undefined) return undefined
172
+ const role = roleOf(item.id)
173
+ if (role !== undefined) item.team = role
174
+ if (!wantsTeammates && role !== undefined) return undefined
175
+ return item
176
+ }
130
177
  if ((args.scope ?? 'children') === 'descendants') {
131
178
  const rows = await ctx.subagents.listDescendants(caller.id, exec.signal)
132
- return rows.map((row) => project(ctx.agents, row, row)).filter(Boolean)
179
+ return rows.map((row) => emit(row, row)).filter(Boolean)
133
180
  }
134
181
  const rows = await ctx.subagents.listChildren(caller.id, exec.signal)
135
- return rows.map((row) => project(ctx.agents, row)).filter(Boolean)
182
+ return rows.map((row) => emit(row)).filter(Boolean)
136
183
  },
137
184
  })
138
185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subagent-control",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
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,14 +12,25 @@
12
12
  "icon": "./icon.svg",
13
13
  "exports": {
14
14
  ".": "./index.js",
15
- "./extras": "./extras.js",
16
15
  "./package.json": "./package.json",
17
16
  "./locale/*.json": "./locale/*.json",
18
- "./icon": "./icon.svg"
17
+ "./icon": "./icon.svg",
18
+ "./relay": "./components/relay/index.js",
19
+ "./relay/package.json": "./components/relay/package.json",
20
+ "./relay/locale/*.json": "./components/relay/locale/*.json",
21
+ "./wait": "./components/wait/index.js",
22
+ "./wait/package.json": "./components/wait/package.json",
23
+ "./wait/locale/*.json": "./components/wait/locale/*.json",
24
+ "./drain": "./components/drain/index.js",
25
+ "./drain/package.json": "./components/drain/package.json",
26
+ "./drain/locale/*.json": "./components/drain/locale/*.json",
27
+ "./budget": "./components/budget/index.js",
28
+ "./budget/package.json": "./components/budget/package.json",
29
+ "./budget/locale/*.json": "./components/budget/locale/*.json"
19
30
  },
20
31
  "files": [
21
32
  "index.js",
22
- "extras.js",
33
+ "components",
23
34
  "cordis.patch.yml",
24
35
  "locale/*.json",
25
36
  "icon.svg",
package/extras.js DELETED
@@ -1,343 +0,0 @@
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
- }