ds-bot 0.1.1 → 0.1.3
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 -5
- package/client.js +1311 -466
- package/index.js +3 -1
- package/package.json +6 -7
- package/src/host/approvals.js +63 -0
- package/src/host/channel.js +14 -3
- package/src/host/constants.js +4 -1
- package/src/host/delivery.js +19 -7
- package/src/host/diagnostics.js +7 -5
- package/src/host/dsh-update.js +8 -6
- package/src/host/groups.js +195 -47
- package/src/host/home-lock.js +16 -10
- package/src/host/images.js +37 -0
- package/src/host/memory.js +27 -16
- package/src/host/parts.js +92 -21
- package/src/host/prompt.js +6 -2
- package/src/host/roster.js +109 -56
- package/src/host/store.js +113 -28
- package/src/host/time-context.js +46 -0
- package/src/host/tools.js +79 -28
- package/src/host/turns.js +176 -38
- package/src/host/update.js +31 -4
- package/src/shared/mentions.js +65 -0
- package/web-search.js +23 -7
package/src/host/store.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// host on the same data from writing it, and the indexes that map a Session to the Bot
|
|
3
3
|
// or group chat it belongs to.
|
|
4
4
|
import { mkdirSync, statSync } from 'node:fs'
|
|
5
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { LEGACY_THEMES, MAX_EXCHANGES, ROOM_MODES } from './constants.js'
|
|
8
8
|
import { homeLock } from './home-lock.js'
|
|
@@ -22,7 +22,9 @@ export function install(rt) {
|
|
|
22
22
|
// undefined while this host may write.
|
|
23
23
|
let holder
|
|
24
24
|
let lockBroken = false
|
|
25
|
+
let stuck
|
|
25
26
|
let warnedReadOnly = false
|
|
27
|
+
let stopped = false
|
|
26
28
|
// mtime and size of the state.json this host last read or wrote.
|
|
27
29
|
let seen = ''
|
|
28
30
|
const stamp = () => {
|
|
@@ -40,27 +42,56 @@ export function install(rt) {
|
|
|
40
42
|
mkdirSync(home, { recursive: true })
|
|
41
43
|
holder = lock.claim()
|
|
42
44
|
} catch (error) {
|
|
43
|
-
// An unusable lock (a read-only or odd file system) must not stop the team
|
|
45
|
+
// An unusable lock (a read-only or odd file system) must not stop the team; a
|
|
46
|
+
// save that then cannot write still rejects.
|
|
44
47
|
lockBroken = true
|
|
45
48
|
holder = undefined
|
|
46
49
|
warn('ds-bot: cannot lock %s, so another dsh on the same data could undo changes: %s', home, error)
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
const readOnly = () => {
|
|
53
|
+
if (stuck !== undefined) return `The Bot team file ${statePath} could not be read (${stuck}). DS Bot does not change it; fix or move the file and reload.`
|
|
50
54
|
if (holder === undefined) return undefined
|
|
51
55
|
const where = holder.pid ? `pid ${holder.pid}${holder.host ? ` on ${holder.host}` : ''}` : 'unknown process'
|
|
52
56
|
return `The Bot team in ${home} is open in another dsh (Web or Desktop, ${where}). This window shows the team but cannot change it; close the other one and reload.`
|
|
53
57
|
}
|
|
54
58
|
|
|
55
59
|
const readFileState = async () => {
|
|
60
|
+
let contents
|
|
56
61
|
try {
|
|
62
|
+
contents = await readFile(statePath, 'utf8')
|
|
63
|
+
} catch (error) {
|
|
57
64
|
seen = stamp()
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
if (error?.code === 'ENOENT') {
|
|
66
|
+
stuck = undefined
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
stuck = error?.code ?? error?.message ?? String(error)
|
|
70
|
+
warn('ds-bot: unreadable state %s: %s', statePath, error)
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
seen = stamp()
|
|
74
|
+
let parsed
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(contents)
|
|
60
77
|
} catch (error) {
|
|
61
|
-
|
|
78
|
+
const backup = `${statePath}.broken-${Date.now()}`
|
|
79
|
+
try {
|
|
80
|
+
await copyFile(statePath, backup)
|
|
81
|
+
stuck = undefined
|
|
82
|
+
warn('ds-bot: unreadable state %s: %s; backed up as %s', statePath, error, backup)
|
|
83
|
+
} catch (copyError) {
|
|
84
|
+
stuck = copyError?.code ?? copyError?.message ?? error?.message ?? String(error)
|
|
85
|
+
warn('ds-bot: unreadable state %s: %s; backup failed: %s', statePath, error, copyError)
|
|
86
|
+
}
|
|
62
87
|
return undefined
|
|
63
88
|
}
|
|
89
|
+
if (parsed?.version === 1) {
|
|
90
|
+
stuck = undefined
|
|
91
|
+
return parsed
|
|
92
|
+
}
|
|
93
|
+
stuck = 'saved by a newer DS Bot; update DS Bot'
|
|
94
|
+
return undefined
|
|
64
95
|
}
|
|
65
96
|
|
|
66
97
|
// Fill `state` from the saved file and bring an older team up to date. Returns the
|
|
@@ -129,7 +160,15 @@ export function install(rt) {
|
|
|
129
160
|
if (holder !== undefined) warn('ds-bot: %s', readOnly())
|
|
130
161
|
const { migrated, stale } = adopt(await readFileState())
|
|
131
162
|
if (holder !== undefined) return
|
|
132
|
-
if (migrated
|
|
163
|
+
if (migrated && stuck === undefined) {
|
|
164
|
+
// A failed write must not poison `loaded`: the migrated state stays in
|
|
165
|
+
// memory and the next save() writes it.
|
|
166
|
+
try {
|
|
167
|
+
await save()
|
|
168
|
+
} catch (error) {
|
|
169
|
+
warn('ds-bot: saving the migrated Bot team failed: %s', error)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
133
172
|
// Not awaited: renaming a session must not hold up the first roster read.
|
|
134
173
|
for (const room of stale) void rt.retitleRoom(room)
|
|
135
174
|
})()
|
|
@@ -137,18 +176,42 @@ export function install(rt) {
|
|
|
137
176
|
// A reading host follows the file and takes over once the other host has gone.
|
|
138
177
|
const refresh = async () => {
|
|
139
178
|
await load()
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
179
|
+
const hadHolder = holder !== undefined
|
|
180
|
+
if (hadHolder) {
|
|
181
|
+
claimLock()
|
|
182
|
+
if (holder === undefined) {
|
|
183
|
+
warn('ds-bot: the other dsh has closed; this one now saves the Bot team')
|
|
184
|
+
warnedReadOnly = false
|
|
185
|
+
adopt(await readFileState())
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
} else if (!lockBroken && !lock.owns()) {
|
|
189
|
+
// A cached "writer" is stale once the lock file no longer names this host:
|
|
190
|
+
// another dsh may have taken it over, or the file may just be gone. Re-claim
|
|
191
|
+
// and re-check instead of trusting the flag.
|
|
192
|
+
claimLock()
|
|
193
|
+
if (lockBroken) return
|
|
194
|
+
if (holder === undefined) {
|
|
195
|
+
// The lock was free: still the writer, but the file may carry changes
|
|
196
|
+
// written while no host held it.
|
|
197
|
+
warnedReadOnly = false
|
|
198
|
+
adopt(await readFileState())
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
warn('ds-bot: %s', readOnly())
|
|
147
202
|
}
|
|
148
|
-
if (stamp() !== seen) adopt(await readFileState())
|
|
203
|
+
if ((stuck !== undefined || holder !== undefined) && stamp() !== seen) adopt(await readFileState())
|
|
149
204
|
}
|
|
150
205
|
|
|
206
|
+
const warnReadOnly = () => {
|
|
207
|
+
if (!warnedReadOnly) {
|
|
208
|
+
warnedReadOnly = true
|
|
209
|
+
warn('ds-bot: not saving the Bot team: %s', readOnly())
|
|
210
|
+
}
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
151
213
|
const writable = () => {
|
|
214
|
+
if (stuck !== undefined) return warnReadOnly()
|
|
152
215
|
if (lockBroken) return true
|
|
153
216
|
if (holder === undefined && lock.owns()) return true
|
|
154
217
|
// Lost the lock: retake it when nobody else holds it (its file was deleted), else
|
|
@@ -157,31 +220,50 @@ export function install(rt) {
|
|
|
157
220
|
claimLock()
|
|
158
221
|
if (holder === undefined) return true
|
|
159
222
|
}
|
|
160
|
-
|
|
161
|
-
warnedReadOnly = true
|
|
162
|
-
warn('ds-bot: not saving the Bot team: %s', readOnly())
|
|
163
|
-
}
|
|
164
|
-
return false
|
|
223
|
+
return warnReadOnly()
|
|
165
224
|
}
|
|
166
225
|
|
|
167
226
|
const save = () => {
|
|
227
|
+
if (stopped) return writing
|
|
168
228
|
indexSessions()
|
|
169
229
|
// A reading host keeps the file's revision, so its window does not skip the next change.
|
|
170
230
|
if (!writable()) return writing
|
|
171
231
|
state.revision += 1
|
|
172
232
|
state.exchanges = state.exchanges.slice(-MAX_EXCHANGES)
|
|
173
233
|
const snapshot = JSON.stringify(state, null, 2)
|
|
174
|
-
|
|
175
|
-
await mkdir(home, { recursive: true })
|
|
234
|
+
const run = writing.then(async () => {
|
|
176
235
|
const temp = `${statePath}.${process.pid}.tmp`
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
236
|
+
try {
|
|
237
|
+
await mkdir(home, { recursive: true })
|
|
238
|
+
const ensureLock = () => {
|
|
239
|
+
if (lockBroken) return
|
|
240
|
+
if (!lock.owns()) {
|
|
241
|
+
const error = new Error('ds-bot lost the state lock to another dsh')
|
|
242
|
+
error.code = 'ELOCKLOST'
|
|
243
|
+
throw error
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
ensureLock()
|
|
247
|
+
await writeFile(temp, snapshot, 'utf8')
|
|
248
|
+
ensureLock()
|
|
249
|
+
await rename(temp, statePath)
|
|
250
|
+
seen = stamp()
|
|
251
|
+
} catch (error) {
|
|
252
|
+
await unlink(temp).catch(() => {})
|
|
253
|
+
throw error
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
writing = run.catch(() => {})
|
|
257
|
+
return run.catch(error => {
|
|
258
|
+
warn('ds-bot: state write failed: %s', error)
|
|
259
|
+
throw error
|
|
260
|
+
})
|
|
182
261
|
}
|
|
183
262
|
|
|
184
|
-
ctx.effect(() => () =>
|
|
263
|
+
ctx.effect(() => () => {
|
|
264
|
+
stopped = true
|
|
265
|
+
return writing.finally(() => lock.release())
|
|
266
|
+
}, 'ds-bot: state lock')
|
|
185
267
|
|
|
186
268
|
const botOf = id => (id === undefined ? undefined : state.bots[id])
|
|
187
269
|
const roomOf = id => (id === undefined ? undefined : state.rooms[id])
|
|
@@ -233,7 +315,10 @@ export function install(rt) {
|
|
|
233
315
|
delete room.sessions[botId]
|
|
234
316
|
if (room.segments !== undefined) delete room.segments[botId]
|
|
235
317
|
groupOwners.delete(sessionId)
|
|
236
|
-
|
|
318
|
+
rt.cancelApprovals?.(sessionId)
|
|
319
|
+
// A plain archive refuses a running Session, which would go on working for a group
|
|
320
|
+
// that no longer has it.
|
|
321
|
+
void ctx.workspaceRegistry.archiveSession(sessionId, { stopActivity: true }).catch(() => {})
|
|
237
322
|
}
|
|
238
323
|
const findBot = (ref) => {
|
|
239
324
|
if (ref === undefined) return undefined
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
2
|
+
|
|
3
|
+
const SOURCE = 'ds-bot-time-context'
|
|
4
|
+
const isClock = source => source?.kind === SOURCE || source?.kind === 'time-context'
|
|
5
|
+
|
|
6
|
+
export function install(rt) {
|
|
7
|
+
const { ctx, config } = rt
|
|
8
|
+
const interval = config.timeRefreshIntervalMs ?? 600_000
|
|
9
|
+
if (!Number.isSafeInteger(interval) || interval < 0) throw new TypeError('timeRefreshIntervalMs must be a non-negative safe integer')
|
|
10
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
11
|
+
...(config.timeZone ? { timeZone: config.timeZone } : {}),
|
|
12
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
13
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
14
|
+
timeZoneName: 'longOffset',
|
|
15
|
+
})
|
|
16
|
+
const zone = formatter.resolvedOptions().timeZone
|
|
17
|
+
|
|
18
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
19
|
+
const decision = await next()
|
|
20
|
+
if (decision.kind === 'reject' || signal.aborted) return decision
|
|
21
|
+
await rt.load()
|
|
22
|
+
if (!rt.isBotSession(agent.id) || signal.aborted) return decision
|
|
23
|
+
// A loaded DSH clock covers every step itself. Its listener may run before or
|
|
24
|
+
// after this one (a reload reorders them), so its presence is read from its
|
|
25
|
+
// projection, not from the messages added so far.
|
|
26
|
+
if (ctx.get?.('sessionProjections')?.stateOf(agent.session, 'timeContext') !== undefined) return decision
|
|
27
|
+
if (decision.messages.some(message => isClock(message.source))) return decision
|
|
28
|
+
const now = Date.now()
|
|
29
|
+
for await (const event of rt.eventsBackwards(agent.id, signal, Infinity)) {
|
|
30
|
+
if (event.type === 'user/message' && isClock(event.data.source)) {
|
|
31
|
+
if (now >= event.time && now - event.time < interval) return decision
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
if (event.time <= now - interval) break
|
|
35
|
+
}
|
|
36
|
+
if (signal.aborted) return decision
|
|
37
|
+
const text = `Current time: ${formatter.format(now)} (${zone}).`
|
|
38
|
+
return {
|
|
39
|
+
...decision,
|
|
40
|
+
messages: [...decision.messages, createUserMessage({
|
|
41
|
+
content: [{ type: 'text', text }],
|
|
42
|
+
source: { kind: SOURCE, form: 'snapshot', sections: [{ name: SOURCE, text }] },
|
|
43
|
+
})],
|
|
44
|
+
}
|
|
45
|
+
}, { prepend: true })
|
|
46
|
+
}
|
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
|
|
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) }] }
|
|
@@ -58,9 +57,21 @@ export function install(rt) {
|
|
|
58
57
|
},
|
|
59
58
|
}))
|
|
60
59
|
|
|
60
|
+
// Sessions a Bot is running in right now: its own chat plus each group Session
|
|
61
|
+
// with a live turn. An idle Bot reports just its own chat.
|
|
62
|
+
const activeSessions = (botId) => {
|
|
63
|
+
const running = [...new Set([rt.chatOf(botId), ...rt.groupSessionsOf(botId)])]
|
|
64
|
+
.filter(sessionId => rt.live.get(sessionId)?.turn !== undefined)
|
|
65
|
+
return { running, idle: running.length === 0 }
|
|
66
|
+
}
|
|
67
|
+
const whereOf = (sessionId) => {
|
|
68
|
+
const room = rt.roomOf(rt.groupOwners.get(sessionId)?.roomId)
|
|
69
|
+
return room ? `group "${room.name}"` : 'its own chat'
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
ctx.tools.register(defineTool({
|
|
62
73
|
name: 'message_bot',
|
|
63
|
-
description: 'Send
|
|
74
|
+
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
75
|
parameters: {
|
|
65
76
|
to: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
|
|
66
77
|
message: { type: 'string', required: true, description: 'Self-contained message; the other Bot cannot see this conversation' },
|
|
@@ -69,6 +80,11 @@ export function install(rt) {
|
|
|
69
80
|
enum: ['me', 'user'],
|
|
70
81
|
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
82
|
},
|
|
83
|
+
when: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
enum: ['now', 'queue'],
|
|
86
|
+
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.',
|
|
87
|
+
},
|
|
72
88
|
},
|
|
73
89
|
output,
|
|
74
90
|
async execute(args, exec) {
|
|
@@ -78,13 +94,56 @@ export function install(rt) {
|
|
|
78
94
|
const target = rt.findBot(args.to)
|
|
79
95
|
if (target === undefined) return `No Bot named ${args.to}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`
|
|
80
96
|
if (target.id === sender.id) return 'You cannot message yourself.'
|
|
81
|
-
const
|
|
97
|
+
const turn = rt.live.get(exec.agent?.id)
|
|
98
|
+
const hop = Math.max(turn?.info?.hop ?? 0, ...(turn?.joined ?? []).map(joined => joined.info?.hop ?? 0))
|
|
82
99
|
if (hop >= MAX_HOPS) return 'This chain of Bot messages is already long. Stop here and report to the user instead.'
|
|
83
100
|
const toUser = args.reply_to === 'user'
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
const working = rt.live.get(rt.chatOf(target.id))?.turn !== undefined
|
|
102
|
+
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' } : {}) })
|
|
104
|
+
const sent = toUser
|
|
86
105
|
? `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
|
-
:
|
|
106
|
+
: working && steer
|
|
107
|
+
? `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.`
|
|
108
|
+
: working
|
|
109
|
+
? `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
|
+
: `Delivered to ${target.name}. Its reply will arrive later as a new message; do not wait for it.`
|
|
111
|
+
// 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 !== rt.chatOf(target.id))
|
|
113
|
+
if (groups.length === 0) return sent
|
|
114
|
+
const stop = rt.isMain(sender.id) ? ' If it must not go on there, use stop_bot.' : ''
|
|
115
|
+
return `${sent} ${target.name} is also working in ${groups.map(whereOf).join(' and ')}; your message does not reach that work.${stop}`
|
|
116
|
+
},
|
|
117
|
+
}))
|
|
118
|
+
|
|
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
|
+
ctx.tools.register(defineTool({
|
|
124
|
+
name: 'stop_bot',
|
|
125
|
+
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.',
|
|
126
|
+
parameters: {
|
|
127
|
+
bot: { type: 'string', required: true, description: 'Exact Bot name from list_bots' },
|
|
128
|
+
reason: { type: 'string', description: 'Why it was stopped; it reads this afterwards' },
|
|
129
|
+
},
|
|
130
|
+
output,
|
|
131
|
+
async execute(args, exec) {
|
|
132
|
+
await rt.load()
|
|
133
|
+
const sender = callerBot(exec)
|
|
134
|
+
if (sender === undefined || !rt.isMain(sender.id)) refuse('Only a Main Bot can stop other Bots.')
|
|
135
|
+
const target = rt.findBot(args.bot)
|
|
136
|
+
if (target === undefined) refuse(`No Bot named ${args.bot}. Team: ${Object.values(state.bots).map(bot => bot.name).join(', ')}`)
|
|
137
|
+
if (target.id === sender.id) refuse('You cannot stop yourself.')
|
|
138
|
+
const { running } = activeSessions(target.id)
|
|
139
|
+
if (running.length === 0) refuse(`${target.name} is not running right now, so there is nothing to stop. Use message_bot to reach it.`)
|
|
140
|
+
for (const sessionId of running) {
|
|
141
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
142
|
+
agent.cancel({ kind: 'hook', reason: 'ds-bot/stopped' })
|
|
143
|
+
}
|
|
144
|
+
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' })
|
|
146
|
+
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
147
|
},
|
|
89
148
|
}))
|
|
90
149
|
|
|
@@ -113,9 +172,9 @@ export function install(rt) {
|
|
|
113
172
|
|
|
114
173
|
ctx.tools.register(defineTool({
|
|
115
174
|
name: 'update_bot',
|
|
116
|
-
description: '
|
|
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.',
|
|
117
176
|
parameters: {
|
|
118
|
-
bot: { type: 'string',
|
|
177
|
+
bot: { type: 'string', description: 'Current name of the Bot to change; leave out to change yourself' },
|
|
119
178
|
name: { type: 'string', description: 'New name' },
|
|
120
179
|
role: { type: 'string', description: 'New role label' },
|
|
121
180
|
instructions: { type: 'string', description: 'Replacement instructions' },
|
|
@@ -125,9 +184,12 @@ export function install(rt) {
|
|
|
125
184
|
output,
|
|
126
185
|
async execute(args, exec) {
|
|
127
186
|
await rt.load()
|
|
128
|
-
|
|
129
|
-
const
|
|
187
|
+
const self = rt.selfOf(exec.agent?.id)
|
|
188
|
+
const caller = rt.botOf(self)
|
|
189
|
+
if (caller === undefined) return 'Only Bots on the team can use update_bot.'
|
|
190
|
+
const bot = args.bot ? rt.findBot(args.bot) : caller
|
|
130
191
|
if (bot === undefined) return `No Bot named ${args.bot}.`
|
|
192
|
+
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
193
|
try {
|
|
132
194
|
const { name: newName, role, instructions, color, model } = args
|
|
133
195
|
const next = await rt.updateBot(bot.id, { name: newName, role, instructions, color, ...(model ? { model } : {}) })
|
|
@@ -294,22 +356,7 @@ export function install(rt) {
|
|
|
294
356
|
await rt.save()
|
|
295
357
|
const reportBack = args.report_back === true
|
|
296
358
|
try {
|
|
297
|
-
|
|
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)
|
|
359
|
+
await rt.postToRoom(room, sender, text, { hop: info?.hop ?? 0, ...(reportBack ? { reportBack: true } : {}) })
|
|
313
360
|
} catch (failure) {
|
|
314
361
|
return `Could not post in "${room.name}": ${failure.message}`
|
|
315
362
|
}
|
|
@@ -354,9 +401,13 @@ export function install(rt) {
|
|
|
354
401
|
.filter(option => option.label !== '')
|
|
355
402
|
.slice(0, 6)
|
|
356
403
|
if (options.length === 0) return 'Give at least one option.'
|
|
404
|
+
// A hidden Bot-to-Bot turn shows none of its calls, so its card goes to the
|
|
405
|
+
// composer dock, which takes questions without a call.
|
|
406
|
+
const turn = rt.live.get(id)?.turn
|
|
407
|
+
const hidden = turn !== undefined && (state.exchangeTurns[id] ?? []).includes(turn)
|
|
357
408
|
state.questions[id] = {
|
|
358
409
|
id: randomUUID(),
|
|
359
|
-
...(exec.callId ? { callId: exec.callId } : {}),
|
|
410
|
+
...(exec.callId && !hidden ? { callId: exec.callId } : {}),
|
|
360
411
|
question: String(args.question ?? '').trim(),
|
|
361
412
|
...(args.detail ? { detail: String(args.detail).trim() } : {}),
|
|
362
413
|
options,
|