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.
@@ -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
+ }
@@ -78,6 +78,7 @@ export function install(rt) {
78
78
  instructions: String(instructions).trim(),
79
79
  createdAt: Date.now(),
80
80
  createdBy: createdBy ?? null,
81
+ capabilities: [],
81
82
  }
82
83
  if (main) state.mainBotIds.push(id)
83
84
  const creator = rt.botOf(createdBy)
@@ -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, { secretsView, secretValue: name => secrets.get(name)?.value, secretEndpoint, forgetBotSecrets, loadSecrets, redactSecrets: text => redact(text) })
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 = false
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 text = textOf(event.data?.message?.content)
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
- if (info?.role === 'room') {
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 : texts.join('\n\n').trim())
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, texts.join('\n\n').trim())
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
  }