ds-bot 0.1.4 → 0.1.5

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/src/host/tools.js CHANGED
@@ -2,16 +2,18 @@
2
2
  // question card. Group rounds and reading chats are in groups.js.
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
- import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES } from './constants.js'
5
+ import { COLORS, EXTRA_COLORS, MAX_HOPS, MAX_UNREAD_REQUESTS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js'
6
6
  import { unquote } from './text.js'
7
7
 
8
8
  export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
9
9
  export const GROUP_REF = { type: 'string', required: true, description: 'Exact group chat name from list_bots' }
10
+ const BLOCKING_QUESTION_TOOL = 'ask_user_question'
10
11
  const MODE_PARAM = {
11
12
  type: 'string',
12
13
  enum: ROOM_MODES,
13
14
  description: '"everyone": every member answers a plain message in turn, admin first. "admin" (the default): a plain message goes to the admin, who calls others with @Name. @mentions always choose who answers.',
14
15
  }
16
+ const WORK_DIR_PARAM = { type: 'string', description: 'Full path of the folder to work in, where commands and file tools start. Only when the user asks for it' }
15
17
 
16
18
  export function install(rt) {
17
19
  const { ctx, state } = rt
@@ -29,6 +31,7 @@ export function install(rt) {
29
31
  `members: ${members.join(', ')}`,
30
32
  room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
31
33
  room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '',
34
+ room.workDir ? `members work in ${room.workDir} here` : '',
32
35
  ].filter(Boolean)
33
36
  return `- "${room.name}" — ${parts.join('; ')}`
34
37
  }
@@ -45,7 +48,7 @@ export function install(rt) {
45
48
  const self = rt.selfOf(exec.agent?.id)
46
49
  const lines = Object.values(state.bots).map(bot => {
47
50
  const model = bot.appliedModel ?? bot.model
48
- const tags = [bot.role, model ? `model ${model}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
51
+ const tags = [bot.role, model ? `model ${model}` : '', bot.workDir ? `works in ${bot.workDir}` : '', rt.isMain(bot.id) ? 'Main Bot' : '', bot.id === self ? 'you' : ''].filter(Boolean).join(', ')
49
52
  const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
50
53
  return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
51
54
  })
@@ -72,6 +75,12 @@ export function install(rt) {
72
75
  const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
73
76
  return room ? `group "${room.name}"` : 'its own chat'
74
77
  }
78
+ // Delivered but not read yet: no step or turn has taken them out of the inbox.
79
+ const unreadRequests = agent => [...(agent.inbox?.nextStep ?? []), ...(agent.inbox?.nextTurn ?? [])]
80
+ .filter(message => message.source?.kind === SOURCE_KIND && message.source.role === 'request').length
81
+
82
+ // A tool that throws shows as failed in the chat, instead of as sent or stopped.
83
+ const refuse = (message) => { throw new Error(message) }
75
84
 
76
85
  ctx.tools.register(defineTool({
77
86
  name: 'message_bot',
@@ -101,8 +110,13 @@ export function install(rt) {
101
110
  const turn = rt.live.get(exec.agent?.id)
102
111
  const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
103
112
  if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
113
+ const chat = rt.chatOf(target.id)
114
+ const unread = unreadRequests(await rt.resolveAgent(chat))
115
+ if (unread >= MAX_UNREAD_REQUESTS) {
116
+ refuse(`Not sent: ${target.name} has not read the last ${unread} messages from Bots yet, so it is most likely stuck, for example waiting for the user. Do not send it more; tell the user that ${target.name} is not responding.`)
117
+ }
104
118
  const toUser = args.reply_to === 'user'
105
- const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
119
+ const working = rt.live.get(chat)?.turn !== undefined
106
120
  const steer = args.when !== 'queue' && !toUser
107
121
  await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) })
108
122
  const sent = toUser
@@ -112,18 +126,20 @@ export function install(rt) {
112
126
  : working
113
127
  ? `Queued for ${target.name}: it reads your message after its current work. Its reply will arrive later as a new message; do not wait for it.`
114
128
  : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
129
+ // "Delivered" only means it is in the inbox; say so when it may sit there a while.
130
+ const notes = [
131
+ rt.waitsForApproval?.(chat) ? `${target.name} is waiting for the user's approval, so it reads your message only after the user answers.` : '',
132
+ unread > 0 ? `${target.name} has not read ${unread === 1 ? 'an earlier message' : `${unread} earlier messages`} from Bots yet.` : '',
133
+ ].filter(Boolean)
134
+ const status = notes.length === 0 ? sent : `${sent} ${notes.join(' ')}`
115
135
  // Messages reach a Bot's own chat only; work in a group goes on without them.
116
- const groups = activeSessions(target.id).running.filter(sessionId => sessionId !== rt.chatOf(target.id))
117
- if (groups.length === 0) return sent
136
+ const groups = activeSessions(target.id).running.filter(sessionId => sessionId !== chat)
137
+ if (groups.length === 0) return status
118
138
  const stop = rt.isMain(sender.id) ? ' If it must not go on there, use stop_bot.' : ''
119
- return `${sent} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
139
+ return `${status} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
120
140
  },
121
141
  }))
122
142
 
123
- // stop_bot throws when it does nothing, so the chat shows the call as failed
124
- // instead of "Stopped".
125
- const refuse = (message) => { throw new Error(message) }
126
-
127
143
  ctx.tools.register(defineTool({
128
144
  name: 'stop_bot',
129
145
  description: 'Main Bot only. Stop another Bot\'s running turn immediately — when it must not finish a dangerous action and a message_bot warning is not enough. Its queued messages are dropped with the turn. It is then told why in a new turn, whose reply comes back to you.',
@@ -160,14 +176,18 @@ export function install(rt) {
160
176
  instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
161
177
  brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
162
178
  model: { type: 'string', description: 'Model the Bot runs on, as provider/model or a model id. Only when the user names one; otherwise the DSH default model.' },
179
+ work_dir: WORK_DIR_PARAM,
163
180
  },
164
181
  output,
165
182
  async execute(args, exec) {
166
183
  await rt.load()
167
184
  if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can create Bots. Ask the Main Bot instead.'
168
185
  try {
169
- const bot = await rt.createBot({ ...args, createdBy: rt.selfOf(exec.agent.id) })
170
- return `Created ${bot.name}. It is greeting the user now.`
186
+ const workDir = args.work_dir ? await rt.cleanWorkDir(args.work_dir) : undefined
187
+ const name = String(args.name ?? '').trim()
188
+ await rt.approveFolder(exec, { path: workDir, who: { en: `the new Bot ${name}`, zh: `新 Bot ${name} ` } })
189
+ const bot = await rt.createBot({ ...args, workDir, createdBy: rt.selfOf(exec.agent.id) })
190
+ return `Created ${bot.name}${workDir ? `, working in ${workDir}` : ''}. It is greeting the user now.`
171
191
  } catch (error) {
172
192
  return `Could not create the Bot: ${error.message}`
173
193
  }
@@ -176,7 +196,7 @@ export function install(rt) {
176
196
 
177
197
  ctx.tools.register(defineTool({
178
198
  name: 'update_bot',
179
- description: 'Change a Bot\'s name, role, color, model, or instructions. Every Bot may change itself; only a Main Bot may change another Bot.',
199
+ description: 'Change a Bot\'s name, role, color, model, working folder, or instructions. Every Bot may change itself; only a Main Bot may change another Bot.',
180
200
  parameters: {
181
201
  bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
182
202
  name: { type: 'string', description: 'New name' },
@@ -184,6 +204,7 @@ export function install(rt) {
184
204
  instructions: { type: 'string', description: 'Replacement instructions' },
185
205
  color: { type: 'string', description: `One of ${[...COLORS, ...EXTRA_COLORS].join(', ')}` },
186
206
  model: { type: 'string', description: 'Move the Bot to another model, as provider/model or a model id' },
207
+ work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}; an empty string returns it to the team workspace` },
187
208
  },
188
209
  output,
189
210
  async execute(args, exec) {
@@ -196,8 +217,13 @@ export function install(rt) {
196
217
  if (bot.id !== caller.id && !rt.isMain(self)) return 'Only a Main Bot can change another Bot. Leave out bot to change yourself, or ask a Main Bot with message_bot.'
197
218
  try {
198
219
  const { name: newName, role, instructions, color, model } = args
199
- const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
200
- return `Updated ${next.name}.`
220
+ const moving = typeof args.work_dir === 'string'
221
+ const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined
222
+ if (moving && workDir !== bot.workDir) await rt.approveFolder(exec, { path: workDir, sessions: rt.sessionsOfBot(bot.id), who: { en: bot.name, zh: ` ${bot.name} ` } })
223
+ const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}), ...(moving ? { workDir: workDir ?? '' } : {}) })
224
+ if (!moving) return `Updated ${next.name}.`
225
+ const folder = workDir ?? 'the team workspace'
226
+ return bot.id === caller.id ? `Updated ${next.name}. From your next step on, you work in ${folder}.` : `Updated ${next.name}. It now works in ${folder}.`
201
227
  } catch (error) {
202
228
  return `Could not update the Bot: ${error.message}`
203
229
  }
@@ -280,6 +306,7 @@ export function install(rt) {
280
306
  remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' },
281
307
  admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' },
282
308
  mode: MODE_PARAM,
309
+ work_dir: { ...WORK_DIR_PARAM, description: `${WORK_DIR_PARAM.description}. Every member works there in this group; an empty string lets each member work in its own folder again (Main Bots only)` },
283
310
  },
284
311
  output,
285
312
  async execute(args, exec) {
@@ -288,6 +315,8 @@ export function install(rt) {
288
315
  if (rt.botOf(self) === undefined) return 'Only Bots on the team can use update_group.'
289
316
  const { room, error } = pickRoom(args.group, self)
290
317
  if (error) return error
318
+ const moving = typeof args.work_dir === 'string'
319
+ if (moving && !rt.isMain(self)) return `Only a Main Bot can set the folder of "${room.name}".`
291
320
  const add = resolveBots(args.add_members)
292
321
  const remove = resolveBots(args.remove_members)
293
322
  const unknown = [...add.unknown, ...remove.unknown]
@@ -301,6 +330,16 @@ export function install(rt) {
301
330
  }
302
331
  }
303
332
  try {
333
+ const workDir = moving ? await rt.cleanWorkDir(args.work_dir) : undefined
334
+ // A Bot that joins a group with a folder works there from its first turn.
335
+ const joining = add.bots.filter(bot => !room.members.includes(bot.id))
336
+ if (moving && workDir !== room.workDir) {
337
+ const members = [...room.members, ...joining.map(bot => bot.id)]
338
+ await rt.approveFolder(exec, { path: workDir, sessions: members.map(id => room.sessions?.[id]).filter(Boolean), who: { en: `every member of "${room.name}"`, zh: `群聊「${room.name}」的成员` } })
339
+ } else if (joining.length > 0) {
340
+ const names = joining.map(bot => bot.name)
341
+ await rt.approveFolder(exec, { path: room.workDir, sessions: joining.map(bot => room.sessions?.[bot.id]).filter(Boolean), who: { en: names.join(', '), zh: ` ${names.join('、')} ` } })
342
+ }
304
343
  const { room: next, changes } = await rt.updateRoom(room.id, {
305
344
  name: args.name,
306
345
  notice: args.notice,
@@ -308,6 +347,7 @@ export function install(rt) {
308
347
  admin,
309
348
  add: add.bots.map(bot => bot.id),
310
349
  remove: remove.bots.map(bot => bot.id),
350
+ ...(moving ? { workDir: workDir ?? '' } : {}),
311
351
  }, self)
312
352
  return changes.length === 0 ? `Nothing changed in "${next.name}".` : `Updated "${next.name}": ${changes.join('; ')}.`
313
353
  } catch (failure) {
@@ -425,6 +465,38 @@ export function install(rt) {
425
465
  },
426
466
  }))
427
467
 
468
+ // dsh's own question tool holds the turn open until the user answers, and every
469
+ // message from another Bot or a group round queues behind it. Bot Sessions ask
470
+ // through ask_user instead, so the tool is taken out of their tool list.
471
+ const hiddenQuestion = new WeakSet()
472
+ const hideBlockingQuestion = (agent) => {
473
+ if (agent === undefined || hiddenQuestion.has(agent) || !rt.isBotSession(agent.session?.id)) return
474
+ if (typeof agent.ctx?.tools?.restrict !== 'function') return
475
+ try {
476
+ // restrict() throws on a name this profile does not serve.
477
+ if (!ctx.tools.schemas(agent).some(schema => schema.name === BLOCKING_QUESTION_TOOL)) return
478
+ agent.ctx.tools.restrict({ deny: [BLOCKING_QUESTION_TOOL] })
479
+ hiddenQuestion.add(agent)
480
+ } catch (error) {
481
+ rt.warn('ds-bot: %s stays in the tool list of %s: %s', BLOCKING_QUESTION_TOOL, agent.session?.id, error)
482
+ }
483
+ }
484
+ // A new Bot, part, or group Session gets its Agent before ds-bot records it, so the
485
+ // list is only trimmed once ds-bot resolves that Agent again; the guard refuses
486
+ // the tool in any Bot Session meanwhile.
487
+ ctx.tools.guard(exec => (exec.name === BLOCKING_QUESTION_TOOL && rt.isBotSession(exec.agent?.id)
488
+ ? `Bots cannot use ${BLOCKING_QUESTION_TOOL}. Ask with ask_user in your own chat, or ask in your reply.`
489
+ : undefined))
490
+ // Creation waits for this listener, so the Agent's first step already lacks the tool.
491
+ ctx.on('agent/created', async ({ agent }) => {
492
+ try {
493
+ await rt.load()
494
+ hideBlockingQuestion(agent)
495
+ } catch (error) {
496
+ rt.warn('ds-bot: hiding %s on agent/created failed: %s', BLOCKING_QUESTION_TOOL, error)
497
+ }
498
+ })
499
+
428
500
  ctx.tools.register(defineTool({
429
501
  name: 'list_capabilities',
430
502
  description: 'List shared MCP servers, skills, and packs, plus the capabilities attached to you.',
@@ -523,5 +595,5 @@ export function install(rt) {
523
595
  }))
524
596
  }
525
597
 
526
- Object.assign(rt, { pickRoom })
598
+ Object.assign(rt, { pickRoom, hideBlockingQuestion })
527
599
  }
package/src/host/turns.js CHANGED
@@ -40,6 +40,10 @@ export const stripWrittenCalls = text => text
40
40
  .replace(/<function=[\w.-]+>[\s\S]*?(?:<\/function>|$)/g, '')
41
41
  .trim()
42
42
 
43
+ // Under DSH's PTC presentation the model calls only this tool; the program's sub-calls
44
+ // are the tools it really runs.
45
+ const PROGRAM_TOOL = 'run_code'
46
+
43
47
  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
48
 
45
49
  export function install(rt) {
@@ -120,6 +124,22 @@ export function install(rt) {
120
124
  else next[id] = value
121
125
  activity = next
122
126
  }
127
+ // The newest call still running, a program's sub-calls included, is what the Session
128
+ // is doing.
129
+ const showCall = (id, entry) => {
130
+ const name = [...(entry.calls?.values() ?? [])].at(-1)?.name
131
+ entry.tool = name
132
+ if (name !== undefined) setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
133
+ else if (entry.turn !== undefined) setActivity(id, 'thinking')
134
+ }
135
+ const startCall = (id, entry, callId, name, args) => {
136
+ if (typeof callId === 'string') (entry.calls ??= new Map()).set(callId, { name, args })
137
+ showCall(id, entry)
138
+ }
139
+ const endCall = (id, entry, callId) => {
140
+ entry.calls?.delete(callId)
141
+ showCall(id, entry)
142
+ }
123
143
 
124
144
  const markExchangeTurn = (sessionId, turn) => {
125
145
  const turns = state.exchangeTurns[sessionId] ??= []
@@ -211,7 +231,7 @@ export function install(rt) {
211
231
  // the command, path or address it acts on.
212
232
  const callDetail = (sessionId, callId) => {
213
233
  let args
214
- try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId) ?? '{}') } catch { args = {} }
234
+ try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId)?.args ?? '{}') } catch { args = {} }
215
235
  const first = keys => keys.map(key => args?.[key]).find(value => typeof value === 'string' && value.trim() !== '')
216
236
  const text = first(['description', 'justification'])
217
237
  const code = first(['command', 'cmd', 'file_path', 'path', 'url', 'query'])
@@ -245,16 +265,22 @@ export function install(rt) {
245
265
  switch (event.type) {
246
266
  case 'tool/call': {
247
267
  const name = String(event.data?.name ?? '')
248
- setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
249
- entry.tool = name
268
+ startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? ''))
269
+ // A program is as quick as the tools it runs, so its sub-calls decide.
270
+ if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name)
271
+ break
272
+ }
273
+ case 'tool/ptc-dispatch-start': {
274
+ const name = String(event.data?.name ?? '')
275
+ startCall(id, entry, event.data?.subCallId, name, JSON.stringify(event.data?.arguments ?? {}))
250
276
  if (rt.groupOwners.has(id)) rt.workRoomTurn?.(id, name)
251
- if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
252
277
  break
253
278
  }
279
+ case 'tool/ptc-dispatch':
280
+ endCall(id, entry, event.data?.subCallId)
281
+ break
254
282
  case 'tool/result':
255
- if (entry.turn !== undefined) setActivity(id, 'thinking')
256
- entry.tool = undefined
257
- entry.calls?.delete(event.data?.callId)
283
+ endCall(id, entry, event.data?.callId)
258
284
  break
259
285
  case 'turn/start':
260
286
  setActivity(id, 'thinking')
package/src/host/usage.js CHANGED
@@ -523,6 +523,9 @@ export function install(rt) {
523
523
  const tag = from !== null && typeof from === 'object' ? tags.get(from) : undefined
524
524
  if (tag !== undefined) tagUsage(to, tag)
525
525
  }
526
+ // DS Bot's own requests name the Session they serve, as some routes require, so
527
+ // `sessionId` alone does not make a request that Session's own round; the tag does.
528
+ const ownRequest = options => tags.has(options?.messages)
526
529
  // Owner and group use are journaled with the call itself: a Bot or room that
527
530
  // is deleted before the first scan still leaves its calls under its own name.
528
531
  const journaled = new Map()
@@ -590,5 +593,5 @@ export function install(rt) {
590
593
  })()
591
594
  })
592
595
 
593
- Object.assign(rt, { usageView, tagUsage, carryUsageTag })
596
+ Object.assign(rt, { usageView, tagUsage, carryUsageTag, ownRequest })
594
597
  }