ds-bot 0.1.2 → 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/src/host/tools.js CHANGED
@@ -1,9 +1,8 @@
1
1
  // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the
2
2
  // question card. Group rounds and reading chats are in groups.js.
3
3
  import { randomUUID } from 'node:crypto'
4
- import { createUserMessage } from '@deepseek-ai/dsh-llm'
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
- import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES, SOURCE_KIND } from './constants.js'
5
+ import { COLORS, EXTRA_COLORS, MAX_HOPS, ROOM_BOT_ROUNDS, ROOM_MODES } from './constants.js'
7
6
  import { unquote } from './text.js'
8
7
 
9
8
  export const output = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
@@ -18,6 +17,10 @@ export function install(rt) {
18
17
  const { ctx, state } = rt
19
18
 
20
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
+ }
21
24
 
22
25
  const roomLine = (room, self) => {
23
26
  const members = room.members.map(rt.botOf).filter(Boolean)
@@ -58,9 +61,21 @@ export function install(rt) {
58
61
  },
59
62
  }))
60
63
 
64
+ // Sessions a Bot is running in right now: its own chat plus each group Session
65
+ // with a live turn. An idle Bot reports just its own chat.
66
+ const activeSessions = (botId) => {
67
+ const running = [...new Set([rt.chatOf(botId), ...rt.groupSessionsOf(botId)])]
68
+ .filter(sessionId => rt.live.get(sessionId)?.turn !== undefined)
69
+ return { running, idle: running.length === 0 }
70
+ }
71
+ const whereOf = (sessionId) => {
72
+ const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
73
+ return room ? `group "${room.name}"` : 'its own chat'
74
+ }
75
+
61
76
  ctx.tools.register(defineTool({
62
77
  name: 'message_bot',
63
- description: 'Send an asynchronous message to another Bot on the team. By default its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.',
78
+ description: 'Send a message to another Bot on the team. If that Bot is working, it reads your message at its next step and can change course, then answers once that work is done; if it is idle, it starts on it right away. Its reply arrives later as a new message to you; with reply_to "user" it answers the user in its own chat instead.',
64
79
  parameters: {
65
80
  to: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
66
81
  message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' },
@@ -69,6 +84,11 @@ export function install(rt) {
69
84
  enum: ['me', 'user'],
70
85
  description: '"me" (default): the reply comes back to you and the user does not see it. "user": the Bot answers the user in its own chat, where the user reads it, and nothing comes back to you. Use "user" whenever the user wants to hear from that Bot directly or asks Bots to say something in their own chats.',
71
86
  },
87
+ when: {
88
+ type: 'string',
89
+ enum: ['now', 'queue'],
90
+ description: '"now" (default): a working Bot reads your message at its next step, between its current actions, and answers once its current work is done; use it for questions, information it needs, corrections and warnings. "queue": it reads your message only after its current work is done, as a separate task; use it only for a new task unrelated to what it is doing now. With reply_to "user" the message always queues.',
91
+ },
72
92
  },
73
93
  output,
74
94
  async execute(args, exec) {
@@ -78,13 +98,56 @@ export function install(rt) {
78
98
  const target = rt.findBot(args.to)
79
99
  if (target === undefined) return `No Bot named ${args.to}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`
80
100
  if (target.id === sender.id) return 'You cannot message yourself.'
81
- const hop = (rt.live.get(exec.agent?.id)?.info?.hop ?? 0)
101
+ const turn = rt.live.get(exec.agent?.id)
102
+ const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
82
103
  if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
83
104
  const toUser = args.reply_to === 'user'
84
- await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, ...(toUser ? { audience: 'user' } : {}) })
85
- return toUser
105
+ const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
106
+ const steer = args.when !== 'queue' && !toUser
107
+ await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) })
108
+ const sent = toUser
86
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.`
87
- : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
110
+ : working && steer
111
+ ? `Delivered to ${target.name} while it is working: it reads your message at its next step and answers once that work is done. Its reply will arrive later as a new message; do not wait for it.`
112
+ : working
113
+ ? `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
+ : `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
115
+ // 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
118
+ 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}`
120
+ },
121
+ }))
122
+
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
+ ctx.tools.register(defineTool({
128
+ name: 'stop_bot',
129
+ 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.',
130
+ parameters: {
131
+ bot: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
132
+ reason: { type: 'string', description: 'Why it was stopped; it reads this afterwards' },
133
+ },
134
+ output,
135
+ async execute(args, exec) {
136
+ await rt.load()
137
+ const sender = callerBot(exec)
138
+ if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.')
139
+ const target = rt.findBot(args.bot)
140
+ if (target === undefined) refuse(`No Bot named ${args.bot}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`)
141
+ if (target.id === sender.id) refuse('You cannot stop yourself.')
142
+ const { running } = activeSessions(target.id)
143
+ if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`)
144
+ for (const sessionId of running) {
145
+ const agent = await rt.resolveAgent(sessionId)
146
+ agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
147
+ }
148
+ const why = String(args.reason ?? '').trim() || 'Stop what you are doing; the previous turn was ended on purpose. Acknowledge briefly.'
149
+ await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', ...callerRoom(exec) })
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.`
88
151
  },
89
152
  }))
90
153
 
@@ -113,9 +176,9 @@ export function install(rt) {
113
176
 
114
177
  ctx.tools.register(defineTool({
115
178
  name: 'update_bot',
116
- description: 'Main Bot only. Rename a Bot or change its role, color, model, or instructions.',
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.',
117
180
  parameters: {
118
- bot: { type: 'string', required: true, description: 'Current Bot name' },
181
+ bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
119
182
  name: { type: 'string', description: 'New name' },
120
183
  role: { type: 'string', description: 'New role label' },
121
184
  instructions: { type: 'string', description: 'Replacement instructions' },
@@ -125,9 +188,12 @@ export function install(rt) {
125
188
  output,
126
189
  async execute(args, exec) {
127
190
  await rt.load()
128
- if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can update Bots.'
129
- const bot = rt.findBot(args.bot)
191
+ const self = rt.selfOf(exec.agent?.id)
192
+ const caller = rt.botOf(self)
193
+ if (caller === undefined) return 'Only Bots on the team can use update_bot.'
194
+ const bot = args.bot ? rt.findBot(args.bot) : caller
130
195
  if (bot === undefined) return `No Bot named ${args.bot}.`
196
+ 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.'
131
197
  try {
132
198
  const { name: newName, role, instructions, color, model } = args
133
199
  const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
@@ -272,7 +338,7 @@ export function install(rt) {
272
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.',
273
339
  parameters: {
274
340
  group: GROUP_REF,
275
- 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' },
276
342
  report_back: { type: 'boolean', description: 'Also send the members\' replies back to you as a new message when the round ends' },
277
343
  },
278
344
  output,
@@ -294,22 +360,7 @@ export function install(rt) {
294
360
  await rt.save()
295
361
  const reportBack = args.report_back === true
296
362
  try {
297
- const agent = await rt.resolveAgent(room.id)
298
- agent.followup(createUserMessage({
299
- content: [{ type: 'text', text: `[Group post from ${sender.name}]\n${text}` }],
300
- source: {
301
- kind: SOURCE_KIND,
302
- form: 'relay',
303
- role: 'post',
304
- exchangeId: randomUUID(),
305
- senderName: sender.name,
306
- senderSessionId: sender.id,
307
- roomId: room.id,
308
- hop: info?.hop ?? 0,
309
- ...(reportBack ? { reportBack: true } : {}),
310
- },
311
- }))
312
- await ctx.sessions.flush(agent.session)
363
+ await rt.postToRoom(room, sender, text, { hop: info?.hop ?? 0, ...(reportBack ? { reportBack: true } : {}) })
313
364
  } catch (failure) {
314
365
  return `Could not post in "${room.name}": ${failure.message}`
315
366
  }
@@ -354,9 +405,13 @@ export function install(rt) {
354
405
  .filter(option => option.label !== '')
355
406
  .slice(0, 6)
356
407
  if (options.length === 0) return 'Give at least one option.'
408
+ // A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the
409
+ // composer dock, which takes questions without a call.
410
+ const turn = rt.live.get(id)?.turn
411
+ const hidden = turn !== undefined && (state.exchangeTurns[id] ?? []).includes(turn)
357
412
  state.questions[id] = {
358
413
  id: randomUUID(),
359
- ...(exec.callId ? { callId: exec.callId } : {}),
414
+ ...(exec.callId && !hidden ? { callId: exec.callId } : {}),
360
415
  question: String(args.question ?? '').trim(),
361
416
  ...(args.detail ? { detail: String(args.detail).trim() } : {}),
362
417
  options,
@@ -370,5 +425,103 @@ export function install(rt) {
370
425
  },
371
426
  }))
372
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
+
373
526
  Object.assign(rt, { pickRoom })
374
527
  }