dsh-plugin-subagent-bridge 0.13.1 → 0.14.0
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.md +42 -0
- package/index.js +121 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,48 @@ dsh_task({ prompt: "…", cwd: "/repo/clawer" })
|
|
|
56
56
|
- 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
|
|
57
57
|
- 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
|
|
58
58
|
|
|
59
|
+
## 回答 worker 的问题(`dsh_answer`)
|
|
60
|
+
|
|
61
|
+
worker 需要用户拍板时会调用 `ask_user_question`,然后**卡在那里等**。如果人不在,任务就停住了。
|
|
62
|
+
|
|
63
|
+
桥把这种情况暴露出来:
|
|
64
|
+
|
|
65
|
+
```jsonc
|
|
66
|
+
// dsh_wait 的返回
|
|
67
|
+
{
|
|
68
|
+
"status": { "w1": "running" },
|
|
69
|
+
"timedOut": true,
|
|
70
|
+
"pendingQuestion": {
|
|
71
|
+
"callId": "call_abc",
|
|
72
|
+
"questions": [
|
|
73
|
+
{
|
|
74
|
+
"id": "mode",
|
|
75
|
+
"question": "用哪种模式?",
|
|
76
|
+
"header": "Choose Mode",
|
|
77
|
+
"options": [{ "label": "快速", "description": "只跑单测" }, { "label": "完整" }]
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
},
|
|
81
|
+
"next": "blocked on a question: answer it with dsh_answer, or call dsh_wait again"
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
调用方(Codex / Claude)看到 `pendingQuestion` 就可以代用户回答:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
dsh_answer({
|
|
89
|
+
"worker": "w1",
|
|
90
|
+
"answers": [{ "id": "mode", "selected": ["快速"] }] // custom 可用于自由文本
|
|
91
|
+
})
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
细节:
|
|
95
|
+
|
|
96
|
+
- 一个问题的 id 必须**恰好出现一次**,否则底层会报 `BAD_ANSWER`;
|
|
97
|
+
- `selected` 是选项 label 数组,多选时给多个;跳过一个问题就给空数组;
|
|
98
|
+
- 回答是插进正在等待的那个回合的(底层用 `steer`),worker 立刻继续;
|
|
99
|
+
- 只有真卡在 `ask_user_question` 上才算「等待中」——工具一旦返回结果就不再报。
|
|
100
|
+
|
|
59
101
|
## 排队还是插话(`mode`)
|
|
60
102
|
|
|
61
103
|
DSH 的「繁忙时的发送行为」只有两种,桥把它们原样交给调用方:
|
package/index.js
CHANGED
|
@@ -207,6 +207,37 @@ const TOOLS = [
|
|
|
207
207
|
additionalProperties: false,
|
|
208
208
|
},
|
|
209
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: 'dsh_answer',
|
|
212
|
+
description:
|
|
213
|
+
'Answer a question a worker is blocked on (ask_user_question) on the user behalf. dsh_wait reports such a question as pendingQuestion; every question id in it must appear exactly once in answers.',
|
|
214
|
+
inputSchema: {
|
|
215
|
+
type: 'object',
|
|
216
|
+
properties: {
|
|
217
|
+
worker: { type: 'string', description: 'task_name or session id of the blocked worker.' },
|
|
218
|
+
answers: {
|
|
219
|
+
type: 'array',
|
|
220
|
+
description: 'One entry per pending question.',
|
|
221
|
+
items: {
|
|
222
|
+
type: 'object',
|
|
223
|
+
properties: {
|
|
224
|
+
id: { type: 'string', description: 'Question id, copied from pendingQuestion.' },
|
|
225
|
+
selected: {
|
|
226
|
+
type: 'array',
|
|
227
|
+
items: { type: 'string' },
|
|
228
|
+
description: 'Chosen option labels; empty when the question is being skipped.',
|
|
229
|
+
},
|
|
230
|
+
custom: { type: 'string', description: 'Free-form answer when no option fits.' },
|
|
231
|
+
},
|
|
232
|
+
required: ['id', 'selected'],
|
|
233
|
+
additionalProperties: false,
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
required: ['worker', 'answers'],
|
|
238
|
+
additionalProperties: false,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
210
241
|
{
|
|
211
242
|
name: 'dsh_list_workers',
|
|
212
243
|
description: 'List the workers this bridge started, with their task_name, session id, and live status.',
|
|
@@ -278,6 +309,61 @@ const TOOLS = [
|
|
|
278
309
|
},
|
|
279
310
|
]
|
|
280
311
|
|
|
312
|
+
/**
|
|
313
|
+
* The question a worker is currently blocked on, if any.
|
|
314
|
+
* `ask_user_question` records a tool call and waits for an answer; while it waits the call
|
|
315
|
+
* has no result yet, which is exactly the signal used here.
|
|
316
|
+
* @param ctx - Host context.
|
|
317
|
+
* @param sessionId - session to inspect.
|
|
318
|
+
* @returns the pending call and its questions, or undefined.
|
|
319
|
+
*/
|
|
320
|
+
function askable(state, ctx, targets) {
|
|
321
|
+
for (const target of targets) {
|
|
322
|
+
const resolved = resolveWorker(state, target)
|
|
323
|
+
const sessionId = resolved === undefined ? target : resolved.sessionId
|
|
324
|
+
const asked = pendingQuestion(ctx, sessionId)
|
|
325
|
+
if (asked !== undefined) return { worker: target, ...asked }
|
|
326
|
+
}
|
|
327
|
+
return undefined
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function pendingQuestion(ctx, sessionId) {
|
|
331
|
+
const session = ctx.get('sessions')?.get?.(sessionId)
|
|
332
|
+
if (session === undefined || session === null) return undefined
|
|
333
|
+
const settled = new Set()
|
|
334
|
+
let waiting
|
|
335
|
+
for (const event of session.snapshotEvents()) {
|
|
336
|
+
if (event.type === 'tool/result' && typeof event.data?.callId === 'string') settled.add(event.data.callId)
|
|
337
|
+
if (event.type === 'tool/call' && event.data?.name === 'ask_user_question') waiting = event.data
|
|
338
|
+
}
|
|
339
|
+
if (waiting === undefined || settled.has(waiting.callId)) return undefined
|
|
340
|
+
let parsed
|
|
341
|
+
try {
|
|
342
|
+
parsed = JSON.parse(typeof waiting.arguments === 'string' ? waiting.arguments : '{}')
|
|
343
|
+
} catch {
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
const questions = Array.isArray(parsed?.questions) ? parsed.questions : []
|
|
347
|
+
if (questions.length === 0) return undefined
|
|
348
|
+
return {
|
|
349
|
+
callId: String(waiting.callId),
|
|
350
|
+
questions: questions.map((question) => ({
|
|
351
|
+
id: String(question?.id ?? ''),
|
|
352
|
+
question: String(question?.question ?? ''),
|
|
353
|
+
...(typeof question?.header === 'string' ? { header: question.header } : {}),
|
|
354
|
+
...(question?.multi_select === true ? { multiSelect: true } : {}),
|
|
355
|
+
...(Array.isArray(question?.options)
|
|
356
|
+
? {
|
|
357
|
+
options: question.options.map((option) => ({
|
|
358
|
+
label: String(option?.label ?? ''),
|
|
359
|
+
...(typeof option?.description === 'string' ? { description: option.description } : {}),
|
|
360
|
+
})),
|
|
361
|
+
}
|
|
362
|
+
: {}),
|
|
363
|
+
})),
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
281
367
|
/**
|
|
282
368
|
* Sequence number of the first message the caller itself delivered after `sinceSeq`.
|
|
283
369
|
* With steer the reply shares the running turn, so "the last assistant text of the turn"
|
|
@@ -659,6 +745,20 @@ async function callTool(id, params, api, signal) {
|
|
|
659
745
|
const worker = requireString(args.worker, 'worker')
|
|
660
746
|
return ok(id, text(await api.close({ worker })))
|
|
661
747
|
}
|
|
748
|
+
case 'dsh_answer': {
|
|
749
|
+
const worker = requireString(args.worker, 'worker')
|
|
750
|
+
if (!Array.isArray(args.answers) || args.answers.length === 0) throw new Error('answers must be a non-empty array')
|
|
751
|
+
const answers = args.answers.map((entry, index) => {
|
|
752
|
+
const questionId = requireString(entry?.id, `answers[${index}].id`)
|
|
753
|
+
if (!Array.isArray(entry?.selected)) throw new Error(`answers[${index}].selected must be an array`)
|
|
754
|
+
return {
|
|
755
|
+
id: questionId,
|
|
756
|
+
selected: entry.selected.map((label, at) => requireString(label, `answers[${index}].selected[${at}]`)),
|
|
757
|
+
...(typeof entry.custom === 'string' && entry.custom !== '' ? { custom: entry.custom } : {}),
|
|
758
|
+
}
|
|
759
|
+
})
|
|
760
|
+
return ok(id, text(await api.answer({ worker, answers })))
|
|
761
|
+
}
|
|
662
762
|
case 'dsh_list_workers':
|
|
663
763
|
return ok(id, text(await api.listWorkers()))
|
|
664
764
|
case 'dsh_wait': {
|
|
@@ -977,18 +1077,23 @@ export function createApi(ctx, state, settings) {
|
|
|
977
1077
|
if (typeof value !== 'string' || final) settled = true
|
|
978
1078
|
}
|
|
979
1079
|
const waitedMs = Date.now() - started
|
|
980
|
-
|
|
1080
|
+
const asked = askable(state, ctx, targets)
|
|
1081
|
+
if (settled) return { status, timedOut: false, waitedMs, ...(asked === undefined ? {} : { pendingQuestion: asked }) }
|
|
981
1082
|
if (waitedMs >= budget) {
|
|
982
1083
|
const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
|
|
983
1084
|
return {
|
|
984
1085
|
status,
|
|
985
1086
|
timedOut: true,
|
|
986
1087
|
waitedMs,
|
|
1088
|
+
...(asked === undefined ? {} : { pendingQuestion: asked }),
|
|
987
1089
|
...(still.length === 0
|
|
988
1090
|
? {}
|
|
989
1091
|
: {
|
|
990
1092
|
running: true,
|
|
991
|
-
next:
|
|
1093
|
+
next:
|
|
1094
|
+
asked === undefined
|
|
1095
|
+
? `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`
|
|
1096
|
+
: `blocked on a question: answer it with dsh_answer, or call dsh_wait again`,
|
|
992
1097
|
}),
|
|
993
1098
|
}
|
|
994
1099
|
}
|
|
@@ -996,6 +1101,20 @@ export function createApi(ctx, state, settings) {
|
|
|
996
1101
|
}
|
|
997
1102
|
},
|
|
998
1103
|
|
|
1104
|
+
async answer({ worker, sessionId, answers }) {
|
|
1105
|
+
const target = worker === undefined ? sessionId : (resolveWorker(state, worker)?.sessionId ?? worker)
|
|
1106
|
+
if (typeof target !== 'string' || target === '') throw new Error('name a worker or a sessionId')
|
|
1107
|
+
const asked = pendingQuestion(ctx, target)
|
|
1108
|
+
if (asked === undefined) throw new Error('that worker is not waiting on a question')
|
|
1109
|
+
const service = ctx.get('userQuestions')
|
|
1110
|
+
const agent = ctx.get('agents')?.get?.(target)
|
|
1111
|
+
if (service === undefined || service === null || agent === undefined) {
|
|
1112
|
+
throw new Error('the question service is unavailable')
|
|
1113
|
+
}
|
|
1114
|
+
const accepted = service.answer(agent, asked.callId, { answers })
|
|
1115
|
+
return { answered: accepted === true, callId: asked.callId, sessionId: target }
|
|
1116
|
+
},
|
|
1117
|
+
|
|
999
1118
|
async dispatch({ sessionId, text, mode }) {
|
|
1000
1119
|
const service = controller()
|
|
1001
1120
|
if (service === undefined || service === null) throw new Error('session controller is unavailable')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subagent-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Subagent bridge: lets Codex delegate a task to DeepSeek Harness the way it would spawn a subagent (one call, waits for the answer), plus a codex_dispatch tool for the reverse direction.",
|
|
6
6
|
"keywords": [
|