ds-bot 0.1.3 → 0.1.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/README.md +2 -2
- package/client.js +1456 -1039
- package/index.js +8 -1
- package/package.json +1 -1
- package/src/host/approvals.js +8 -2
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +16 -3
- package/src/host/constants.js +8 -0
- package/src/host/delivery.js +20 -6
- package/src/host/groups.js +119 -30
- package/src/host/parts.js +2 -0
- package/src/host/prompt.js +1 -1
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +1 -0
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +16 -3
- package/src/host/tools.js +105 -3
- package/src/host/turns.js +93 -7
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Resume: Bot work that DeepSeek Harness dropped when it closed or restarted. DSH closes
|
|
2
|
+
// a turn it never finished as interrupted when the Session next opens, and a clean
|
|
3
|
+
// shutdown also cancels the messages still waiting in a Session's inbox; nothing would
|
|
4
|
+
// ever run either again, and whoever waited for them was not told. DS Bot notes each
|
|
5
|
+
// running Bot turn and each message it delivers until a turn takes it up, in
|
|
6
|
+
// inflight.json; what is still noted at the next start is picked up once.
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { SOURCE_KIND } from './constants.js'
|
|
12
|
+
|
|
13
|
+
// Long enough for DSH to finish opening its workspaces after a start.
|
|
14
|
+
const RESUME_DELAY_MS = 3000
|
|
15
|
+
// A delivery waiting longer than this was not lost to a restart.
|
|
16
|
+
const QUEUED_KEPT_MS = 24 * 60 * 60 * 1000
|
|
17
|
+
const QUEUED_MAX = 50
|
|
18
|
+
|
|
19
|
+
export const resumeText = record => [
|
|
20
|
+
'DeepSeek Harness restarted while you were working on this, so your last turn stopped before it finished.',
|
|
21
|
+
record.approval ? `You were waiting for the user's approval to run ${record.approval.tool || 'a tool'}${record.approval.reason ? ` (${record.approval.reason})` : ''}; that request is gone, so ask again if you still need it.` : '',
|
|
22
|
+
'Check what is already done (files, commands, messages) before you repeat anything, then finish the task and give your reply.',
|
|
23
|
+
].filter(Boolean).join(' ')
|
|
24
|
+
|
|
25
|
+
// The trigger fields a resumed turn needs to answer the same sender.
|
|
26
|
+
const triggerOf = info => ({
|
|
27
|
+
role: info.role,
|
|
28
|
+
...(info.exchangeId ? { exchangeId: info.exchangeId } : {}),
|
|
29
|
+
...(info.senderSessionId ? { senderSessionId: info.senderSessionId } : {}),
|
|
30
|
+
...(info.roomId ? { roomId: info.roomId } : {}),
|
|
31
|
+
...(typeof info.hop === 'number' ? { hop: info.hop } : {}),
|
|
32
|
+
...(info.audience ? { audience: info.audience } : {}),
|
|
33
|
+
...(info.askedBy ? { askedBy: info.askedBy } : {}),
|
|
34
|
+
...(info.senderRoomId ? { senderRoomId: info.senderRoomId } : {}),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const readNotes = (file) => {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
40
|
+
const object = value => (value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {})
|
|
41
|
+
return { turns: object(parsed?.turns), queued: object(parsed?.queued) }
|
|
42
|
+
} catch {
|
|
43
|
+
return { turns: {}, queued: {} }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function install(rt) {
|
|
48
|
+
const { ctx, home } = rt
|
|
49
|
+
const file = join(home, 'inflight.json')
|
|
50
|
+
|
|
51
|
+
// What the last run left; read before this run notes anything.
|
|
52
|
+
let interrupted = readNotes(file)
|
|
53
|
+
|
|
54
|
+
/** @type {Record<string, {botId:string, roomId?:string, since:number, trigger?:any, approval?:{tool:string, reason:string}, resumed?:boolean}>} */
|
|
55
|
+
let turns = {}
|
|
56
|
+
/** @type {Record<string, {at:number, args:any}[]>} deliveries no turn has taken up, by Session */
|
|
57
|
+
let queued = {}
|
|
58
|
+
let writing = Promise.resolve()
|
|
59
|
+
let stopped = false
|
|
60
|
+
let latest
|
|
61
|
+
let due = false
|
|
62
|
+
const persist = () => {
|
|
63
|
+
// Only the host that saves the team runs its work again.
|
|
64
|
+
if (stopped || rt.readOnly() !== undefined) return
|
|
65
|
+
latest = JSON.stringify({ turns, queued })
|
|
66
|
+
// One write waits at a time and takes the latest notes, so a busy round does not
|
|
67
|
+
// write the file once per change.
|
|
68
|
+
if (due) return
|
|
69
|
+
due = true
|
|
70
|
+
// Written in place: a rename over the file fails on Windows while anything reads
|
|
71
|
+
// it, and a torn write only loses what one restart would pick up.
|
|
72
|
+
writing = writing.then(async () => {
|
|
73
|
+
due = false
|
|
74
|
+
const snapshot = latest
|
|
75
|
+
await mkdir(home, { recursive: true })
|
|
76
|
+
await writeFile(file, snapshot, 'utf8')
|
|
77
|
+
}).catch(error => rt.warn('ds-bot: noting running turns failed: %s', error))
|
|
78
|
+
}
|
|
79
|
+
const update = (id, change) => {
|
|
80
|
+
const next = change(turns[id])
|
|
81
|
+
if (next === turns[id]) return
|
|
82
|
+
turns = { ...turns }
|
|
83
|
+
if (next === undefined) delete turns[id]
|
|
84
|
+
else turns[id] = next
|
|
85
|
+
persist()
|
|
86
|
+
}
|
|
87
|
+
const forgetQueued = (exchangeId) => {
|
|
88
|
+
for (const [sessionId, list] of Object.entries(queued)) {
|
|
89
|
+
const at = list.findIndex(item => item.args.exchangeId === exchangeId)
|
|
90
|
+
if (at === -1) continue
|
|
91
|
+
const rest = list.toSpliced(at, 1)
|
|
92
|
+
queued = { ...queued }
|
|
93
|
+
if (rest.length === 0) delete queued[sessionId]
|
|
94
|
+
else queued[sessionId] = rest
|
|
95
|
+
persist()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const deliver = rt.deliver
|
|
101
|
+
rt.deliver = async (args) => {
|
|
102
|
+
const noted = { ...args, exchangeId: args.exchangeId ?? randomUUID() }
|
|
103
|
+
const sessionId = noted.sessionId ?? rt.chatOf(noted.toId)
|
|
104
|
+
// Noted before it is sent: a turn may take it up before the send returns.
|
|
105
|
+
queued = { ...queued, [sessionId]: [...(queued[sessionId] ?? []), { at: Date.now(), args: noted }].slice(-QUEUED_MAX) }
|
|
106
|
+
persist()
|
|
107
|
+
try {
|
|
108
|
+
return await deliver(noted)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
forgetQueued(noted.exchangeId)
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A message is done with once a turn takes it up, or once it leaves the inbox untaken
|
|
116
|
+
// while DS Bot runs: the user stopped the Bot, or DS Bot moved or withdrew it.
|
|
117
|
+
ctx.on('agent/inbox/discarded', ({ message } = {}) => {
|
|
118
|
+
if (message?.source?.kind === SOURCE_KIND && message.source.exchangeId) forgetQueued(message.source.exchangeId)
|
|
119
|
+
})
|
|
120
|
+
// Exchanges a turn took up since this start, until the resends are done, so a resend
|
|
121
|
+
// never repeats one.
|
|
122
|
+
let taken = Object.keys(interrupted.queued).length > 0 ? new Set() : undefined
|
|
123
|
+
// The first turn event of each Session since this start, until the noted turns are
|
|
124
|
+
// picked up. A turn that ends, or still runs, before one starts here outlived the
|
|
125
|
+
// last run: only DS Bot restarted (the plugin reloaded) and DSH went on.
|
|
126
|
+
let firstTurn = Object.keys(interrupted.turns).length > 0 ? new Map() : undefined
|
|
127
|
+
ctx.on('session/event', (session, event) => {
|
|
128
|
+
const id = session?.id
|
|
129
|
+
if (id === undefined) return
|
|
130
|
+
if ((event.type === 'turn/start' || event.type === 'turn/end') && firstTurn?.has(id) === false) firstTurn.set(id, event.type)
|
|
131
|
+
if (event.type === 'user/message' && event.data?.source?.kind === SOURCE_KIND && event.data.source.exchangeId) {
|
|
132
|
+
taken?.add(event.data.source.exchangeId)
|
|
133
|
+
forgetQueued(event.data.source.exchangeId)
|
|
134
|
+
}
|
|
135
|
+
if (!rt.isBotSession(id)) return
|
|
136
|
+
switch (event.type) {
|
|
137
|
+
case 'turn/start': {
|
|
138
|
+
const owner = rt.groupOwners.get(id)
|
|
139
|
+
const botId = owner?.botId ?? rt.chatOwners.get(id)
|
|
140
|
+
update(id, () => ({ botId, ...(owner ? { roomId: owner.roomId } : {}), since: event.time ?? Date.now() }))
|
|
141
|
+
break
|
|
142
|
+
}
|
|
143
|
+
case 'user/message': {
|
|
144
|
+
if (event.data?.source?.kind !== SOURCE_KIND) break
|
|
145
|
+
// turns.js, installed earlier, has already claimed the turn's trigger.
|
|
146
|
+
const info = rt.live.get(id)?.info
|
|
147
|
+
update(id, record => (record === undefined || record.trigger !== undefined || info === undefined ? record
|
|
148
|
+
: { ...record, trigger: triggerOf(info), ...(info.resumed ? { resumed: true } : {}) }))
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
case 'turn/end': {
|
|
152
|
+
// DSH stops every turn as it shuts down; that one is picked up at the next start.
|
|
153
|
+
const reason = event.data?.reason
|
|
154
|
+
if (reason?.kind === 'aborted' && reason.reason?.kind === 'disposed') break
|
|
155
|
+
update(id, () => undefined)
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// An approval the restart drops is named when the turn is picked up.
|
|
162
|
+
rt.noteApproval = (sessionId, approval) => update(sessionId, record => (record === undefined ? record
|
|
163
|
+
: approval === undefined ? (({ approval: _, ...rest }) => rest)(record) : { ...record, approval }))
|
|
164
|
+
|
|
165
|
+
async function resumeTurn(sessionId, record) {
|
|
166
|
+
const bot = rt.botOf(record.botId)
|
|
167
|
+
if (bot === undefined) return
|
|
168
|
+
const text = resumeText(record)
|
|
169
|
+
if (record.roomId !== undefined) {
|
|
170
|
+
const room = rt.roomOf(record.roomId)
|
|
171
|
+
if (room === undefined || !room.members.includes(bot.id)) return
|
|
172
|
+
await rt.postToRoom(room, bot, '', { resumed: true, quiet: true, ...(record.resumed ? { again: true } : {}) })
|
|
173
|
+
// A turn already picked up once and cut off again stays stopped: a restart loop
|
|
174
|
+
// must not run it forever.
|
|
175
|
+
if (record.resumed) return
|
|
176
|
+
await rt.deliver({ toId: bot.id, sessionId: room.sessions?.[bot.id] ?? sessionId, role: 'room', roomId: room.id, note: 'picking up after a restart', resumed: true, text, ...(record.trigger?.askedBy ? { askedBy: record.trigger.askedBy } : {}) })
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (record.resumed) return
|
|
180
|
+
const trigger = record.trigger
|
|
181
|
+
if ((trigger?.role === 'request' || trigger?.role === 'stopped') && trigger.senderSessionId && trigger.audience !== 'user' && rt.botOf(trigger.senderSessionId)) {
|
|
182
|
+
// The sender still waits for this turn's answer, so it goes on under the same exchange.
|
|
183
|
+
await rt.deliver({ fromId: trigger.senderSessionId, toId: bot.id, role: trigger.role, hop: trigger.hop ?? 0, ...(trigger.exchangeId ? { exchangeId: trigger.exchangeId } : {}), ...(trigger.senderRoomId ? { fromRoomId: trigger.senderRoomId } : {}), resumed: true, text })
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
await rt.deliver({ toId: bot.id, role: 'resume', resumed: true, text })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A hard stop keeps the inbox, which the Session runs once it wakes; only what DSH
|
|
190
|
+
// cancelled is sent again.
|
|
191
|
+
async function resendQueued(sessionId, list) {
|
|
192
|
+
// A Session that moved on to a new part since is reached at its current one.
|
|
193
|
+
const current = (args) => {
|
|
194
|
+
if (rt.isBotSession(sessionId)) return sessionId
|
|
195
|
+
return args.roomId === undefined ? rt.chatOf(args.toId) : rt.roomOf(args.roomId)?.sessions?.[args.toId]
|
|
196
|
+
}
|
|
197
|
+
const fresh = list.filter(item => Date.now() - item.at < QUEUED_KEPT_MS && rt.botOf(item.args?.toId) !== undefined
|
|
198
|
+
&& current(item.args) !== undefined && taken?.has(item.args.exchangeId) !== true)
|
|
199
|
+
if (fresh.length === 0) return
|
|
200
|
+
const target = current(fresh[0].args)
|
|
201
|
+
const agent = await rt.resolveAgent(target)
|
|
202
|
+
const waiting = new Set([...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])].map(message => message.source?.exchangeId))
|
|
203
|
+
for (const { args } of fresh) {
|
|
204
|
+
if (waiting.has(args.exchangeId) || taken?.has(args.exchangeId) === true) continue
|
|
205
|
+
await rt.deliver({ ...args, sessionId: current(args), steer: false, resent: true })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function resumeAll() {
|
|
210
|
+
await rt.load()
|
|
211
|
+
if (rt.readOnly() !== undefined) return
|
|
212
|
+
const { turns: noted, queued: dropped } = interrupted
|
|
213
|
+
interrupted = { turns: {}, queued: {} }
|
|
214
|
+
// Taken up now, so another restart does not pick them up twice.
|
|
215
|
+
persist()
|
|
216
|
+
// Agents and inboxes are read before anything wakes their Sessions.
|
|
217
|
+
const cut = []
|
|
218
|
+
for (const [sessionId, record] of Object.entries(noted)) {
|
|
219
|
+
const first = firstTurn.get(sessionId)
|
|
220
|
+
if (first === 'turn/end') continue
|
|
221
|
+
// Still running: noted again, as if it had started here.
|
|
222
|
+
if (first === undefined && (await rt.resolveAgent(sessionId).catch(() => undefined))?.status === 'running') update(sessionId, () => record)
|
|
223
|
+
else cut.push([sessionId, record])
|
|
224
|
+
}
|
|
225
|
+
firstTurn = undefined
|
|
226
|
+
for (const [sessionId, list] of Object.entries(dropped)) {
|
|
227
|
+
if (!Array.isArray(list)) continue
|
|
228
|
+
await resendQueued(sessionId, list).catch(error => rt.warn('ds-bot: sending again what %s was sent failed: %s', sessionId, error))
|
|
229
|
+
}
|
|
230
|
+
taken = undefined
|
|
231
|
+
for (const [sessionId, record] of cut) {
|
|
232
|
+
await resumeTurn(sessionId, record).catch(error => rt.warn('ds-bot: picking up the turn of %s failed: %s', sessionId, error))
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ctx.effect(() => {
|
|
237
|
+
const pending = Object.keys(interrupted.turns).length > 0 || Object.keys(interrupted.queued).length > 0
|
|
238
|
+
const timer = pending ? setTimeout(() => void resumeAll().catch(error => rt.warn('ds-bot: picking up interrupted work failed: %s', error)), RESUME_DELAY_MS) : undefined
|
|
239
|
+
timer?.unref?.()
|
|
240
|
+
// What the turns stopped by this shutdown left stays as last noted.
|
|
241
|
+
return () => {
|
|
242
|
+
stopped = true
|
|
243
|
+
clearTimeout(timer)
|
|
244
|
+
return writing
|
|
245
|
+
}
|
|
246
|
+
}, 'ds-bot: resume interrupted turns')
|
|
247
|
+
}
|
package/src/host/roster.js
CHANGED
package/src/host/secrets.js
CHANGED
|
@@ -526,5 +526,13 @@ export function install(rt) {
|
|
|
526
526
|
return () => { if (usedTimer !== null) clearTimeout(usedTimer) }
|
|
527
527
|
}, 'ds-bot: load secrets')
|
|
528
528
|
|
|
529
|
-
Object.assign(rt, {
|
|
529
|
+
Object.assign(rt, {
|
|
530
|
+
secretsView,
|
|
531
|
+
secretValue: name => secrets.get(name)?.value,
|
|
532
|
+
canUseSecret: (name, botId) => canUse(secrets.get(name), botId),
|
|
533
|
+
secretEndpoint,
|
|
534
|
+
forgetBotSecrets,
|
|
535
|
+
loadSecrets,
|
|
536
|
+
redactSecrets: text => redact(text),
|
|
537
|
+
})
|
|
530
538
|
}
|
package/src/host/store.js
CHANGED
|
@@ -11,8 +11,8 @@ import { currentColor, normalize, unquote } from './text.js'
|
|
|
11
11
|
export function install(rt) {
|
|
12
12
|
const { ctx, home, statePath } = rt
|
|
13
13
|
|
|
14
|
-
/** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
|
|
15
|
-
const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
|
|
14
|
+
/** @type {{version:1, workspaceId?:string, mainBotIds:string[], bots:Record<string,any>, rooms:Record<string,any>, agents:Record<string,any>, capabilities:{items:Record<string,any>}, exchanges:any[], exchangeTurns:Record<string,number[]>, revision:number}} */
|
|
15
|
+
const state = { version: 1, mainBotIds: [], bots: {}, rooms: {}, agents: {}, capabilities: { items: {} }, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0 }
|
|
16
16
|
let loaded = null
|
|
17
17
|
let writing = Promise.resolve()
|
|
18
18
|
const { warn } = rt
|
|
@@ -97,13 +97,25 @@ export function install(rt) {
|
|
|
97
97
|
// Fill `state` from the saved file and bring an older team up to date. Returns the
|
|
98
98
|
// groups whose Session titles still need the new name.
|
|
99
99
|
const adopt = (parsed) => {
|
|
100
|
+
const hadCapabilities = parsed !== null && parsed !== undefined && Object.hasOwn(parsed, 'capabilities')
|
|
100
101
|
for (const key of Object.keys(state)) delete state[key]
|
|
101
102
|
Object.assign(state, JSON.parse(EMPTY), parsed ?? {})
|
|
102
103
|
state.prefs ??= {}
|
|
103
104
|
// Teams saved before the DSH Agent existed carry no `agents`; corrupt values reset.
|
|
104
105
|
if (state.agents === null || typeof state.agents !== 'object' || Array.isArray(state.agents)) state.agents = {}
|
|
105
|
-
let migrated =
|
|
106
|
+
let migrated = !hadCapabilities
|
|
107
|
+
if (state.capabilities === null || typeof state.capabilities !== 'object' || Array.isArray(state.capabilities)) {
|
|
108
|
+
state.capabilities = { items: {} }
|
|
109
|
+
migrated = true
|
|
110
|
+
} else if (state.capabilities.items === null || typeof state.capabilities.items !== 'object' || Array.isArray(state.capabilities.items)) {
|
|
111
|
+
state.capabilities.items = {}
|
|
112
|
+
migrated = true
|
|
113
|
+
}
|
|
106
114
|
for (const bot of Object.values(state.bots)) {
|
|
115
|
+
if (!Array.isArray(bot.capabilities)) {
|
|
116
|
+
bot.capabilities = []
|
|
117
|
+
migrated = true
|
|
118
|
+
}
|
|
107
119
|
if (bot.color !== currentColor(bot.color)) {
|
|
108
120
|
bot.color = currentColor(bot.color)
|
|
109
121
|
migrated = true
|
|
@@ -248,6 +260,7 @@ export function install(rt) {
|
|
|
248
260
|
ensureLock()
|
|
249
261
|
await rename(temp, statePath)
|
|
250
262
|
seen = stamp()
|
|
263
|
+
rt.capabilitiesStateSaved?.()
|
|
251
264
|
} catch (error) {
|
|
252
265
|
await unlink(temp).catch(() => {})
|
|
253
266
|
throw error
|
package/src/host/tools.js
CHANGED
|
@@ -17,6 +17,10 @@ export function install(rt) {
|
|
|
17
17
|
const { ctx, state } = rt
|
|
18
18
|
|
|
19
19
|
const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id))
|
|
20
|
+
const callerRoom = (exec) => {
|
|
21
|
+
const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId
|
|
22
|
+
return roomId ? { fromRoomId: roomId } : {}
|
|
23
|
+
}
|
|
20
24
|
|
|
21
25
|
const roomLine = (room, self) => {
|
|
22
26
|
const members = room.members.map(rt.botOf).filter(Boolean)
|
|
@@ -100,7 +104,7 @@ export function install(rt) {
|
|
|
100
104
|
const toUser = args.reply_to === 'user'
|
|
101
105
|
const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
|
|
102
106
|
const steer = args.when !== 'queue' && !toUser
|
|
103
|
-
await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } :
|
|
107
|
+
await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) })
|
|
104
108
|
const sent = toUser
|
|
105
109
|
? `Delivered to ${target.name}. It will answer the user in its own chat; nothing comes back to you, so point the user to ${target.name}'s chat instead of relaying.`
|
|
106
110
|
: working && steer
|
|
@@ -142,7 +146,7 @@ export function install(rt) {
|
|
|
142
146
|
agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
|
|
143
147
|
}
|
|
144
148
|
const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.'
|
|
145
|
-
await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped' })
|
|
149
|
+
await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', ...callerRoom(exec) })
|
|
146
150
|
return `Stopped ${target.name} in ${running.map(whereOf).join(' and ')}. It is told why in a new turn, whose reply comes back to you.`
|
|
147
151
|
},
|
|
148
152
|
}))
|
|
@@ -334,7 +338,7 @@ export function install(rt) {
|
|
|
334
338
|
description: 'Post a message into a group chat as yourself. It starts a round: members reply in the group, where the user reads them. Main Bots may post into any group; an admin into its own group.',
|
|
335
339
|
parameters: {
|
|
336
340
|
group: GROUP_REF,
|
|
337
|
-
message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers' },
|
|
341
|
+
message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers, and a name without "@" to only mention a member' },
|
|
338
342
|
report_back: { type: 'boolean', description: 'Also send the members\' replies back to you as a new message when the round ends' },
|
|
339
343
|
},
|
|
340
344
|
output,
|
|
@@ -421,5 +425,103 @@ export function install(rt) {
|
|
|
421
425
|
},
|
|
422
426
|
}))
|
|
423
427
|
|
|
428
|
+
ctx.tools.register(defineTool({
|
|
429
|
+
name: 'list_capabilities',
|
|
430
|
+
description: 'List shared MCP servers, skills, and packs, plus the capabilities attached to you.',
|
|
431
|
+
parameters: {},
|
|
432
|
+
output,
|
|
433
|
+
async execute(_args, exec) {
|
|
434
|
+
await rt.load()
|
|
435
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
436
|
+
const bot = rt.botOf(self)
|
|
437
|
+
if (!bot) return 'Only Bots on the team can use list_capabilities.'
|
|
438
|
+
const items = Object.values(state.capabilities?.items ?? {})
|
|
439
|
+
if (items.length === 0) return 'No shared capabilities are saved yet.'
|
|
440
|
+
const statuses = rt.capabilityStatusesView()[bot.id] ?? {}
|
|
441
|
+
const lines = items.map(item => {
|
|
442
|
+
const extra = item.kind === 'pack' ? `; includes ${(item.items ?? []).join(', ') || 'nothing'}`
|
|
443
|
+
: item.kind === 'mcp' && item.transport === 'stdio' ? `; ${item.approved ? 'allowed' : 'needs user approval'}` : ''
|
|
444
|
+
const itemStatus = statuses[item.name]
|
|
445
|
+
const state = itemStatus ? ` (${itemStatus.state}${itemStatus.error ? `: ${itemStatus.error}` : ''})` : ''
|
|
446
|
+
const status = bot.capabilities?.includes(item.name) ? `; attached to you${state}` : ''
|
|
447
|
+
return `- ${item.name} (${item.kind})${item.description ? ` — ${item.description}` : ''}${extra}${status}`
|
|
448
|
+
})
|
|
449
|
+
return ['Shared capabilities:', ...lines].join('\n')
|
|
450
|
+
},
|
|
451
|
+
}))
|
|
452
|
+
ctx.tools.register(defineTool({
|
|
453
|
+
name: 'save_capability',
|
|
454
|
+
description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.',
|
|
455
|
+
parameters: {
|
|
456
|
+
name: { type: 'string', required: true, description: 'Lowercase letters, digits, and single hyphens, up to 32 characters; "github" is reserved' },
|
|
457
|
+
kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] },
|
|
458
|
+
description: { type: 'string', description: 'What this capability provides' },
|
|
459
|
+
transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' },
|
|
460
|
+
command: { type: 'string', description: 'Program that starts a local MCP server' },
|
|
461
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Arguments for command' },
|
|
462
|
+
env: { type: 'object', additionalProperties: true, description: 'Environment variables for command; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
|
|
463
|
+
url: { type: 'string', description: 'MCP server URL' },
|
|
464
|
+
headers: { type: 'object', additionalProperties: true, description: 'HTTP headers; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
|
|
465
|
+
content: { type: 'string', description: 'Skill instructions' },
|
|
466
|
+
items: { type: 'array', items: { type: 'string' }, description: 'MCP server and skill names in the pack' },
|
|
467
|
+
},
|
|
468
|
+
output,
|
|
469
|
+
async execute(args, exec) {
|
|
470
|
+
await rt.load()
|
|
471
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
472
|
+
if (!rt.botOf(self)) return 'Only Bots on the team can use save_capability.'
|
|
473
|
+
try {
|
|
474
|
+
const item = await rt.capabilitySave(args, { botId: self })
|
|
475
|
+
const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved
|
|
476
|
+
? ' The user must allow this MCP server in DS Bot settings before it can work.'
|
|
477
|
+
: ''
|
|
478
|
+
return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}`
|
|
479
|
+
} catch (error) { return error.message }
|
|
480
|
+
},
|
|
481
|
+
}))
|
|
482
|
+
ctx.tools.register(defineTool({
|
|
483
|
+
name: 'delete_capability',
|
|
484
|
+
description: 'Remove a shared capability and detach it from every Bot and pack.',
|
|
485
|
+
parameters: { name: { type: 'string', required: true } },
|
|
486
|
+
output,
|
|
487
|
+
async execute(args, exec) {
|
|
488
|
+
await rt.load()
|
|
489
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
490
|
+
if (!rt.botOf(self)) return 'Only Bots on the team can use delete_capability.'
|
|
491
|
+
try {
|
|
492
|
+
await rt.capabilityDelete(args.name, { botId: self })
|
|
493
|
+
return `Deleted ${args.name}; it was removed from every Bot and pack. The change applies from their next message.`
|
|
494
|
+
} catch (error) { return error.message }
|
|
495
|
+
},
|
|
496
|
+
}))
|
|
497
|
+
for (const [name, endpoint, verb] of [['attach_capability', 'attach', 'Attached'], ['detach_capability', 'detach', 'Detached']]) {
|
|
498
|
+
ctx.tools.register(defineTool({
|
|
499
|
+
name,
|
|
500
|
+
description: `${verb} a shared capability to yourself, or to another Bot if you are a Main Bot.`,
|
|
501
|
+
parameters: {
|
|
502
|
+
name: { type: 'string', required: true, description: 'Capability name from list_capabilities' },
|
|
503
|
+
bot: { type: 'string', description: 'Optional Bot name; only a Main Bot may change another Bot' },
|
|
504
|
+
},
|
|
505
|
+
output,
|
|
506
|
+
async execute(args, exec) {
|
|
507
|
+
await rt.load()
|
|
508
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
509
|
+
if (!rt.botOf(self)) return `Only Bots on the team can use ${name}.`
|
|
510
|
+
try {
|
|
511
|
+
const result = await rt.changeCapabilityAttachment(endpoint, args, self)
|
|
512
|
+
const attached = state.capabilities.items[args.name]
|
|
513
|
+
const members = attached?.kind === 'pack'
|
|
514
|
+
? (attached.items ?? []).map(child => state.capabilities.items[child]).filter(Boolean)
|
|
515
|
+
: attached ? [attached] : []
|
|
516
|
+
const approvals = members
|
|
517
|
+
.filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true)
|
|
518
|
+
.map(item => ` The user must allow ${item.name} in DS Bot settings before it can work.`)
|
|
519
|
+
.join('')
|
|
520
|
+
return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}`
|
|
521
|
+
} catch (error) { return error.message }
|
|
522
|
+
},
|
|
523
|
+
}))
|
|
524
|
+
}
|
|
525
|
+
|
|
424
526
|
Object.assign(rt, { pickRoom })
|
|
425
527
|
}
|
package/src/host/turns.js
CHANGED
|
@@ -31,9 +31,41 @@ export function attachReference(queued, source, text) {
|
|
|
31
31
|
post.references = [...(post.references ?? []), { labels, text }]
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// A tool call some models write into their text instead of making it (the XML-like
|
|
35
|
+
// form of their own chat template).
|
|
36
|
+
const WRITTEN_CALL = /<tool_call>\s*<function=|<function=[\w.-]+>\s*<parameter=/
|
|
37
|
+
export const writesToolCall = text => typeof text === 'string' && WRITTEN_CALL.test(text)
|
|
38
|
+
export const stripWrittenCalls = text => text
|
|
39
|
+
.replace(/<tool_call>[\s\S]*?(?:<\/tool_call>|$)/g, '')
|
|
40
|
+
.replace(/<function=[\w.-]+>[\s\S]*?(?:<\/function>|$)/g, '')
|
|
41
|
+
.trim()
|
|
42
|
+
|
|
43
|
+
const RETRY_TEXT = 'Your last reply wrote a tool call as plain text (<tool_call>…), so it did not run and nothing happened. Call the tool through the tool interface now, then reply as you meant to.'
|
|
44
|
+
|
|
34
45
|
export function install(rt) {
|
|
35
46
|
const { ctx, state } = rt
|
|
36
47
|
|
|
48
|
+
// The retry goes where the turn's trigger came from: a group round's waiter or a
|
|
49
|
+
// Bot's request keeps its exchange; anything else is a nudge in the Bot's own chat.
|
|
50
|
+
const retryWrittenCall = (id, info) => {
|
|
51
|
+
const keeps = info !== undefined && (info.role === 'room' || expectsReply(info))
|
|
52
|
+
return rt.deliver({
|
|
53
|
+
...(keeps && info.senderSessionId ? { fromId: info.senderSessionId } : {}),
|
|
54
|
+
toId: rt.selfOf(id),
|
|
55
|
+
sessionId: id,
|
|
56
|
+
role: keeps ? info.role : 'retry',
|
|
57
|
+
...(keeps && info.roomId ? { roomId: info.roomId } : {}),
|
|
58
|
+
hop: keeps ? info.hop ?? 0 : 0,
|
|
59
|
+
...(keeps && info.exchangeId ? { exchangeId: info.exchangeId } : {}),
|
|
60
|
+
...(keeps && info.audience ? { audience: info.audience } : {}),
|
|
61
|
+
...(keeps && info.askedBy ? { askedBy: info.askedBy } : {}),
|
|
62
|
+
...(keeps && info.senderRoomId ? { fromRoomId: info.senderRoomId } : {}),
|
|
63
|
+
note: 'your tool call did not run',
|
|
64
|
+
retried: true,
|
|
65
|
+
text: RETRY_TEXT,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
37
69
|
/** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
|
|
38
70
|
const live = new Map()
|
|
39
71
|
/** @type {Map<string, {sessionId:string, resolve:(text:string|null)=>void, fail:(failure:any)=>void, touch:()=>void, approval:()=>void}>} member turn waiters keyed by exchange id */
|
|
@@ -157,6 +189,24 @@ export function install(rt) {
|
|
|
157
189
|
return bots
|
|
158
190
|
}
|
|
159
191
|
|
|
192
|
+
// Per group, the members whose turn there is running; the round's current speaker is
|
|
193
|
+
// marked, since its round shows it.
|
|
194
|
+
const roomWorkView = () => {
|
|
195
|
+
const speaking = new Set([...roomProgress.values()].map(round => `${round.roomId}:${round.speaking}`))
|
|
196
|
+
const rooms = {}
|
|
197
|
+
for (const [id, entry] of live) {
|
|
198
|
+
const owner = rt.groupOwners.get(id)
|
|
199
|
+
if (entry.turn === undefined || owner === undefined) continue
|
|
200
|
+
rooms[owner.roomId] = [...(rooms[owner.roomId] ?? []), {
|
|
201
|
+
botId: owner.botId,
|
|
202
|
+
since: entry.since,
|
|
203
|
+
...(entry.tool ? { tool: entry.tool } : {}),
|
|
204
|
+
...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}),
|
|
205
|
+
}]
|
|
206
|
+
}
|
|
207
|
+
return rooms
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
// What a running tool call is about, for an approval card: its own description and
|
|
161
211
|
// the command, path or address it acts on.
|
|
162
212
|
const callDetail = (sessionId, callId) => {
|
|
@@ -197,6 +247,7 @@ export function install(rt) {
|
|
|
197
247
|
const name = String(event.data?.name ?? '')
|
|
198
248
|
setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
|
|
199
249
|
entry.tool = name
|
|
250
|
+
if (rt.groupOwners.has(id)) rt.workRoomTurn?.(id, name)
|
|
200
251
|
if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
|
|
201
252
|
break
|
|
202
253
|
}
|
|
@@ -255,6 +306,10 @@ export function install(rt) {
|
|
|
255
306
|
...(source.late ? { late: true } : {}),
|
|
256
307
|
...(source.quiet ? { quiet: true } : {}),
|
|
257
308
|
...(source.failed ? { failed: source.failed } : {}),
|
|
309
|
+
...(source.resumed ? { resumed: true } : {}),
|
|
310
|
+
...(source.again ? { again: true } : {}),
|
|
311
|
+
...(source.stopped ? { stopped: true } : {}),
|
|
312
|
+
...(source.askedBy ? { askedBy: source.askedBy } : {}),
|
|
258
313
|
})
|
|
259
314
|
} else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
|
|
260
315
|
queueRoomMessage(id, { text: '', kickoff: true })
|
|
@@ -295,8 +350,11 @@ export function install(rt) {
|
|
|
295
350
|
break
|
|
296
351
|
}
|
|
297
352
|
case 'assistant/message': {
|
|
298
|
-
const
|
|
353
|
+
const content = event.data?.message?.content
|
|
354
|
+
const text = textOf(content)
|
|
299
355
|
if (text !== '') entry.texts.push(text)
|
|
356
|
+
entry.lastText = text
|
|
357
|
+
entry.lastCalled = Array.isArray(content) && content.some(block => block?.type === 'tool-call')
|
|
300
358
|
break
|
|
301
359
|
}
|
|
302
360
|
case 'turn/end': {
|
|
@@ -304,6 +362,7 @@ export function install(rt) {
|
|
|
304
362
|
const info = entry.info
|
|
305
363
|
const joined = entry.joined ?? []
|
|
306
364
|
const texts = entry.texts
|
|
365
|
+
const { lastText, lastCalled } = entry
|
|
307
366
|
// A turn stopped for a switch goes on in the next part, which answers instead.
|
|
308
367
|
const switched = entry.switched === true
|
|
309
368
|
entry.turn = undefined
|
|
@@ -314,20 +373,37 @@ export function install(rt) {
|
|
|
314
373
|
entry.since = undefined
|
|
315
374
|
entry.tool = undefined
|
|
316
375
|
entry.calls = undefined
|
|
376
|
+
entry.lastText = undefined
|
|
377
|
+
entry.lastCalled = undefined
|
|
317
378
|
const reason = event.data?.reason
|
|
318
379
|
const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
|
|
319
380
|
if (failure !== undefined) noteTurnFailure(id, failure)
|
|
320
381
|
if (switched) break
|
|
382
|
+
// A model that writes its tool call as text ends the turn as if it were done,
|
|
383
|
+
// though nothing ran. It gets one more turn on the same trigger, so a waiting
|
|
384
|
+
// round or sender gets the real answer; a second time, the text goes on without
|
|
385
|
+
// the call.
|
|
386
|
+
const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText)
|
|
387
|
+
const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId))
|
|
388
|
+
if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error))
|
|
389
|
+
const replyText = () => {
|
|
390
|
+
const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim()
|
|
391
|
+
return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply
|
|
392
|
+
}
|
|
321
393
|
// Whoever the turn answers gets the same three-way rule: a failure notice,
|
|
322
394
|
// the turn's text (or a stopped-before-answer line), or nothing.
|
|
323
395
|
const answer = (source, reply) => {
|
|
324
396
|
if (!expectsReply(source) || answersUser(source)) return
|
|
325
397
|
const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
|
|
398
|
+
// A sender that wrote from a group gets the reply in its Session there, while
|
|
399
|
+
// it is still a member.
|
|
400
|
+
const back = rt.roomOf(source.senderRoomId)?.sessions?.[source.senderSessionId]
|
|
326
401
|
if (failure !== undefined) {
|
|
327
402
|
// The sender was told a reply comes later; without this it would wait forever.
|
|
328
403
|
void rt.deliver({
|
|
329
404
|
fromId: rt.selfOf(id),
|
|
330
405
|
toId: source.senderSessionId,
|
|
406
|
+
...(back ? { sessionId: back } : {}),
|
|
331
407
|
text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
|
|
332
408
|
role: 'reply',
|
|
333
409
|
hop: (source.hop ?? 0) + 1,
|
|
@@ -339,6 +415,7 @@ export function install(rt) {
|
|
|
339
415
|
void rt.deliver({
|
|
340
416
|
fromId: rt.selfOf(id),
|
|
341
417
|
toId: source.senderSessionId,
|
|
418
|
+
...(back ? { sessionId: back } : {}),
|
|
342
419
|
text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
|
|
343
420
|
role: 'reply',
|
|
344
421
|
hop: (source.hop ?? 0) + 1,
|
|
@@ -346,20 +423,28 @@ export function install(rt) {
|
|
|
346
423
|
}).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
|
|
347
424
|
}
|
|
348
425
|
}
|
|
349
|
-
|
|
426
|
+
// A retried turn answers the same waiter or sender, so this one passes nothing on.
|
|
427
|
+
if (!retry && info?.role === 'room') {
|
|
350
428
|
const waiter = roomWaiters.get(info.exchangeId)
|
|
351
429
|
roomWaiters.delete(info.exchangeId)
|
|
352
430
|
const stopped = stoppedRoomTurns.delete(info.exchangeId)
|
|
353
431
|
if (waiter !== undefined) {
|
|
354
432
|
if (failure !== undefined) waiter.fail(failure)
|
|
355
|
-
else waiter.resolve(reason?.kind === 'aborted' ? null :
|
|
433
|
+
else waiter.resolve(reason?.kind === 'aborted' ? null : replyText())
|
|
356
434
|
} else if (!stopped && reason?.kind !== 'aborted') {
|
|
357
435
|
// The round moved on without this member; how its turn ends still reaches the group.
|
|
358
436
|
if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
|
|
359
|
-
else void rt.lateRoomReply?.(id, info,
|
|
437
|
+
else void rt.lateRoomReply?.(id, info, replyText())
|
|
438
|
+
}
|
|
439
|
+
} else if (!retry && info !== undefined) {
|
|
440
|
+
// A group Session speaks only in its group: the turn that takes a reply to a
|
|
441
|
+
// message it sent from there posts what it ends with.
|
|
442
|
+
if (rt.groupOwners.has(id) && (info.role === 'reply' || info.role === 'retry')) {
|
|
443
|
+
if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
|
|
444
|
+
else if (reason?.kind !== 'aborted') void rt.lateRoomReply?.(id, info, replyText())
|
|
445
|
+
} else {
|
|
446
|
+
answer(info, replyText())
|
|
360
447
|
}
|
|
361
|
-
} else if (info !== undefined) {
|
|
362
|
-
answer(info, texts.join('\n\n').trim())
|
|
363
448
|
}
|
|
364
449
|
for (const request of joined) {
|
|
365
450
|
// A failed or stopped turn closes the request like any other; a finished
|
|
@@ -376,6 +461,7 @@ export function install(rt) {
|
|
|
376
461
|
role: 'request',
|
|
377
462
|
hop: request.info.hop ?? 0,
|
|
378
463
|
exchangeId: request.info.exchangeId,
|
|
464
|
+
...(request.info.senderRoomId ? { fromRoomId: request.info.senderRoomId } : {}),
|
|
379
465
|
deferred: true,
|
|
380
466
|
}).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error))
|
|
381
467
|
}
|
|
@@ -384,5 +470,5 @@ export function install(rt) {
|
|
|
384
470
|
}
|
|
385
471
|
})
|
|
386
472
|
|
|
387
|
-
Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, callDetail, stopRoomTurn })
|
|
473
|
+
Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn })
|
|
388
474
|
}
|