ds-bot 0.1.3 → 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/README.md +5 -4
- package/client.js +1948 -1269
- package/index.js +12 -3
- package/package.json +1 -1
- package/src/host/approvals.js +8 -2
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +25 -6
- package/src/host/constants.js +11 -0
- package/src/host/delivery.js +21 -6
- package/src/host/folders.js +135 -0
- package/src/host/groups.js +123 -33
- package/src/host/images.js +2 -2
- package/src/host/memory.js +31 -8
- package/src/host/parts.js +100 -32
- package/src/host/prompt.js +5 -5
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +41 -10
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +16 -3
- package/src/host/tools.js +193 -19
- package/src/host/turns.js +126 -14
- package/src/host/usage.js +4 -1
package/src/host/roster.js
CHANGED
|
@@ -21,13 +21,16 @@ export function install(rt) {
|
|
|
21
21
|
return workspace.id
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// `dir` is a working folder outside the team workspace. Such a Session is made by cwd
|
|
25
|
+
// alone, so it joins no DSH workspace and the user's own project lists stay as they are.
|
|
26
|
+
async function createSession(title, { dir } = {}) {
|
|
25
27
|
// A Bot's tools run here too; while another dsh holds the team, a new Session would
|
|
26
28
|
// belong to no saved Bot.
|
|
27
29
|
const readOnly = rt.readOnly()
|
|
28
30
|
if (readOnly !== undefined) throw new Error(readOnly)
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
+
const usable = dir !== undefined && await rt.isFolder(dir)
|
|
32
|
+
if (dir !== undefined && !usable) rt.warn('ds-bot: the working folder %s is gone; %s starts in the team workspace', dir, title)
|
|
33
|
+
const { sessionId } = await ctx.sessionController.create(usable ? { cwd: dir } : { workspaceId: await ensureWorkspace() })
|
|
31
34
|
const agent = await rt.resolveAgent(sessionId)
|
|
32
35
|
await ctx.sessionTitle.rename(agent.session, title)
|
|
33
36
|
return sessionId
|
|
@@ -55,7 +58,7 @@ export function install(rt) {
|
|
|
55
58
|
|
|
56
59
|
// `pickModel` leaves the model to the user: the chat opens on a model card, and the
|
|
57
60
|
// greeting waits until a model is chosen. So does a Bot made when no model is served.
|
|
58
|
-
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
|
|
61
|
+
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, copyOf, pickModel = false }) {
|
|
59
62
|
const cleanName = String(botName ?? '').trim().slice(0, 40)
|
|
60
63
|
const problem = nameProblem(cleanName)
|
|
61
64
|
if (problem !== undefined) throw new Error(problem)
|
|
@@ -64,7 +67,8 @@ export function install(rt) {
|
|
|
64
67
|
// The model is fixed at creation, so a later change of the DSH default leaves it alone.
|
|
65
68
|
const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
|
|
66
69
|
const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
|
|
67
|
-
const
|
|
70
|
+
const dir = workDir === undefined || workDir === null ? undefined : await rt.cleanWorkDir(workDir)
|
|
71
|
+
const id = await createSession(cleanName, { dir })
|
|
68
72
|
try {
|
|
69
73
|
state.bots[id] = {
|
|
70
74
|
id,
|
|
@@ -75,24 +79,29 @@ export function install(rt) {
|
|
|
75
79
|
...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
|
|
76
80
|
...(modelRef ? { model: modelRef } : {}),
|
|
77
81
|
...(effortId ? { effort: effortId } : {}),
|
|
82
|
+
...(dir ? { workDir: dir } : {}),
|
|
78
83
|
instructions: String(instructions).trim(),
|
|
79
84
|
createdAt: Date.now(),
|
|
80
85
|
createdBy: createdBy ?? null,
|
|
86
|
+
capabilities: [],
|
|
81
87
|
}
|
|
82
88
|
if (main) state.mainBotIds.push(id)
|
|
83
89
|
const creator = rt.botOf(createdBy)
|
|
84
90
|
const original = rt.botOf(copyOf)
|
|
85
91
|
const language = rt.uiLanguage?.()
|
|
92
|
+
// The kickoff arrives as a user message, so it outranks the Bot's instructions
|
|
93
|
+
// unless it gives way to them, and it names no tool those instructions may rule out.
|
|
86
94
|
const kickoff = main
|
|
87
|
-
? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then
|
|
95
|
+
? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then ask in one short question what they want the team to help with first. Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
|
|
88
96
|
: original
|
|
89
|
-
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then
|
|
97
|
+
? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then ask in one short question what this copy should focus on.`
|
|
90
98
|
: creator
|
|
91
|
-
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then
|
|
99
|
+
? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then ask in one short question where to start. Keep it brief.`
|
|
92
100
|
: brief
|
|
93
101
|
? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
|
|
94
|
-
: 'The user just created you. Greet the user in one short message, then
|
|
95
|
-
const
|
|
102
|
+
: 'The user just created you. Greet the user in one short message, then ask in one short question what you should focus on.'
|
|
103
|
+
const opener = `${kickoff}\nWhere your instructions say otherwise, follow them.`
|
|
104
|
+
const text = !main && language ? `${opener}\nWrite in ${language}.` : opener
|
|
96
105
|
const bot = state.bots[id]
|
|
97
106
|
if (modelRef === undefined) {
|
|
98
107
|
bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
|
|
@@ -144,6 +153,8 @@ export function install(rt) {
|
|
|
144
153
|
avatar: bot.avatar,
|
|
145
154
|
model: served ? bot.model : undefined,
|
|
146
155
|
effort: served ? bot.effort : undefined,
|
|
156
|
+
// A folder that went away since leaves the copy in the team workspace.
|
|
157
|
+
workDir: bot.workDir !== undefined && await rt.isFolder(bot.workDir) ? bot.workDir : undefined,
|
|
147
158
|
copyOf: bot.id,
|
|
148
159
|
})
|
|
149
160
|
}
|
|
@@ -151,6 +162,9 @@ export function install(rt) {
|
|
|
151
162
|
async function updateBot(id, patch) {
|
|
152
163
|
const bot = rt.botOf(id)
|
|
153
164
|
if (bot === undefined) throw new Error('Unknown Bot')
|
|
165
|
+
// Checked first, so a folder that does not pass leaves the rest unchanged too.
|
|
166
|
+
const moving = patch.workDir !== undefined
|
|
167
|
+
const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
|
|
154
168
|
if (patch.name !== undefined) {
|
|
155
169
|
const next = String(patch.name).trim().slice(0, 40)
|
|
156
170
|
const clash = rt.findBot(next)
|
|
@@ -190,8 +204,14 @@ export function install(rt) {
|
|
|
190
204
|
modelChanged = true
|
|
191
205
|
}
|
|
192
206
|
if (patch.permission !== undefined) await rt.setPermission(bot, patch.permission)
|
|
207
|
+
const moved = moving && workDir !== bot.workDir
|
|
208
|
+
if (moving) {
|
|
209
|
+
if (workDir === undefined) delete bot.workDir
|
|
210
|
+
else bot.workDir = workDir
|
|
211
|
+
}
|
|
193
212
|
await rt.save()
|
|
194
213
|
if (modelChanged) await rt.applyModel(bot, { force: true })
|
|
214
|
+
if (moved) void rt.followFolder(bot.id)
|
|
195
215
|
if (bot.model && bot.kickoff !== undefined) {
|
|
196
216
|
const held = bot.kickoff
|
|
197
217
|
delete bot.kickoff
|
|
@@ -259,6 +279,8 @@ export function install(rt) {
|
|
|
259
279
|
if (adminId !== null && !members.includes(adminId)) throw new Error('The admin must be a member of the group')
|
|
260
280
|
if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
261
281
|
const title = custom || autoRoomName(members)
|
|
282
|
+
// The group's own Session only relays, so it stays in the team workspace; the
|
|
283
|
+
// group's folder is for its members' Sessions.
|
|
262
284
|
const id = await createSession(title)
|
|
263
285
|
try {
|
|
264
286
|
state.rooms[id] = {
|
|
@@ -308,6 +330,9 @@ export function install(rt) {
|
|
|
308
330
|
if (patch.mode !== undefined && !ROOM_MODES.includes(patch.mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
|
|
309
331
|
const name = patch.name === undefined ? undefined : cleanRoomName(patch.name, room.id)
|
|
310
332
|
const notice = patch.notice === undefined ? undefined : cleanNotice(patch.notice)
|
|
333
|
+
const moving = patch.workDir !== undefined
|
|
334
|
+
if (moving && byAdmin) throw new Error(`Only the user or a Main Bot can set the folder of "${room.name}"`)
|
|
335
|
+
const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
|
|
311
336
|
|
|
312
337
|
const changes = []
|
|
313
338
|
const names = ids => ids.map(member => rt.botOf(member)?.name).filter(Boolean).join(', ')
|
|
@@ -331,6 +356,12 @@ export function install(rt) {
|
|
|
331
356
|
room.notice = notice
|
|
332
357
|
changes.push(notice ? 'updated the notice' : 'cleared the notice')
|
|
333
358
|
}
|
|
359
|
+
// Members' group conversations move on to the folder at their next turn there.
|
|
360
|
+
if (moving && workDir !== room.workDir) {
|
|
361
|
+
if (workDir === undefined) delete room.workDir
|
|
362
|
+
else room.workDir = workDir
|
|
363
|
+
changes.push(workDir ? `every member now works in ${workDir} here` : 'every member works in its own folder again')
|
|
364
|
+
}
|
|
334
365
|
const before = room.name
|
|
335
366
|
if (name !== undefined) {
|
|
336
367
|
if (name !== '') {
|
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
|
@@ -2,21 +2,27 @@
|
|
|
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
|
|
18
20
|
|
|
19
21
|
const callerBot = (exec) => rt.botOf(rt.selfOf(exec.agent?.id))
|
|
22
|
+
const callerRoom = (exec) => {
|
|
23
|
+
const roomId = rt.groupOwners.get(exec.agent?.id)?.roomId
|
|
24
|
+
return roomId ? { fromRoomId: roomId } : {}
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
const roomLine = (room, self) => {
|
|
22
28
|
const members = room.members.map(rt.botOf).filter(Boolean)
|
|
@@ -25,6 +31,7 @@ export function install(rt) {
|
|
|
25
31
|
`members: ${members.join(', ')}`,
|
|
26
32
|
room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn',
|
|
27
33
|
room.notice ? `notice: ${room.notice.split('\n')[0].slice(0, 100)}` : '',
|
|
34
|
+
room.workDir ? `members work in ${room.workDir} here` : '',
|
|
28
35
|
].filter(Boolean)
|
|
29
36
|
return `- "${room.name}" — ${parts.join('; ')}`
|
|
30
37
|
}
|
|
@@ -41,7 +48,7 @@ export function install(rt) {
|
|
|
41
48
|
const self = rt.selfOf(exec.agent?.id)
|
|
42
49
|
const lines = Object.values(state.bots).map(bot => {
|
|
43
50
|
const model = bot.appliedModel ?? bot.model
|
|
44
|
-
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(', ')
|
|
45
52
|
const job = bot.instructions ? ` — ${bot.instructions.split('\n')[0].slice(0, 120)}` : ''
|
|
46
53
|
return `- ${bot.name}${tags ? ` (${tags})` : ''}${job}`
|
|
47
54
|
})
|
|
@@ -68,6 +75,12 @@ export function install(rt) {
|
|
|
68
75
|
const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
|
|
69
76
|
return room ? `group "${room.name}"` : 'its own chat'
|
|
70
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) }
|
|
71
84
|
|
|
72
85
|
ctx.tools.register(defineTool({
|
|
73
86
|
name: 'message_bot',
|
|
@@ -97,10 +110,15 @@ export function install(rt) {
|
|
|
97
110
|
const turn = rt.live.get(exec.agent?.id)
|
|
98
111
|
const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
|
|
99
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
|
+
}
|
|
100
118
|
const toUser = args.reply_to === 'user'
|
|
101
|
-
const working = rt.live.get(
|
|
119
|
+
const working = rt.live.get(chat)?.turn !== undefined
|
|
102
120
|
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' } :
|
|
121
|
+
await rt.deliver({ fromId: sender.id, toId: target.id, text: args.message, role: 'request', hop, steer, ...(toUser ? { audience: 'user' } : callerRoom(exec)) })
|
|
104
122
|
const sent = toUser
|
|
105
123
|
? `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
124
|
: working && steer
|
|
@@ -108,18 +126,20 @@ export function install(rt) {
|
|
|
108
126
|
: working
|
|
109
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.`
|
|
110
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(' ')}`
|
|
111
135
|
// Messages reach a Bot's own chat only; work in a group goes on without them.
|
|
112
|
-
const groups = activeSessions(target.id).running.filter(sessionId => sessionId !==
|
|
113
|
-
if (groups.length === 0) return
|
|
136
|
+
const groups = activeSessions(target.id).running.filter(sessionId => sessionId !== chat)
|
|
137
|
+
if (groups.length === 0) return status
|
|
114
138
|
const stop = rt.isMain(sender.id) ? ' If it must not go on there, use stop_bot.' : ''
|
|
115
|
-
return `${
|
|
139
|
+
return `${status} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
|
|
116
140
|
},
|
|
117
141
|
}))
|
|
118
142
|
|
|
119
|
-
// stop_bot throws when it does nothing, so the chat shows the call as failed
|
|
120
|
-
// instead of "Stopped".
|
|
121
|
-
const refuse = (message) => { throw new Error(message) }
|
|
122
|
-
|
|
123
143
|
ctx.tools.register(defineTool({
|
|
124
144
|
name: 'stop_bot',
|
|
125
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.',
|
|
@@ -142,7 +162,7 @@ export function install(rt) {
|
|
|
142
162
|
agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
|
|
143
163
|
}
|
|
144
164
|
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' })
|
|
165
|
+
await rt.deliver({ fromId: sender.id, toId: target.id, text: why, role: 'stopped', ...callerRoom(exec) })
|
|
146
166
|
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
167
|
},
|
|
148
168
|
}))
|
|
@@ -156,14 +176,18 @@ export function install(rt) {
|
|
|
156
176
|
instructions: { type: 'string', required: true, description: 'What this Bot is responsible for and how it should work' },
|
|
157
177
|
brief: { type: 'string', description: 'Why the user wants it, passed to the new Bot as its first context' },
|
|
158
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,
|
|
159
180
|
},
|
|
160
181
|
output,
|
|
161
182
|
async execute(args, exec) {
|
|
162
183
|
await rt.load()
|
|
163
184
|
if (!rt.isMain(rt.selfOf(exec.agent?.id))) return 'Only the Main Bot can create Bots. Ask the Main Bot instead.'
|
|
164
185
|
try {
|
|
165
|
-
const
|
|
166
|
-
|
|
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.`
|
|
167
191
|
} catch (error) {
|
|
168
192
|
return `Could not create the Bot: ${error.message}`
|
|
169
193
|
}
|
|
@@ -172,7 +196,7 @@ export function install(rt) {
|
|
|
172
196
|
|
|
173
197
|
ctx.tools.register(defineTool({
|
|
174
198
|
name: 'update_bot',
|
|
175
|
-
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.',
|
|
176
200
|
parameters: {
|
|
177
201
|
bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
|
|
178
202
|
name: { type: 'string', description: 'New name' },
|
|
@@ -180,6 +204,7 @@ export function install(rt) {
|
|
|
180
204
|
instructions: { type: 'string', description: 'Replacement instructions' },
|
|
181
205
|
color: { type: 'string', description: `One of ${[...COLORS, ...EXTRA_COLORS].join(', ')}` },
|
|
182
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` },
|
|
183
208
|
},
|
|
184
209
|
output,
|
|
185
210
|
async execute(args, exec) {
|
|
@@ -192,8 +217,13 @@ export function install(rt) {
|
|
|
192
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.'
|
|
193
218
|
try {
|
|
194
219
|
const { name: newName, role, instructions, color, model } = args
|
|
195
|
-
const
|
|
196
|
-
|
|
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}.`
|
|
197
227
|
} catch (error) {
|
|
198
228
|
return `Could not update the Bot: ${error.message}`
|
|
199
229
|
}
|
|
@@ -276,6 +306,7 @@ export function install(rt) {
|
|
|
276
306
|
remove_members: { type: 'array', items: { type: 'string' }, description: 'Exact names of Bots to remove' },
|
|
277
307
|
admin: { type: 'string', description: 'Member to make the admin; "none" leaves the group without one (Main Bots only)' },
|
|
278
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)` },
|
|
279
310
|
},
|
|
280
311
|
output,
|
|
281
312
|
async execute(args, exec) {
|
|
@@ -284,6 +315,8 @@ export function install(rt) {
|
|
|
284
315
|
if (rt.botOf(self) === undefined) return 'Only Bots on the team can use update_group.'
|
|
285
316
|
const { room, error } = pickRoom(args.group, self)
|
|
286
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}".`
|
|
287
320
|
const add = resolveBots(args.add_members)
|
|
288
321
|
const remove = resolveBots(args.remove_members)
|
|
289
322
|
const unknown = [...add.unknown, ...remove.unknown]
|
|
@@ -297,6 +330,16 @@ export function install(rt) {
|
|
|
297
330
|
}
|
|
298
331
|
}
|
|
299
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
|
+
}
|
|
300
343
|
const { room: next, changes } = await rt.updateRoom(room.id, {
|
|
301
344
|
name: args.name,
|
|
302
345
|
notice: args.notice,
|
|
@@ -304,6 +347,7 @@ export function install(rt) {
|
|
|
304
347
|
admin,
|
|
305
348
|
add: add.bots.map(bot => bot.id),
|
|
306
349
|
remove: remove.bots.map(bot => bot.id),
|
|
350
|
+
...(moving ? { workDir: workDir ?? '' } : {}),
|
|
307
351
|
}, self)
|
|
308
352
|
return changes.length === 0 ? `Nothing changed in "${next.name}".` : `Updated "${next.name}": ${changes.join('; ')}.`
|
|
309
353
|
} catch (failure) {
|
|
@@ -334,7 +378,7 @@ export function install(rt) {
|
|
|
334
378
|
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
379
|
parameters: {
|
|
336
380
|
group: GROUP_REF,
|
|
337
|
-
message: { type: 'string', required: true, description: 'What to say; write "@Name" to choose who answers' },
|
|
381
|
+
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
382
|
report_back: { type: 'boolean', description: 'Also send the members\' replies back to you as a new message when the round ends' },
|
|
339
383
|
},
|
|
340
384
|
output,
|
|
@@ -421,5 +465,135 @@ export function install(rt) {
|
|
|
421
465
|
},
|
|
422
466
|
}))
|
|
423
467
|
|
|
424
|
-
|
|
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
|
+
|
|
500
|
+
ctx.tools.register(defineTool({
|
|
501
|
+
name: 'list_capabilities',
|
|
502
|
+
description: 'List shared MCP servers, skills, and packs, plus the capabilities attached to you.',
|
|
503
|
+
parameters: {},
|
|
504
|
+
output,
|
|
505
|
+
async execute(_args, exec) {
|
|
506
|
+
await rt.load()
|
|
507
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
508
|
+
const bot = rt.botOf(self)
|
|
509
|
+
if (!bot) return 'Only Bots on the team can use list_capabilities.'
|
|
510
|
+
const items = Object.values(state.capabilities?.items ?? {})
|
|
511
|
+
if (items.length === 0) return 'No shared capabilities are saved yet.'
|
|
512
|
+
const statuses = rt.capabilityStatusesView()[bot.id] ?? {}
|
|
513
|
+
const lines = items.map(item => {
|
|
514
|
+
const extra = item.kind === 'pack' ? `; includes ${(item.items ?? []).join(', ') || 'nothing'}`
|
|
515
|
+
: item.kind === 'mcp' && item.transport === 'stdio' ? `; ${item.approved ? 'allowed' : 'needs user approval'}` : ''
|
|
516
|
+
const itemStatus = statuses[item.name]
|
|
517
|
+
const state = itemStatus ? ` (${itemStatus.state}${itemStatus.error ? `: ${itemStatus.error}` : ''})` : ''
|
|
518
|
+
const status = bot.capabilities?.includes(item.name) ? `; attached to you${state}` : ''
|
|
519
|
+
return `- ${item.name} (${item.kind})${item.description ? ` — ${item.description}` : ''}${extra}${status}`
|
|
520
|
+
})
|
|
521
|
+
return ['Shared capabilities:', ...lines].join('\n')
|
|
522
|
+
},
|
|
523
|
+
}))
|
|
524
|
+
ctx.tools.register(defineTool({
|
|
525
|
+
name: 'save_capability',
|
|
526
|
+
description: 'Create or update a shared MCP server, skill, or pack. Changes apply from your next message.',
|
|
527
|
+
parameters: {
|
|
528
|
+
name: { type: 'string', required: true, description: 'Lowercase letters, digits, and single hyphens, up to 32 characters; "github" is reserved' },
|
|
529
|
+
kind: { type: 'string', required: true, enum: ['mcp', 'skill', 'pack'] },
|
|
530
|
+
description: { type: 'string', description: 'What this capability provides' },
|
|
531
|
+
transport: { type: 'string', enum: ['stdio', 'streamable-http'], description: 'How to reach an MCP server: a local command or an HTTP URL' },
|
|
532
|
+
command: { type: 'string', description: 'Program that starts a local MCP server' },
|
|
533
|
+
args: { type: 'array', items: { type: 'string' }, description: 'Arguments for command' },
|
|
534
|
+
env: { type: 'object', additionalProperties: true, description: 'Environment variables for command; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
|
|
535
|
+
url: { type: 'string', description: 'MCP server URL' },
|
|
536
|
+
headers: { type: 'object', additionalProperties: true, description: 'HTTP headers; write $DSH_SECRET_<NAME> to insert a saved secret instead of the value' },
|
|
537
|
+
content: { type: 'string', description: 'Skill instructions' },
|
|
538
|
+
items: { type: 'array', items: { type: 'string' }, description: 'MCP server and skill names in the pack' },
|
|
539
|
+
},
|
|
540
|
+
output,
|
|
541
|
+
async execute(args, exec) {
|
|
542
|
+
await rt.load()
|
|
543
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
544
|
+
if (!rt.botOf(self)) return 'Only Bots on the team can use save_capability.'
|
|
545
|
+
try {
|
|
546
|
+
const item = await rt.capabilitySave(args, { botId: self })
|
|
547
|
+
const approval = item.kind === 'mcp' && item.transport === 'stdio' && !item.approved
|
|
548
|
+
? ' The user must allow this MCP server in DS Bot settings before it can work.'
|
|
549
|
+
: ''
|
|
550
|
+
return `Saved ${item.name}. Bots with it attached use the change from their next message.${approval}`
|
|
551
|
+
} catch (error) { return error.message }
|
|
552
|
+
},
|
|
553
|
+
}))
|
|
554
|
+
ctx.tools.register(defineTool({
|
|
555
|
+
name: 'delete_capability',
|
|
556
|
+
description: 'Remove a shared capability and detach it from every Bot and pack.',
|
|
557
|
+
parameters: { name: { type: 'string', required: true } },
|
|
558
|
+
output,
|
|
559
|
+
async execute(args, exec) {
|
|
560
|
+
await rt.load()
|
|
561
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
562
|
+
if (!rt.botOf(self)) return 'Only Bots on the team can use delete_capability.'
|
|
563
|
+
try {
|
|
564
|
+
await rt.capabilityDelete(args.name, { botId: self })
|
|
565
|
+
return `Deleted ${args.name}; it was removed from every Bot and pack. The change applies from their next message.`
|
|
566
|
+
} catch (error) { return error.message }
|
|
567
|
+
},
|
|
568
|
+
}))
|
|
569
|
+
for (const [name, endpoint, verb] of [['attach_capability', 'attach', 'Attached'], ['detach_capability', 'detach', 'Detached']]) {
|
|
570
|
+
ctx.tools.register(defineTool({
|
|
571
|
+
name,
|
|
572
|
+
description: `${verb} a shared capability to yourself, or to another Bot if you are a Main Bot.`,
|
|
573
|
+
parameters: {
|
|
574
|
+
name: { type: 'string', required: true, description: 'Capability name from list_capabilities' },
|
|
575
|
+
bot: { type: 'string', description: 'Optional Bot name; only a Main Bot may change another Bot' },
|
|
576
|
+
},
|
|
577
|
+
output,
|
|
578
|
+
async execute(args, exec) {
|
|
579
|
+
await rt.load()
|
|
580
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
581
|
+
if (!rt.botOf(self)) return `Only Bots on the team can use ${name}.`
|
|
582
|
+
try {
|
|
583
|
+
const result = await rt.changeCapabilityAttachment(endpoint, args, self)
|
|
584
|
+
const attached = state.capabilities.items[args.name]
|
|
585
|
+
const members = attached?.kind === 'pack'
|
|
586
|
+
? (attached.items ?? []).map(child => state.capabilities.items[child]).filter(Boolean)
|
|
587
|
+
: attached ? [attached] : []
|
|
588
|
+
const approvals = members
|
|
589
|
+
.filter(item => item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true)
|
|
590
|
+
.map(item => ` The user must allow ${item.name} in DS Bot settings before it can work.`)
|
|
591
|
+
.join('')
|
|
592
|
+
return `${verb} ${args.name} ${args.bot ? `to ${result.bot}` : 'to yourself'}. Changes apply from ${result.bot}'s next message.${endpoint === 'attach' ? approvals : ''}`
|
|
593
|
+
} catch (error) { return error.message }
|
|
594
|
+
},
|
|
595
|
+
}))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
Object.assign(rt, { pickRoom, hideBlockingQuestion })
|
|
425
599
|
}
|