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/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// DS Bot: a team of long-lived Bots on top of ordinary dsh root Sessions.
|
|
2
2
|
// Every Bot is one root Session; the roster, rooms, and the bot-to-bot exchange log live
|
|
3
3
|
// in one JSON file under DSH_HOME so they survive restarts without new Session event types.
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
4
|
import { join } from 'node:path'
|
|
6
5
|
import * as diagnostics from './src/host/diagnostics.js'
|
|
7
6
|
import * as store from './src/host/store.js'
|
|
@@ -10,6 +9,7 @@ import * as permissions from './src/host/permissions.js'
|
|
|
10
9
|
import * as turns from './src/host/turns.js'
|
|
11
10
|
import * as delivery from './src/host/delivery.js'
|
|
12
11
|
import * as roster from './src/host/roster.js'
|
|
12
|
+
import * as folders from './src/host/folders.js'
|
|
13
13
|
import * as agents from './src/host/agents.js'
|
|
14
14
|
import * as prompt from './src/host/prompt.js'
|
|
15
15
|
import * as timeContext from './src/host/time-context.js'
|
|
@@ -17,12 +17,14 @@ import * as tools from './src/host/tools.js'
|
|
|
17
17
|
import * as chatIndex from './src/host/chat-index.js'
|
|
18
18
|
import * as groups from './src/host/groups.js'
|
|
19
19
|
import * as approvals from './src/host/approvals.js'
|
|
20
|
+
import * as resume from './src/host/resume.js'
|
|
20
21
|
import * as memory from './src/host/memory.js'
|
|
21
22
|
import * as parts from './src/host/parts.js'
|
|
22
23
|
import * as images from './src/host/images.js'
|
|
23
24
|
import * as browser from './src/host/browser.js'
|
|
24
25
|
import * as secrets from './src/host/secrets.js'
|
|
25
26
|
import * as connectors from './src/host/connectors.js'
|
|
27
|
+
import * as capabilities from './src/host/capabilities.js'
|
|
26
28
|
import * as schedules from './src/host/schedules.js'
|
|
27
29
|
import * as usage from './src/host/usage.js'
|
|
28
30
|
import * as update from './src/host/update.js'
|
|
@@ -38,13 +40,19 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
|
|
|
38
40
|
// the functions other modules call; they call them through `rt` at run time, never during
|
|
39
41
|
// install. Listeners run and tools are listed in registration order, so this order holds:
|
|
40
42
|
// usage meters model requests after every listener that rewrites them.
|
|
41
|
-
const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, agents, prompt, timeContext, tools, chatIndex, groups, approvals, memory, parts, images, secrets, connectors, schedules, browser, usage, update, dshUpdate, channel]
|
|
43
|
+
const MODULES = [diagnostics, store, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, channel]
|
|
42
44
|
|
|
43
45
|
export function apply(ctx, config = {}) {
|
|
44
|
-
|
|
46
|
+
createRuntime(ctx, config)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createRuntime(ctx, config = {}) {
|
|
50
|
+
const dshHome = folders.dshHomeOf(process.env.DSH_HOME)
|
|
51
|
+
const home = config.home ?? join(dshHome, 'bot')
|
|
45
52
|
const rt = {
|
|
46
53
|
ctx,
|
|
47
54
|
config,
|
|
55
|
+
dshHome,
|
|
48
56
|
home,
|
|
49
57
|
statePath: join(home, 'state.json'),
|
|
50
58
|
defaults: {
|
|
@@ -53,4 +61,5 @@ export function apply(ctx, config = {}) {
|
|
|
53
61
|
},
|
|
54
62
|
}
|
|
55
63
|
for (const module of MODULES) module.install(rt)
|
|
64
|
+
return rt
|
|
56
65
|
}
|
package/package.json
CHANGED
package/src/host/approvals.js
CHANGED
|
@@ -29,11 +29,17 @@ export function install(rt) {
|
|
|
29
29
|
at: Date.now(),
|
|
30
30
|
}
|
|
31
31
|
pending.set(id, entry)
|
|
32
|
-
|
|
32
|
+
rt.noteApproval?.(sessionId, { tool: entry.tool, reason: entry.reason.slice(0, 200) })
|
|
33
|
+
const settled = () => {
|
|
34
|
+
pending.delete(id)
|
|
35
|
+
rt.noteApproval?.(sessionId, undefined)
|
|
36
|
+
}
|
|
37
|
+
if (owner === undefined) return Promise.resolve(next()).finally(settled)
|
|
33
38
|
rt.yieldRoomTurn?.(sessionId)
|
|
34
39
|
return new Promise((resolve) => {
|
|
35
40
|
entry.answer = (outcome) => {
|
|
36
|
-
if (!pending.
|
|
41
|
+
if (!pending.has(id)) return
|
|
42
|
+
settled()
|
|
37
43
|
resolve(outcome)
|
|
38
44
|
}
|
|
39
45
|
if (request.signal?.aborted) entry.answer('cancelled')
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client'
|
|
4
|
+
const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
|
5
|
+
const SECRET_REFERENCE = /\$DSH_SECRET_([A-Z][A-Z0-9_]*)/g
|
|
6
|
+
const ENSURE_TIMEOUT_MS = 15_000
|
|
7
|
+
|
|
8
|
+
export function validateCapabilityName(value) {
|
|
9
|
+
const name = String(value ?? '').trim()
|
|
10
|
+
if (name.length > 32 || !NAME.test(name) || name === 'github') {
|
|
11
|
+
throw new Error('Use a capability name with lowercase letters, digits, and single hyphens; "github" is reserved.')
|
|
12
|
+
}
|
|
13
|
+
return name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function expandCapabilityNames(capabilities, library) {
|
|
17
|
+
const expanded = new Set()
|
|
18
|
+
for (const name of capabilities ?? []) {
|
|
19
|
+
const item = library[name]
|
|
20
|
+
if (!item) throw new Error(`No capability named ${name}.`)
|
|
21
|
+
if (item.kind === 'pack') {
|
|
22
|
+
for (const childName of item.items ?? []) {
|
|
23
|
+
const child = library[childName]
|
|
24
|
+
if (!child || !['mcp', 'skill'].includes(child.kind)) throw new Error(`Pack ${name} refers to an unavailable capability ${childName}.`)
|
|
25
|
+
expanded.add(childName)
|
|
26
|
+
}
|
|
27
|
+
} else if (item.kind === 'mcp' || item.kind === 'skill') {
|
|
28
|
+
expanded.add(name)
|
|
29
|
+
} else {
|
|
30
|
+
throw new Error(`Capability ${name} has an unknown kind.`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return expanded
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
|
|
37
|
+
const strings = value => {
|
|
38
|
+
if (value === undefined) return []
|
|
39
|
+
if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) throw new Error('Arguments must be a list of text values.')
|
|
40
|
+
return [...value]
|
|
41
|
+
}
|
|
42
|
+
const stringMap = (value, label) => {
|
|
43
|
+
if (value === undefined) return {}
|
|
44
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)
|
|
45
|
+
|| Object.values(value).some(item => typeof item !== 'string')) throw new Error(`${label} must contain text values.`)
|
|
46
|
+
return Object.fromEntries(Object.entries(value))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function install(rt) {
|
|
50
|
+
const { ctx, state } = rt
|
|
51
|
+
const agents = new Map()
|
|
52
|
+
// Mounted fibers live in each Agent's own context, so they outlive this plugin unless
|
|
53
|
+
// its cleanup disposes them; a strong map keeps them reachable for that.
|
|
54
|
+
const mounts = new Map()
|
|
55
|
+
// One reconcile chain per Agent: a slow MCP server holds up only its own Agent.
|
|
56
|
+
const chains = new WeakMap()
|
|
57
|
+
const queued = new WeakSet()
|
|
58
|
+
const sessionStatuses = new Map()
|
|
59
|
+
let pluginPromise
|
|
60
|
+
let scheduled = null
|
|
61
|
+
let loadingSecrets = 0
|
|
62
|
+
let stopped = false
|
|
63
|
+
|
|
64
|
+
const library = () => state.capabilities.items
|
|
65
|
+
const ensureLibrary = () => {
|
|
66
|
+
state.capabilities ??= { items: {} }
|
|
67
|
+
state.capabilities.items ??= {}
|
|
68
|
+
return library()
|
|
69
|
+
}
|
|
70
|
+
const writeCheck = () => {
|
|
71
|
+
const readOnly = rt.readOnly()
|
|
72
|
+
if (readOnly !== undefined) throw new Error(readOnly)
|
|
73
|
+
}
|
|
74
|
+
const allowedToEdit = (item, botId) => rt.isMain(botId) || item.createdBy === botId
|
|
75
|
+
const usedByOthers = (name, botId) => Object.values(state.bots).some(bot => bot.id !== botId
|
|
76
|
+
&& (bot.capabilities ?? []).some(attached => attached === name
|
|
77
|
+
|| (library()[attached]?.kind === 'pack' && (library()[attached].items ?? []).includes(name))))
|
|
78
|
+
// A creator that is not a Main Bot could otherwise rewrite what a Main Bot runs.
|
|
79
|
+
const editCheck = (item, botId, verb) => {
|
|
80
|
+
if (!allowedToEdit(item, botId)) throw new Error(`Only ${item.createdBy ? 'its creator or a Main Bot' : 'a Main Bot'} can ${verb} ${item.name}.`)
|
|
81
|
+
if (!rt.isMain(botId) && usedByOthers(item.name, botId)) throw new Error(`Another Bot uses ${item.name}, so only a Main Bot can ${verb} it.`)
|
|
82
|
+
}
|
|
83
|
+
const itemInput = (body, { botId, ui }) => {
|
|
84
|
+
const name = validateCapabilityName(body.name)
|
|
85
|
+
const kind = body.kind
|
|
86
|
+
if (!['mcp', 'skill', 'pack'].includes(kind)) throw new Error('Choose an MCP server, skill, or pack.')
|
|
87
|
+
const items = ensureLibrary()
|
|
88
|
+
const previous = items[name]
|
|
89
|
+
if (previous && !ui) editCheck(previous, botId, 'change')
|
|
90
|
+
if (previous && previous.kind !== kind) throw new Error('A capability cannot change its kind. Delete it and save a new one.')
|
|
91
|
+
const now = Date.now()
|
|
92
|
+
const item = {
|
|
93
|
+
name,
|
|
94
|
+
kind,
|
|
95
|
+
description: String(body.description ?? '').trim().slice(0, 2000),
|
|
96
|
+
createdBy: previous?.createdBy ?? (ui ? null : botId),
|
|
97
|
+
createdAt: previous?.createdAt ?? now,
|
|
98
|
+
updatedAt: now,
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'mcp') {
|
|
101
|
+
const transport = body.transport === 'streamable-http' ? 'streamable-http' : 'stdio'
|
|
102
|
+
if (transport === 'stdio') {
|
|
103
|
+
const command = String(body.command ?? '').trim()
|
|
104
|
+
if (command === '') throw new Error('Enter a command for the MCP server.')
|
|
105
|
+
const args = strings(body.args)
|
|
106
|
+
const env = stringMap(body.env, 'Environment variables')
|
|
107
|
+
const changed = !previous || previous.kind !== 'mcp' || previous.transport !== 'stdio'
|
|
108
|
+
|| previous.command !== command || !same(previous.args ?? [], args) || !same(previous.env ?? {}, env)
|
|
109
|
+
Object.assign(item, { transport, command, args, env, approved: ui ? true : changed ? false : previous.approved === true })
|
|
110
|
+
} else {
|
|
111
|
+
const url = String(body.url ?? '').trim()
|
|
112
|
+
try {
|
|
113
|
+
const parsed = new URL(url)
|
|
114
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') throw new Error()
|
|
115
|
+
} catch { throw new Error('Enter a valid HTTP or HTTPS URL.') }
|
|
116
|
+
Object.assign(item, { transport, url, headers: stringMap(body.headers, 'Headers'), approved: true })
|
|
117
|
+
}
|
|
118
|
+
} else if (kind === 'skill') {
|
|
119
|
+
item.content = String(body.content ?? '').trim()
|
|
120
|
+
} else {
|
|
121
|
+
const names = [...new Set(strings(body.items))]
|
|
122
|
+
for (const childName of names) {
|
|
123
|
+
const child = items[childName]
|
|
124
|
+
if (!child || !['mcp', 'skill'].includes(child.kind)) throw new Error(`Packs can include only saved MCP servers and skills. ${childName} is not one.`)
|
|
125
|
+
}
|
|
126
|
+
item.items = names
|
|
127
|
+
}
|
|
128
|
+
items[name] = item
|
|
129
|
+
return item
|
|
130
|
+
}
|
|
131
|
+
const capabilitySave = async (body, options = {}) => {
|
|
132
|
+
await rt.load()
|
|
133
|
+
writeCheck()
|
|
134
|
+
const item = itemInput(body ?? {}, options)
|
|
135
|
+
await rt.save()
|
|
136
|
+
return item
|
|
137
|
+
}
|
|
138
|
+
const capabilityDelete = async (name, { botId, ui } = {}) => {
|
|
139
|
+
await rt.load()
|
|
140
|
+
writeCheck()
|
|
141
|
+
const items = ensureLibrary()
|
|
142
|
+
const item = items[String(name ?? '')]
|
|
143
|
+
if (!item) throw new Error(`No capability named ${name}.`)
|
|
144
|
+
if (!ui) editCheck(item, botId, 'delete')
|
|
145
|
+
delete items[item.name]
|
|
146
|
+
for (const pack of Object.values(items)) if (pack.kind === 'pack') pack.items = (pack.items ?? []).filter(child => child !== item.name)
|
|
147
|
+
for (const bot of Object.values(state.bots)) bot.capabilities = (bot.capabilities ?? []).filter(attached => attached !== item.name)
|
|
148
|
+
await rt.save()
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
const changeAttachment = async (endpoint, { name, bot: targetRef }, callerId) => {
|
|
152
|
+
await rt.load()
|
|
153
|
+
writeCheck()
|
|
154
|
+
const caller = rt.botOf(callerId)
|
|
155
|
+
if (!caller) throw new Error('Only Bots on the team can change capabilities.')
|
|
156
|
+
const target = targetRef ? rt.findBot(targetRef) : caller
|
|
157
|
+
if (!target) throw new Error(`No Bot named ${targetRef}.`)
|
|
158
|
+
if (target.id !== caller.id && !rt.isMain(callerId)) throw new Error('Only a Main Bot can change another Bot’s capabilities.')
|
|
159
|
+
const item = ensureLibrary()[String(name ?? '')]
|
|
160
|
+
if (!item) throw new Error(`No capability named ${name}.`)
|
|
161
|
+
await setAttached(target, item.name, endpoint === 'attach')
|
|
162
|
+
return { bot: target.name, attached: endpoint === 'attach' }
|
|
163
|
+
}
|
|
164
|
+
// Changes one name against the saved list, so two quick changes both land.
|
|
165
|
+
const setAttached = async (bot, name, attached) => {
|
|
166
|
+
bot.capabilities ??= []
|
|
167
|
+
const wasEmpty = bot.capabilities.length === 0
|
|
168
|
+
if (attached && !bot.capabilities.includes(name)) bot.capabilities.push(name)
|
|
169
|
+
if (!attached) bot.capabilities = bot.capabilities.filter(entry => entry !== name)
|
|
170
|
+
await rt.save()
|
|
171
|
+
if (wasEmpty && bot.capabilities.length > 0) {
|
|
172
|
+
await trackBotAgents(bot)
|
|
173
|
+
void scheduleReconcile()
|
|
174
|
+
}
|
|
175
|
+
return bot.capabilities
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const statusSet = (botId, itemName, sessionId, status) => {
|
|
179
|
+
let byItem = sessionStatuses.get(botId)
|
|
180
|
+
if (!byItem) sessionStatuses.set(botId, byItem = new Map())
|
|
181
|
+
let bySession = byItem.get(itemName)
|
|
182
|
+
if (!bySession) byItem.set(itemName, bySession = new Map())
|
|
183
|
+
bySession.set(sessionId, status)
|
|
184
|
+
}
|
|
185
|
+
const clearStatus = (botId, itemName, sessionId) => {
|
|
186
|
+
const byItem = sessionStatuses.get(botId)
|
|
187
|
+
const bySession = byItem?.get(itemName)
|
|
188
|
+
bySession?.delete(sessionId)
|
|
189
|
+
if (bySession?.size === 0) byItem.delete(itemName)
|
|
190
|
+
if (byItem?.size === 0) sessionStatuses.delete(botId)
|
|
191
|
+
}
|
|
192
|
+
const clearSession = sessionId => {
|
|
193
|
+
for (const [botId, byItem] of sessionStatuses) {
|
|
194
|
+
for (const [name, bySession] of byItem) {
|
|
195
|
+
bySession.delete(sessionId)
|
|
196
|
+
if (bySession.size === 0) byItem.delete(name)
|
|
197
|
+
}
|
|
198
|
+
if (byItem.size === 0) sessionStatuses.delete(botId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const statusFor = (botId, name) => {
|
|
202
|
+
const values = [...(sessionStatuses.get(botId)?.get(name)?.values() ?? [])]
|
|
203
|
+
if (values.some(status => status.state === 'error')) return values.find(status => status.state === 'error')
|
|
204
|
+
if (values.some(status => status.state === 'needs-approval')) return { state: 'needs-approval' }
|
|
205
|
+
if (values.some(status => status.state === 'connecting')) return { state: 'connecting' }
|
|
206
|
+
return values.length > 0 ? { state: 'ready' } : undefined
|
|
207
|
+
}
|
|
208
|
+
const capabilityStatusesView = () => {
|
|
209
|
+
const result = {}
|
|
210
|
+
for (const bot of Object.values(state.bots)) {
|
|
211
|
+
const statuses = {}
|
|
212
|
+
for (const name of bot.capabilities ?? []) {
|
|
213
|
+
const item = library()[name]
|
|
214
|
+
if (!item) continue
|
|
215
|
+
if (item.kind !== 'pack') {
|
|
216
|
+
const status = statusFor(bot.id, name)
|
|
217
|
+
if (status) statuses[name] = status
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
const children = (item.items ?? []).map(child => statusFor(bot.id, child)).filter(Boolean)
|
|
221
|
+
const status = children.find(status => status.state === 'error')
|
|
222
|
+
?? children.find(status => status.state === 'needs-approval')
|
|
223
|
+
?? children.find(status => status.state === 'connecting')
|
|
224
|
+
?? children[0]
|
|
225
|
+
if (status) statuses[name] = status
|
|
226
|
+
}
|
|
227
|
+
result[bot.id] = statuses
|
|
228
|
+
}
|
|
229
|
+
return result
|
|
230
|
+
}
|
|
231
|
+
const libraryView = () => Object.values(library()).map(item => ({
|
|
232
|
+
...item,
|
|
233
|
+
...(item.env ? { env: { ...item.env } } : {}),
|
|
234
|
+
...(item.headers ? { headers: { ...item.headers } } : {}),
|
|
235
|
+
...(item.items ? { items: [...item.items] } : {}),
|
|
236
|
+
}))
|
|
237
|
+
|
|
238
|
+
async function loadMcpPlugin() {
|
|
239
|
+
if (!pluginPromise) {
|
|
240
|
+
pluginPromise = (async () => {
|
|
241
|
+
const loader = ctx.get?.('loader')
|
|
242
|
+
if (typeof loader?.import !== 'function') throw new Error('This DSH cannot load MCP servers.')
|
|
243
|
+
const exports = await loader.import(MCP_CLIENT)
|
|
244
|
+
return loader.unwrapExports?.(exports) ?? exports?.default ?? exports
|
|
245
|
+
})()
|
|
246
|
+
}
|
|
247
|
+
return pluginPromise
|
|
248
|
+
}
|
|
249
|
+
function resolveValues(values, botId) {
|
|
250
|
+
const resolved = {}
|
|
251
|
+
for (const [key, raw] of Object.entries(values ?? {})) {
|
|
252
|
+
resolved[key] = String(raw).replace(SECRET_REFERENCE, (_match, name) => {
|
|
253
|
+
if (!rt.canUseSecret(name, botId)) throw new Error(`Secret ${name} is missing or unavailable for this Bot.`)
|
|
254
|
+
const value = rt.secretValue(name)
|
|
255
|
+
if (value === undefined) throw new Error(`Secret ${name} is missing or unavailable for this Bot.`)
|
|
256
|
+
return value
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
return resolved
|
|
260
|
+
}
|
|
261
|
+
const configFor = (item, botId) => {
|
|
262
|
+
if (item.kind === 'skill') return { name: item.name, description: item.description, content: item.content }
|
|
263
|
+
if (item.transport === 'stdio') return { transport: 'stdio', serverName: item.name, command: item.command, args: item.args ?? [], env: resolveValues(item.env, botId), failOnStartupError: true }
|
|
264
|
+
return { transport: 'streamable-http', serverName: item.name, url: item.url, headers: resolveValues(item.headers, botId), failOnStartupError: true }
|
|
265
|
+
}
|
|
266
|
+
const disposeMount = async mount => {
|
|
267
|
+
if (mount?.fiber) await Promise.resolve().then(() => mount.fiber.dispose()).catch(() => {})
|
|
268
|
+
}
|
|
269
|
+
const mountOne = async (agent, bot, agentMounts, name) => {
|
|
270
|
+
const sessionId = agent.session.id
|
|
271
|
+
const item = library()[name]
|
|
272
|
+
const drop = async () => {
|
|
273
|
+
await disposeMount(agentMounts.get(name))
|
|
274
|
+
agentMounts.delete(name)
|
|
275
|
+
}
|
|
276
|
+
if (item.kind === 'mcp' && item.transport === 'stdio' && item.approved !== true) {
|
|
277
|
+
await drop()
|
|
278
|
+
statusSet(bot.id, name, sessionId, { state: 'needs-approval' })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
let config
|
|
282
|
+
try {
|
|
283
|
+
config = configFor(item, bot.id)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
await drop()
|
|
286
|
+
statusSet(bot.id, name, sessionId, { state: 'error', error: rt.redactSecrets(error.message).slice(0, 300) })
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
const key = `${name}:${createHash('sha256').update(JSON.stringify(config)).digest('hex')}`
|
|
290
|
+
if (agentMounts.get(name)?.key === key) return
|
|
291
|
+
await drop()
|
|
292
|
+
statusSet(bot.id, name, sessionId, { state: 'connecting' })
|
|
293
|
+
try {
|
|
294
|
+
const plugin = item.kind === 'skill'
|
|
295
|
+
? {
|
|
296
|
+
name: `ds-bot-capability-skill-${name}`,
|
|
297
|
+
inject: ['skills'],
|
|
298
|
+
apply(c) {
|
|
299
|
+
c.effect(() => c.skills.register({ name: config.name, description: config.description, source: 'runtime', content: config.content }))
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
: await loadMcpPlugin()
|
|
303
|
+
if (stopped) return
|
|
304
|
+
const fiber = item.kind === 'skill' ? agent.ctx.plugin(plugin) : agent.ctx.plugin(plugin, config)
|
|
305
|
+
agentMounts.set(name, { key, fiber })
|
|
306
|
+
await fiber.await()
|
|
307
|
+
if (stopped || agents.get(sessionId)?.deref() !== agent) return
|
|
308
|
+
statusSet(bot.id, name, sessionId, { state: 'ready' })
|
|
309
|
+
} catch (error) {
|
|
310
|
+
await drop()
|
|
311
|
+
const message = rt.redactSecrets([error, error?.cause].filter(Boolean).map(part => String(part?.message ?? part)).join(': ') || 'Connection failed').slice(0, 300)
|
|
312
|
+
statusSet(bot.id, name, sessionId, { state: 'error', error: message })
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const reconcileAgent = async (agent, bot) => {
|
|
316
|
+
const sessionId = agent.session.id
|
|
317
|
+
const desired = bot ? expandCapabilityNames(bot.capabilities, library()) : new Set()
|
|
318
|
+
const agentMounts = mounts.get(agent) ?? new Map()
|
|
319
|
+
if (desired.size > 0) mounts.set(agent, agentMounts)
|
|
320
|
+
if (bot) {
|
|
321
|
+
for (const name of sessionStatuses.get(bot.id)?.keys() ?? []) {
|
|
322
|
+
if (!desired.has(name)) clearStatus(bot.id, name, sessionId)
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
clearSession(sessionId)
|
|
326
|
+
}
|
|
327
|
+
for (const [name, mount] of agentMounts) {
|
|
328
|
+
if (desired.has(name)) continue
|
|
329
|
+
await disposeMount(mount)
|
|
330
|
+
agentMounts.delete(name)
|
|
331
|
+
}
|
|
332
|
+
await Promise.all([...desired].map(name => mountOne(agent, bot, agentMounts, name)))
|
|
333
|
+
if (agentMounts.size === 0 && mounts.get(agent) === agentMounts) mounts.delete(agent)
|
|
334
|
+
}
|
|
335
|
+
const botOfSession = sessionId => (rt.isBotSession(sessionId) ? rt.botOf(rt.selfOf(sessionId)) : undefined)
|
|
336
|
+
// At most one run waits behind the running one; it reads the latest state when it starts.
|
|
337
|
+
const reconcileOne = agent => {
|
|
338
|
+
if (queued.has(agent)) return chains.get(agent)
|
|
339
|
+
queued.add(agent)
|
|
340
|
+
const run = (chains.get(agent) ?? Promise.resolve()).then(async () => {
|
|
341
|
+
queued.delete(agent)
|
|
342
|
+
const sessionId = agent.session.id
|
|
343
|
+
if (stopped || agents.get(sessionId)?.deref() !== agent) return
|
|
344
|
+
await reconcileAgent(agent, botOfSession(sessionId))
|
|
345
|
+
}).catch(error => rt.warn('ds-bot: capability reconcile for %s failed: %s', agent.session?.id, error))
|
|
346
|
+
chains.set(agent, run)
|
|
347
|
+
return run
|
|
348
|
+
}
|
|
349
|
+
const loadInputs = async () => {
|
|
350
|
+
await rt.load()
|
|
351
|
+
loadingSecrets += 1
|
|
352
|
+
try {
|
|
353
|
+
await rt.loadSecrets()
|
|
354
|
+
} finally {
|
|
355
|
+
loadingSecrets -= 1
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
async function reconcile() {
|
|
359
|
+
if (stopped) return
|
|
360
|
+
await loadInputs()
|
|
361
|
+
const runs = []
|
|
362
|
+
for (const [sessionId, reference] of agents) {
|
|
363
|
+
const agent = reference.deref()
|
|
364
|
+
if (agent) {
|
|
365
|
+
runs.push(reconcileOne(agent))
|
|
366
|
+
continue
|
|
367
|
+
}
|
|
368
|
+
agents.delete(sessionId)
|
|
369
|
+
clearSession(sessionId)
|
|
370
|
+
}
|
|
371
|
+
await Promise.all(runs)
|
|
372
|
+
}
|
|
373
|
+
const scheduleReconcile = () => {
|
|
374
|
+
if (!scheduled) {
|
|
375
|
+
scheduled = new Promise(resolve => queueMicrotask(() => {
|
|
376
|
+
scheduled = null
|
|
377
|
+
resolve(reconcile().catch(error => rt.warn('ds-bot: capability reconcile failed: %s', error)))
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
return scheduled
|
|
381
|
+
}
|
|
382
|
+
// A run that outlasts the limit goes on in the background, and later requests for the
|
|
383
|
+
// same Agent do not wait for it again.
|
|
384
|
+
const stalled = new WeakSet()
|
|
385
|
+
const prepare = async (agent, sessionId) => {
|
|
386
|
+
const run = loadInputs().then(() => reconcileOne(agent)).then(() => true, error => {
|
|
387
|
+
rt.warn('ds-bot: capability mount for %s failed: %s', sessionId, error)
|
|
388
|
+
return true
|
|
389
|
+
})
|
|
390
|
+
if (stalled.has(agent)) return
|
|
391
|
+
const timeoutMs = rt.capabilityEnsureTimeoutMs ?? ENSURE_TIMEOUT_MS
|
|
392
|
+
let timer
|
|
393
|
+
const completed = await Promise.race([run, new Promise(resolve => { timer = setTimeout(resolve, timeoutMs, false) })])
|
|
394
|
+
clearTimeout(timer)
|
|
395
|
+
if (completed) return
|
|
396
|
+
stalled.add(agent)
|
|
397
|
+
void run.then(() => stalled.delete(agent))
|
|
398
|
+
rt.warn('ds-bot: capability mount for %s did not finish within %dms; continuing', sessionId, timeoutMs)
|
|
399
|
+
}
|
|
400
|
+
async function ensureCapabilities(sessionId) {
|
|
401
|
+
await rt.load()
|
|
402
|
+
const bot = botOfSession(sessionId)
|
|
403
|
+
if (!bot) return
|
|
404
|
+
if ((bot.capabilities ?? []).length === 0 && !mounts.get(agents.get(sessionId)?.deref())?.size) return
|
|
405
|
+
let agent
|
|
406
|
+
try {
|
|
407
|
+
agent = await rt.resolveAgent(sessionId)
|
|
408
|
+
} catch (error) {
|
|
409
|
+
rt.warn('ds-bot: capability Agent for %s could not be resolved: %s', sessionId, rt.redactSecrets(String(error)))
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
trackAgent(agent)
|
|
413
|
+
await prepare(agent, sessionId)
|
|
414
|
+
}
|
|
415
|
+
const trackAgent = agent => {
|
|
416
|
+
const sessionId = agent?.session?.id
|
|
417
|
+
if (typeof sessionId !== 'string' || !agent.ctx) return
|
|
418
|
+
if (agents.get(sessionId)?.deref() === agent) return
|
|
419
|
+
agents.set(sessionId, new WeakRef(agent))
|
|
420
|
+
agent.ctx.effect(() => () => {
|
|
421
|
+
if (agents.get(sessionId)?.deref() === agent) agents.delete(sessionId)
|
|
422
|
+
clearSession(sessionId)
|
|
423
|
+
for (const mount of mounts.get(agent)?.values() ?? []) void disposeMount(mount)
|
|
424
|
+
mounts.delete(agent)
|
|
425
|
+
}, `ds-bot: capabilities ${sessionId}`)
|
|
426
|
+
}
|
|
427
|
+
const trackBotAgents = async bot => {
|
|
428
|
+
const sessionIds = new Set()
|
|
429
|
+
try {
|
|
430
|
+
const sessionId = rt.chatOf(bot.id)
|
|
431
|
+
if (typeof sessionId === 'string') sessionIds.add(sessionId)
|
|
432
|
+
} catch (error) {
|
|
433
|
+
rt.warn('ds-bot: capability chat session for %s could not be read: %s', bot.id, error)
|
|
434
|
+
}
|
|
435
|
+
try {
|
|
436
|
+
for (const sessionId of rt.groupSessionsOf(bot.id) ?? []) {
|
|
437
|
+
if (typeof sessionId === 'string') sessionIds.add(sessionId)
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
rt.warn('ds-bot: capability group sessions for %s could not be read: %s', bot.id, error)
|
|
441
|
+
}
|
|
442
|
+
for (const sessionId of sessionIds) {
|
|
443
|
+
try {
|
|
444
|
+
trackAgent(await rt.resolveAgent(sessionId))
|
|
445
|
+
} catch (error) {
|
|
446
|
+
rt.warn('ds-bot: capability Agent for %s could not be resolved: %s', sessionId, error)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function capabilityEndpoint(endpoint, body = {}) {
|
|
452
|
+
if (endpoint === 'capability-save') return capabilitySave(body, { ui: true })
|
|
453
|
+
if (endpoint === 'capability-delete') return capabilityDelete(body.name, { ui: true })
|
|
454
|
+
if (endpoint === 'capability-approve') {
|
|
455
|
+
await rt.load()
|
|
456
|
+
writeCheck()
|
|
457
|
+
const item = library()[String(body.name ?? '')]
|
|
458
|
+
if (!item || item.kind !== 'mcp' || item.transport !== 'stdio') throw new Error(`No unapproved stdio MCP named ${body.name}.`)
|
|
459
|
+
item.approved = true
|
|
460
|
+
item.updatedAt = Date.now()
|
|
461
|
+
await rt.save()
|
|
462
|
+
return item
|
|
463
|
+
}
|
|
464
|
+
if (endpoint === 'bot-capability') {
|
|
465
|
+
await rt.load()
|
|
466
|
+
writeCheck()
|
|
467
|
+
const bot = rt.botOf(String(body.botId ?? ''))
|
|
468
|
+
if (!bot) throw new Error('Unknown Bot')
|
|
469
|
+
const name = String(body.name ?? '')
|
|
470
|
+
if (!library()[name]) throw new Error(`No capability named ${name}.`)
|
|
471
|
+
return setAttached(bot, name, body.attached === true)
|
|
472
|
+
}
|
|
473
|
+
throw new Error('Unknown capability operation.')
|
|
474
|
+
}
|
|
475
|
+
const secretsChanged = rt.secretsChanged
|
|
476
|
+
rt.secretsChanged = () => {
|
|
477
|
+
secretsChanged?.()
|
|
478
|
+
if (loadingSecrets === 0) void scheduleReconcile()
|
|
479
|
+
}
|
|
480
|
+
rt.capabilitiesStateSaved = () => { void scheduleReconcile() }
|
|
481
|
+
// Creation waits for these listeners, so a new Agent's first request already has its
|
|
482
|
+
// tools and skill catalog. agent/pre-step is too late: a step assembles its tools first.
|
|
483
|
+
ctx.on('agent/created', async function prepareCapabilities({ agent }) {
|
|
484
|
+
trackAgent(agent)
|
|
485
|
+
try {
|
|
486
|
+
await rt.load()
|
|
487
|
+
const bot = botOfSession(agent.session.id)
|
|
488
|
+
if ((bot?.capabilities ?? []).length > 0) await prepare(agent, agent.session.id)
|
|
489
|
+
} catch (error) {
|
|
490
|
+
rt.warn('ds-bot: capability load failed: %s', error)
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
ctx.effect(() => {
|
|
494
|
+
void rt.load().then(async () => {
|
|
495
|
+
for (const bot of Object.values(state.bots)) {
|
|
496
|
+
if ((bot.capabilities ?? []).length > 0) await trackBotAgents(bot)
|
|
497
|
+
}
|
|
498
|
+
await scheduleReconcile()
|
|
499
|
+
}).catch(error => rt.warn('ds-bot: capability startup failed: %s', error))
|
|
500
|
+
return async () => {
|
|
501
|
+
stopped = true
|
|
502
|
+
const live = [...mounts.values()]
|
|
503
|
+
mounts.clear()
|
|
504
|
+
agents.clear()
|
|
505
|
+
sessionStatuses.clear()
|
|
506
|
+
await Promise.all(live.flatMap(agentMounts => [...agentMounts.values()].map(disposeMount)))
|
|
507
|
+
}
|
|
508
|
+
}, 'ds-bot: capabilities')
|
|
509
|
+
Object.assign(rt, {
|
|
510
|
+
capabilitySave,
|
|
511
|
+
capabilityDelete,
|
|
512
|
+
capabilityEndpoint,
|
|
513
|
+
capabilityLibraryView: libraryView,
|
|
514
|
+
capabilityStatusesView,
|
|
515
|
+
reconcileCapabilities: reconcile,
|
|
516
|
+
scheduleCapabilitiesReconcile: scheduleReconcile,
|
|
517
|
+
ensureCapabilities,
|
|
518
|
+
expandCapabilities: names => expandCapabilityNames(names, library()),
|
|
519
|
+
changeCapabilityAttachment: changeAttachment,
|
|
520
|
+
})
|
|
521
|
+
}
|