ds-bot 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/client.js +1456 -1039
- package/index.js +8 -1
- package/package.json +1 -1
- package/src/host/approvals.js +8 -2
- package/src/host/capabilities.js +521 -0
- package/src/host/channel.js +16 -3
- package/src/host/constants.js +8 -0
- package/src/host/delivery.js +20 -6
- package/src/host/groups.js +119 -30
- package/src/host/parts.js +2 -0
- package/src/host/prompt.js +1 -1
- package/src/host/resume.js +247 -0
- package/src/host/roster.js +1 -0
- package/src/host/secrets.js +9 -1
- package/src/host/store.js +16 -3
- package/src/host/tools.js +105 -3
- package/src/host/turns.js +93 -7
package/src/host/delivery.js
CHANGED
|
@@ -12,8 +12,11 @@ export function install(rt) {
|
|
|
12
12
|
return resolved.agent
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false) => {
|
|
15
|
+
const headerFor = (role, fromName, roomName, audience, note = 'new message', deferred = false, { resumed = false, retried = false } = {}) => {
|
|
16
16
|
if (deferred) return `[Message from ${fromName} · you read it during your last task; answer it now]`
|
|
17
|
+
if (retried && role !== 'room') return '[Your last reply wrote a tool call as text, so nothing ran]'
|
|
18
|
+
if (resumed && role === 'resume') return '[DeepSeek Harness restarted while you were working]'
|
|
19
|
+
if (resumed && role !== 'room') return `[Message from ${fromName} · DeepSeek Harness restarted while you were working on it]`
|
|
17
20
|
if (role === 'reply') return `[Reply from ${fromName}]`
|
|
18
21
|
if (role === 'stopped') return `[Stopped by ${fromName}]`
|
|
19
22
|
if (role === 'room') return `[Group chat "${roomName}" · ${note}]`
|
|
@@ -29,8 +32,13 @@ export function install(rt) {
|
|
|
29
32
|
|
|
30
33
|
// `sessionId` defaults to the recipient's own chat; group turns name its group Session.
|
|
31
34
|
// `deferred`: a request the Bot already read mid-turn, handed back to answer in a
|
|
32
|
-
// turn of its own; its exchange was recorded when it first arrived.
|
|
33
|
-
|
|
35
|
+
// turn of its own; its exchange was recorded when it first arrived. `resumed` and
|
|
36
|
+
// `retried` carry on a turn that a restart cut off or that wrote its tool call as
|
|
37
|
+
// text (resume.js, turns.js), and `resent` is a message a shutdown cancelled; their
|
|
38
|
+
// exchange is recorded already too. `askedBy` is the member whose @mention called a
|
|
39
|
+
// group turn (groups.js). `fromRoomId`: the group whose Session the sender wrote
|
|
40
|
+
// from; the reply goes back to that Session (turns.js).
|
|
41
|
+
async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false, resumed = false, retried = false, resent = false, askedBy, fromRoomId }) {
|
|
34
42
|
const from = rt.botOf(fromId)
|
|
35
43
|
const to = rt.botOf(toId)
|
|
36
44
|
if (to === undefined) throw new Error(`Unknown Bot ${toId}`)
|
|
@@ -49,16 +57,22 @@ export function install(rt) {
|
|
|
49
57
|
...(roomId ? { roomId } : {}),
|
|
50
58
|
...(audience === 'user' ? { audience } : {}),
|
|
51
59
|
...(deferred ? { deferred: true } : {}),
|
|
60
|
+
...(resumed ? { resumed: true } : {}),
|
|
61
|
+
...(retried ? { retried: true } : {}),
|
|
62
|
+
...(askedBy ? { askedBy } : {}),
|
|
63
|
+
...(fromRoomId ? { senderRoomId: fromRoomId } : {}),
|
|
52
64
|
}
|
|
53
|
-
if (EXCHANGE_ROLES.has(role) && !deferred) {
|
|
65
|
+
if (EXCHANGE_ROLES.has(role) && !deferred && !resumed && !retried && !resent) {
|
|
54
66
|
state.exchanges.push({ id: exchangeId, from: fromId, to: toId, text, role, time: Date.now(), ...(replyTo ? { replyTo } : {}), ...(audience === 'user' ? { audience } : {}) })
|
|
55
67
|
void rt.save().catch(() => {})
|
|
56
68
|
}
|
|
57
|
-
const
|
|
69
|
+
const targetSessionId = sessionId ?? rt.chatOf(toId)
|
|
70
|
+
await rt.ensureCapabilities(targetSessionId)
|
|
71
|
+
const agent = await resolveAgent(targetSessionId)
|
|
58
72
|
// A steered message cuts into a running turn at its next step; a follow-up
|
|
59
73
|
// queues for the next turn (see turns.js for how each is routed).
|
|
60
74
|
const message = createUserMessage({
|
|
61
|
-
content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred)}\n${text}` }],
|
|
75
|
+
content: [{ type: 'text', text: `${headerFor(role, fromName, room?.name, audience, note, deferred, { resumed, retried })}\n${text}` }],
|
|
62
76
|
source,
|
|
63
77
|
})
|
|
64
78
|
if (steer) agent.steer(message)
|
package/src/host/groups.js
CHANGED
|
@@ -9,10 +9,15 @@
|
|
|
9
9
|
// goes to every member in roster order, admin first, or to the admin alone in admin
|
|
10
10
|
// mode; "@Name" picks members. A reply that @mentions a member calls that member
|
|
11
11
|
// next, bounded per member and per round; the admin's calls reach further.
|
|
12
|
+
//
|
|
13
|
+
// Work does not hold a round up: a member that is still busy with tools a little after
|
|
14
|
+
// it started, or that is called while still at earlier work, lets the round go on. Its
|
|
15
|
+
// turn keeps running in its group Session, and what it ends with is posted to the group,
|
|
16
|
+
// where the member that asked for it answers.
|
|
12
17
|
import { randomUUID } from 'node:crypto'
|
|
13
18
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
14
19
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
15
|
-
import {
|
|
20
|
+
import { ROOM_LATE_ROUNDS, ROOM_REPLY_TIMEOUT_MS, ROOM_WORK_GRACE_MS, SOURCE_KIND, TEAM_TOOLS } from './constants.js'
|
|
16
21
|
import { escapeRegExp, splitRef, textOf } from './text.js'
|
|
17
22
|
import { mentionSpans } from '../shared/mentions.js'
|
|
18
23
|
import { GROUP_REF, output } from './tools.js'
|
|
@@ -48,9 +53,13 @@ const unseenLines = (room, memberId) => {
|
|
|
48
53
|
return room.log.filter(line => line.seq > since && line.botId !== memberId).slice(-ROOM_DELTA_MAX)
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
const lineText = line =>
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
const lineText = (line) => {
|
|
57
|
+
if (line.kind === 'lookup') return `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
|
|
58
|
+
if (line.kind === 'resumed') return `[${line.who}'s work here was cut off when DeepSeek Harness restarted; it is picking it back up and posts when done]`
|
|
59
|
+
if (line.kind === 'cut') return `[${line.who}'s work here was cut off by a restart again, so it stopped; ask it to go on if it still matters]`
|
|
60
|
+
if (line.kind === 'stopped') return `[The user stopped ${line.who}'s work here]`
|
|
61
|
+
return `${line.who}: ${line.text}`
|
|
62
|
+
}
|
|
54
63
|
const transcript = lines => lines.map(lineText).join('\n')
|
|
55
64
|
|
|
56
65
|
// Models often echo the transcript format ("Name: …") despite the instruction.
|
|
@@ -76,13 +85,15 @@ export const referenceNote = references => (references?.length
|
|
|
76
85
|
? `Conversations the user referenced in this round:\n\n${references.map(reference => reference.text).join('\n\n')}`
|
|
77
86
|
: '')
|
|
78
87
|
|
|
79
|
-
const ROOM_REPLY = 'Reply with your message for the group: one or two short messages,
|
|
88
|
+
const ROOM_REPLY = 'Reply with your message for the group, no name prefix: in a discussion, one or two short messages. When you are asked to do something, do it now with your tools. The group does not wait for you, and your final reply is posted here when you finish, so give the result, never a promise to do it later. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
|
|
89
|
+
const ROOM_CALLS = 'Writing "@Name" calls that member next, to answer or to do what you ask; when it finishes work you gave it, you answer its result. To only mention a member, write its name without "@".'
|
|
80
90
|
|
|
81
91
|
const slotLabel = (slot) => {
|
|
82
92
|
if (slot.status === 'speaking') return `${slot.name} (you, now)`
|
|
83
93
|
if (slot.status === 'posted') return `${slot.name} (posted)`
|
|
84
94
|
if (slot.status === 'passed') return `${slot.name} (passed)`
|
|
85
95
|
if (slot.status === 'missed') return `${slot.name} (no answer)`
|
|
96
|
+
if (slot.status === 'working') return `${slot.name} (working on it; posts when done)`
|
|
86
97
|
if (slot.status === 'waiting-approval') return `${slot.name} (waiting for the user's approval; posts when done)`
|
|
87
98
|
if (slot.status === 'failed') return `${slot.name} (could not answer: error)`
|
|
88
99
|
if (slot.status === 'left') return `${slot.name} (left the group)`
|
|
@@ -92,7 +103,7 @@ const slotLabel = (slot) => {
|
|
|
92
103
|
const adminNote = room => [
|
|
93
104
|
'You are this group\'s admin.',
|
|
94
105
|
room.mode === 'admin' ? 'Plain messages come to you first: answer them, or call the members who should answer with @Name.' : '',
|
|
95
|
-
'
|
|
106
|
+
'"@all" calls every member. When the user asks, change the group with update_group.',
|
|
96
107
|
].filter(Boolean).join(' ')
|
|
97
108
|
|
|
98
109
|
// The opening turn of a group the user just made without a first message.
|
|
@@ -109,7 +120,10 @@ export function install(rt) {
|
|
|
109
120
|
async function groupSessionFor(room, member) {
|
|
110
121
|
room.sessions ??= {}
|
|
111
122
|
const known = room.sessions[member.id]
|
|
112
|
-
if (known !== undefined)
|
|
123
|
+
if (known !== undefined) {
|
|
124
|
+
await rt.ensureCapabilities(known)
|
|
125
|
+
return { id: known, brief: undefined }
|
|
126
|
+
}
|
|
113
127
|
const id = await rt.createSession(`${member.name} · ${room.name}`)
|
|
114
128
|
room.sessions[member.id] = id
|
|
115
129
|
rt.groupOwners.set(id, { botId: member.id, roomId: room.id })
|
|
@@ -122,6 +136,7 @@ export function install(rt) {
|
|
|
122
136
|
await rt.applyPermission(member, id)
|
|
123
137
|
.catch(error => rt.warn('ds-bot: permission for %s in %s not applied: %s', member.name, room.name, error))
|
|
124
138
|
await rt.save()
|
|
139
|
+
await rt.ensureCapabilities(id)
|
|
125
140
|
return { id, brief: await rt.ownChatBrief(member) }
|
|
126
141
|
}
|
|
127
142
|
|
|
@@ -138,9 +153,10 @@ export function install(rt) {
|
|
|
138
153
|
const index = round.slots.findIndex(slot => slot.status === 'speaking')
|
|
139
154
|
const before = index
|
|
140
155
|
const after = round.slots.length - index - 1
|
|
141
|
-
const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts
|
|
156
|
+
const members = `Members: the user${peers.length ? `, ${peers.join(', ')}` : ''} and you${leads ? ' (admin)' : ''}. Members speak one at a time; each message is posted before the next speaker starts. ${ROOM_CALLS}`
|
|
142
157
|
const notice = room.notice ? `Group notice (shared instructions for every member):\n${room.notice}` : ''
|
|
143
158
|
if (round.kickoff) return [members, notice, leads ? adminNote(room) : '', roomKickoff(rt.uiLanguage?.())].filter(Boolean).join('\n\n')
|
|
159
|
+
const working = workingIn(room).filter(item => item.bot.id !== member.id)
|
|
144
160
|
return [
|
|
145
161
|
members,
|
|
146
162
|
notice,
|
|
@@ -150,26 +166,41 @@ export function install(rt) {
|
|
|
150
166
|
? `This round so far${spokeThisRound ? ', since your last message' : ', starting with the new message'} (oldest first):\n${transcript(current)}`
|
|
151
167
|
: 'Nothing new in this round since your last message.',
|
|
152
168
|
round.shown?.includes(member.id) ? '' : referenceNote(round.references),
|
|
169
|
+
working.length > 0 ? `Still at work on what they were asked; each posts here when done: ${working.map(item => `${item.bot.name} (${minutesSince(item.since)} min)`).join(', ')}.` : '',
|
|
153
170
|
`Speaking order for this round: ${round.slots.map(slotLabel).join(' → ')}.`,
|
|
154
171
|
`You are number ${index + 1} of ${round.slots.length}. ${before === 0 ? 'Nobody spoke before you' : `${before} went before you`}${after === 0 ? ', and you are the last.' : `; ${after} after you will see your message.`}`,
|
|
155
172
|
ROOM_REPLY,
|
|
156
173
|
].filter(Boolean).join('\n\n')
|
|
157
174
|
}
|
|
158
175
|
|
|
176
|
+
// Members whose turn in this group is running, and since when.
|
|
177
|
+
const workingIn = room => room.members.flatMap((id) => {
|
|
178
|
+
const entry = rt.live.get(room.sessions?.[id])
|
|
179
|
+
const bot = rt.botOf(id)
|
|
180
|
+
return entry?.turn !== undefined && bot !== undefined ? [{ bot, since: entry.since ?? Date.now() }] : []
|
|
181
|
+
})
|
|
182
|
+
const minutesSince = at => Math.max(1, Math.round((Date.now() - at) / 60000))
|
|
183
|
+
|
|
159
184
|
// One member turn inside the room; resolves to its final text, `{ failure }` when its
|
|
160
185
|
// model call failed, `{ missed }` when it went quiet for too long, `{ approval }` when
|
|
161
|
-
// it waits for the user's approval,
|
|
162
|
-
//
|
|
163
|
-
|
|
186
|
+
// it waits for the user's approval, `{ working }` when it goes on working without the
|
|
187
|
+
// round, or null when the round was stopped. The wait restarts on each event of the
|
|
188
|
+
// member's Session, so a member at work is never cut off. `by` is the member that
|
|
189
|
+
// called it, which answers what the turn posts after the round moved on.
|
|
190
|
+
async function roomTurn(room, member, sessionId, text, note, signal, started, by) {
|
|
164
191
|
if (signal.aborted) return null
|
|
165
192
|
const exchangeId = randomUUID()
|
|
166
193
|
const turn = { botId: member.id, sessionId, exchangeId }
|
|
167
194
|
started.push(turn)
|
|
168
195
|
room.seen[member.id] = room.seq
|
|
196
|
+
// Checked before the delivery, which may start the turn at once.
|
|
197
|
+
const busy = rt.live.get(sessionId)?.turn !== undefined
|
|
169
198
|
const done = new Promise((resolve) => {
|
|
170
199
|
let timer
|
|
200
|
+
let grace
|
|
171
201
|
const finish = (value) => {
|
|
172
202
|
clearTimeout(timer)
|
|
203
|
+
clearTimeout(grace)
|
|
173
204
|
rt.roomWaiters.delete(exchangeId)
|
|
174
205
|
resolve(value)
|
|
175
206
|
}
|
|
@@ -177,14 +208,17 @@ export function install(rt) {
|
|
|
177
208
|
clearTimeout(timer)
|
|
178
209
|
timer = setTimeout(() => finish({ missed: true }), ROOM_REPLY_TIMEOUT_MS)
|
|
179
210
|
}
|
|
211
|
+
const work = () => { grace ??= setTimeout(() => finish({ working: true }), ROOM_WORK_GRACE_MS) }
|
|
180
212
|
touch()
|
|
181
|
-
rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, approval: () => finish({ approval: true }) })
|
|
213
|
+
rt.roomWaiters.set(exchangeId, { sessionId, botId: member.id, roomId: room.id, resolve: finish, fail: failure => finish({ failure }), touch, work, approval: () => finish({ approval: true }) })
|
|
182
214
|
signal.addEventListener('abort', () => finish(null), { once: true })
|
|
183
215
|
})
|
|
184
216
|
try {
|
|
185
|
-
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text })
|
|
217
|
+
await rt.deliver({ fromId: undefined, toId: member.id, sessionId, role: 'room', roomId: room.id, exchangeId, note, text, ...(by ? { askedBy: by } : {}) })
|
|
186
218
|
// Still held by an earlier request: this turn cannot start until the user answers.
|
|
187
219
|
if (rt.waitsForApproval?.(sessionId)) rt.roomWaiters.get(exchangeId)?.approval()
|
|
220
|
+
// Still at earlier work: the call waits in its inbox and is answered after that.
|
|
221
|
+
else if (busy) rt.roomWaiters.get(exchangeId)?.resolve({ working: true })
|
|
188
222
|
} catch (error) {
|
|
189
223
|
rt.warn('ds-bot: group delivery to %s failed: %s', member.name, error)
|
|
190
224
|
rt.roomWaiters.get(exchangeId)?.resolve(null)
|
|
@@ -205,17 +239,25 @@ export function install(rt) {
|
|
|
205
239
|
}
|
|
206
240
|
rt.touchRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.touch() }
|
|
207
241
|
rt.yieldRoomTurn = (sessionId) => { for (const waiter of waitersOf(sessionId)) waiter.approval() }
|
|
242
|
+
// The team's own tools are quick; any other tool is the start of work.
|
|
243
|
+
const QUICK_TOOLS = new Set(TEAM_TOOLS)
|
|
244
|
+
rt.workRoomTurn = (sessionId, tool) => {
|
|
245
|
+
if (QUICK_TOOLS.has(tool)) return
|
|
246
|
+
for (const waiter of waitersOf(sessionId)) waiter.work()
|
|
247
|
+
}
|
|
208
248
|
|
|
209
249
|
// A called member moves up if it is still waiting, or gets one more turn if it
|
|
210
250
|
// already spoke; either way it goes right after slot `index`. `cap` is the caller's
|
|
211
|
-
// per-member turn bound for the round.
|
|
212
|
-
function callMembers(round, turns, index, callees, note, cap) {
|
|
251
|
+
// per-member turn bound for the round; `by` is the calling member, if a member called.
|
|
252
|
+
function callMembers(round, turns, index, callees, note, cap, by) {
|
|
213
253
|
let next = index + 1
|
|
214
254
|
for (const peer of callees) {
|
|
215
255
|
if ((turns.get(peer.id) ?? 0) >= cap) continue
|
|
216
256
|
const waiting = round.slots.findIndex((other, at) => at > index && other.id === peer.id && other.status === 'waiting')
|
|
217
257
|
const called = waiting === -1 ? { id: peer.id, name: peer.name, status: 'waiting' } : round.slots.splice(waiting, 1)[0]
|
|
218
258
|
called.note = note
|
|
259
|
+
if (by === undefined) delete called.by
|
|
260
|
+
else called.by = by
|
|
219
261
|
round.slots.splice(next, 0, called)
|
|
220
262
|
next += 1
|
|
221
263
|
}
|
|
@@ -258,6 +300,7 @@ export function install(rt) {
|
|
|
258
300
|
}
|
|
259
301
|
// The user spoke in this room, so Bots may start their own rounds again.
|
|
260
302
|
room.botRounds = 0
|
|
303
|
+
room.lateRounds = 0
|
|
261
304
|
void rt.save().catch(() => {})
|
|
262
305
|
return round.slots.length - before
|
|
263
306
|
}
|
|
@@ -296,7 +339,7 @@ export function install(rt) {
|
|
|
296
339
|
const prompt = roomPrompt(room, member, round)
|
|
297
340
|
round.shown?.push(member.id)
|
|
298
341
|
const text = session.brief === undefined ? prompt : `${session.brief}\n\n${prompt}`
|
|
299
|
-
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started)
|
|
342
|
+
const reply = await roomTurn(room, member, session.id, text, slot.note ?? 'your turn', signal, round.started, slot.by)
|
|
300
343
|
round.speaking = null
|
|
301
344
|
if (reply?.failure !== undefined) {
|
|
302
345
|
// Not a pass: the user sees why the member is silent, and the round goes on.
|
|
@@ -312,6 +355,11 @@ export function install(rt) {
|
|
|
312
355
|
round.replies.push({ kind: 'approval', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
313
356
|
continue
|
|
314
357
|
}
|
|
358
|
+
if (reply?.working) {
|
|
359
|
+
slot.status = 'working'
|
|
360
|
+
round.replies.push({ kind: 'working', botId: member.id, name: member.name, color: member.color, time: Date.now() })
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
315
363
|
if (reply === null || reply?.missed || isPass(reply)) {
|
|
316
364
|
slot.status = typeof reply === 'string' ? 'passed' : 'missed'
|
|
317
365
|
round.passed.push(member.name)
|
|
@@ -329,7 +377,7 @@ export function install(rt) {
|
|
|
329
377
|
const { all, found: named } = mentionsIn(reply, everyone)
|
|
330
378
|
const found = named.filter(other => other.id !== member.id)
|
|
331
379
|
const callees = leads && all ? [...found, ...others.filter(other => !found.includes(other))] : found
|
|
332
|
-
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH)
|
|
380
|
+
callMembers(round, turns, index, callees, `${member.name} mentioned you`, leads ? ROOM_ADMIN_TURNS_EACH : ROOM_TURNS_EACH, member.id)
|
|
333
381
|
}
|
|
334
382
|
}
|
|
335
383
|
|
|
@@ -350,25 +398,37 @@ export function install(rt) {
|
|
|
350
398
|
const posts = queued.length > 0 ? queued : [{ text: '' }]
|
|
351
399
|
const startSeq = room.seq + 1
|
|
352
400
|
// The opening greeting is a cue for the admin, not a line of the group; a member's
|
|
353
|
-
// late failure
|
|
354
|
-
|
|
401
|
+
// late failure, its work picked up after a restart, or its work the user stopped is
|
|
402
|
+
// an event of the round.
|
|
403
|
+
const event = item => item.failed || item.resumed || item.stopped
|
|
404
|
+
for (const post of posts.filter(item => !item.kickoff && !event(item))) {
|
|
355
405
|
// Image readings go into the log only; `@` names are picked from what the user typed.
|
|
356
406
|
const text = post.images === undefined ? post.text : [post.text, ...await rt.imagesForGroup(post.images, room.id)].filter(Boolean).join('\n')
|
|
357
407
|
appendRoom(room, post.botId
|
|
358
408
|
? { who: rt.botOf(post.botId)?.name ?? post.name ?? 'Bot', botId: post.botId, text }
|
|
359
409
|
: { who: 'User', text })
|
|
360
410
|
}
|
|
361
|
-
if (posts.some(post => !post.botId && !post.kickoff))
|
|
411
|
+
if (posts.some(post => !post.botId && !post.kickoff)) {
|
|
412
|
+
room.botRounds = 0
|
|
413
|
+
room.lateRounds = 0
|
|
414
|
+
}
|
|
362
415
|
await rt.save()
|
|
363
|
-
|
|
416
|
+
// An event post never picks the speakers over a message queued with it.
|
|
417
|
+
const opener = posts.findLast(post => !event(post)) ?? posts.at(-1)
|
|
364
418
|
const references = posts.flatMap(post => post.references ?? [])
|
|
419
|
+
// A member a Bot's post calls by name works for that Bot.
|
|
420
|
+
const named = opener.botId === undefined ? [] : mentionsIn(opener.text ?? '', room.members.map(rt.botOf).filter(Boolean)).found
|
|
365
421
|
const round = {
|
|
366
422
|
roomId: room.id,
|
|
367
423
|
members: room.members,
|
|
368
424
|
startSeq,
|
|
369
425
|
...(opener.kickoff ? { kickoff: true } : {}),
|
|
370
426
|
...(references.length > 0 ? { references, shown: [] } : {}),
|
|
371
|
-
slots: openingSpeakers(room, opener).map(member => ({
|
|
427
|
+
slots: openingSpeakers(room, opener).map(member => ({
|
|
428
|
+
id: member.id, name: member.name, status: 'waiting',
|
|
429
|
+
...(opener.kickoff ? { note: 'the group was just created' } : {}),
|
|
430
|
+
...(named.includes(member) ? { by: opener.botId } : {}),
|
|
431
|
+
})),
|
|
372
432
|
speaking: null,
|
|
373
433
|
turns: 0,
|
|
374
434
|
replies: [],
|
|
@@ -382,6 +442,15 @@ export function install(rt) {
|
|
|
382
442
|
round.failed.push(bot?.name ?? post.name)
|
|
383
443
|
round.replies.push({ kind: 'failed', botId: post.botId, name: bot?.name ?? post.name, color: bot?.color, code, ...(status ? { status } : {}), text: message, time: Date.now() })
|
|
384
444
|
}
|
|
445
|
+
// The log line tells the admin and the other members on their next turn.
|
|
446
|
+
for (const post of posts.filter(item => item.resumed || item.stopped)) {
|
|
447
|
+
const bot = rt.botOf(post.botId)
|
|
448
|
+
const name = bot?.name ?? post.name
|
|
449
|
+
const kind = post.stopped ? 'stopped' : post.again ? 'cut' : 'resumed'
|
|
450
|
+
appendRoom(room, { who: name, botId: post.botId, kind, text: '' })
|
|
451
|
+
round.replies.push({ kind, botId: post.botId, name, color: bot?.color, time: Date.now() })
|
|
452
|
+
}
|
|
453
|
+
if (posts.some(post => post.resumed || post.stopped)) await rt.save()
|
|
385
454
|
rt.roomProgress.set(exec.callId, round)
|
|
386
455
|
try {
|
|
387
456
|
await runRound(room, round, exec.signal, exec.agent)
|
|
@@ -476,7 +545,9 @@ export function install(rt) {
|
|
|
476
545
|
}))
|
|
477
546
|
|
|
478
547
|
// A Bot that posts never answers its own post. @mentions choose the speakers; else
|
|
479
|
-
// the admin answers alone in admin mode, or first of everyone.
|
|
548
|
+
// the admin answers alone in admin mode, or first of everyone. What a member posts
|
|
549
|
+
// after its round moved on is answered by the member that asked for it; work the
|
|
550
|
+
// user asked for is left for the user to read.
|
|
480
551
|
function openingSpeakers(room, opener) {
|
|
481
552
|
const everyone = room.members.map(rt.botOf).filter(Boolean)
|
|
482
553
|
const members = everyone.filter(bot => bot.id !== opener.botId)
|
|
@@ -485,7 +556,7 @@ export function install(rt) {
|
|
|
485
556
|
if (opener.quiet) return []
|
|
486
557
|
const { all, found: named } = mentionsIn(opener.text, everyone, opener.references)
|
|
487
558
|
const found = named.filter(bot => bot.id !== opener.botId)
|
|
488
|
-
if (opener.late) return found.length > 0 ? found :
|
|
559
|
+
if (opener.late) return found.length > 0 ? found : members.filter(bot => bot.id === opener.askedBy)
|
|
489
560
|
if (found.length > 0 && !all) return found
|
|
490
561
|
if (!all && room.mode === 'admin' && admin) return [admin]
|
|
491
562
|
return admin ? [admin, ...members.filter(bot => bot !== admin)] : members
|
|
@@ -502,9 +573,10 @@ export function install(rt) {
|
|
|
502
573
|
}
|
|
503
574
|
rt.postToRoom = postToRoom
|
|
504
575
|
|
|
505
|
-
// A member the round moved on from (it went quiet or waited for an
|
|
506
|
-
// what its turn ended with.
|
|
507
|
-
//
|
|
576
|
+
// A member the round moved on from (it went on working, went quiet or waited for an
|
|
577
|
+
// approval) posts what its turn ended with. The member that asked for it and anyone
|
|
578
|
+
// it names answer; past the late round limit nobody does, so late posts cannot keep a
|
|
579
|
+
// group talking.
|
|
508
580
|
async function lateRoomReply(sessionId, info, reply) {
|
|
509
581
|
const owner = rt.groupOwners.get(sessionId)
|
|
510
582
|
const room = rt.roomOf(owner?.roomId ?? info.roomId)
|
|
@@ -512,14 +584,29 @@ export function install(rt) {
|
|
|
512
584
|
if (room === undefined || member === undefined || !room.members.includes(member.id)) return
|
|
513
585
|
const text = stripOwnName(member.name, reply)
|
|
514
586
|
if (isPass(text)) return
|
|
515
|
-
const quiet = (room.
|
|
516
|
-
if (!quiet) room.
|
|
587
|
+
const quiet = (room.lateRounds ?? 0) >= ROOM_LATE_ROUNDS
|
|
588
|
+
if (!quiet) room.lateRounds = (room.lateRounds ?? 0) + 1
|
|
517
589
|
await rt.save()
|
|
518
|
-
await postToRoom(room, member, text, { late: true, ...(quiet ? { quiet: true } : {}) })
|
|
590
|
+
await postToRoom(room, member, text, { late: true, ...(info.askedBy ? { askedBy: info.askedBy } : {}), ...(quiet ? { quiet: true } : {}) })
|
|
519
591
|
.catch(error => rt.warn('ds-bot: late group reply from %s failed: %s', member.name, error))
|
|
520
592
|
}
|
|
521
593
|
rt.lateRoomReply = lateRoomReply
|
|
522
594
|
|
|
595
|
+
// The user stops a member's work in this group from the group's details. Messages
|
|
596
|
+
// still waiting for that member here go with it.
|
|
597
|
+
async function stopMemberWork(roomId, botId) {
|
|
598
|
+
await rt.load()
|
|
599
|
+
const room = rt.roomOf(roomId)
|
|
600
|
+
const member = rt.botOf(botId)
|
|
601
|
+
const sessionId = room?.sessions?.[botId]
|
|
602
|
+
if (room === undefined || member === undefined || sessionId === undefined || rt.live.get(sessionId)?.turn === undefined) return false
|
|
603
|
+
const agent = await rt.resolveAgent(sessionId)
|
|
604
|
+
agent.cancel({ kind: 'user' })
|
|
605
|
+
await postToRoom(room, member, '', { quiet: true, stopped: true })
|
|
606
|
+
return true
|
|
607
|
+
}
|
|
608
|
+
rt.stopMemberWork = stopMemberWork
|
|
609
|
+
|
|
523
610
|
// The round said such a member posts when done; a failed model call is how it ended.
|
|
524
611
|
// Nobody answers it.
|
|
525
612
|
async function lateRoomFailure(sessionId, info, failure) {
|
|
@@ -550,8 +637,10 @@ export function install(rt) {
|
|
|
550
637
|
async function reportRound(room, post, round) {
|
|
551
638
|
const lines = round.replies.filter(reply => reply.kind === undefined).map(reply => `${reply.name}: ${reply.text}`)
|
|
552
639
|
const waiting = round.replies.filter(reply => reply.kind === 'approval').map(reply => reply.name)
|
|
640
|
+
const working = round.replies.filter(reply => reply.kind === 'working').map(reply => reply.name)
|
|
553
641
|
const text = [
|
|
554
642
|
lines.length > 0 ? `Replies to your post (oldest first):\n${lines.join('\n')}` : 'Nobody replied to your post.',
|
|
643
|
+
working.length > 0 ? `Still working on it: ${working.join(', ')}. Each posts its result in the group when done, not to you; read_group_chat shows it.` : '',
|
|
555
644
|
waiting.length > 0 ? `Waiting for the user's approval: ${waiting.join(', ')}. What they say afterwards is posted in the group, not sent to you; read_group_chat shows it.` : '',
|
|
556
645
|
round.passed.length > 0 ? `Stayed silent: ${round.passed.join(', ')}.` : '',
|
|
557
646
|
round.failed.length > 0 ? `Could not answer because their model call failed: ${round.replies.filter(reply => reply.kind === 'failed').map(reply => `${reply.name} (${failureLine({ code: reply.code, status: reply.status, message: reply.text })})`).join(', ')}.` : '',
|
package/src/host/parts.js
CHANGED
|
@@ -641,6 +641,7 @@ export function install(rt) {
|
|
|
641
641
|
async function forward(owner, messages) {
|
|
642
642
|
const target = currentOf(owner)
|
|
643
643
|
if (target === undefined || messages.length === 0) return
|
|
644
|
+
await rt.ensureCapabilities(target)
|
|
644
645
|
const agent = await rt.resolveAgent(target)
|
|
645
646
|
for (const message of messages) {
|
|
646
647
|
const copy = createUserMessage({ content: message.content, ...(message.source ? { source: message.source } : {}) })
|
|
@@ -770,6 +771,7 @@ export function install(rt) {
|
|
|
770
771
|
const resume = job.resume || job.overflow
|
|
771
772
|
const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
|
|
772
773
|
freshParts.set(freshId, estimateTokens(text))
|
|
774
|
+
await rt.ensureCapabilities(freshId)
|
|
773
775
|
const fresh = await rt.resolveAgent(freshId)
|
|
774
776
|
fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
|
|
775
777
|
await ctx.sessions.flush(fresh.session)
|
package/src/host/prompt.js
CHANGED
|
@@ -15,7 +15,7 @@ const TEAM_RULES = [
|
|
|
15
15
|
'- For "[Group chat …]", "[Message from …]", or "[Reply from …]" messages, do not write plans or progress updates: use tools silently, then reply with the answer. Simple answers do not need progress messages.',
|
|
16
16
|
'- Offer choices with ask_user (a tap-to-answer card) instead of long option lists in text. Calling it ends your turn at once, so write everything you want to say before the call, in the same reply. The answer arrives as the user\'s next message. Never use ask_user_question: it blocks you, and messages from other Bots would queue behind it.',
|
|
17
17
|
'- update_bot changes your own settings: name, role label, color, model, or instructions. Leave out bot to change yourself. Use it when the user asks you to change how you are set up. Only a Main Bot can change other Bots; when another Bot needs changing, ask a Main Bot.',
|
|
18
|
-
'- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
|
|
18
|
+
'- You may be in group chats with the user and other Bots. You speak in each group from a separate conversation of yours, apart from your own chat with the user. There your turn arrives as a "[Group chat …]" message that shows the speaking order, and your reply is posted to the group. When someone there asks you to do something, do the work in that turn: the group goes on without waiting, and your final reply is posted when you finish. Any member can hand work to another with @Name, and the one that asked answers the result. In a group, read_own_chat shows your own chat (the group sees that you looked; share only what the group needs). In your own chat, read_group_chat shows what was said in a group. A group may have an admin, who steers it: the admin can call members with @Name, change the group with update_group, and start a new discussion there with post_to_group.',
|
|
19
19
|
'- When you need a key, token, or password, call request_secret: the user types it into a secure card and you never see it. Never ask the user to paste one into the chat. Use it through its environment variable ($DSH_SECRET_<NAME> in bash, $env:DSH_SECRET_<NAME> in PowerShell) and never print it. list_secrets shows which keys exist.',
|
|
20
20
|
'- web_search results say which search backend answered. If web_search fails, never guess URLs or present pages you remember as search results: use a browser tool if you have one, otherwise tell the user that search is unavailable and the reason it gave.',
|
|
21
21
|
'- Your conversations are long-lived. When one grows long, either its older messages are condensed in place into a summary you wrote (a "[Checkpoint N]" message), or it moves on to a fresh part that opens with a handoff note you wrote and your latest messages (a "[Handoff · part N]" message). What leaves your working context stays saved. When you need the exact earlier wording or a detail the summary or note does not show in full (a full path, a figure, every item of a complete list or a final summary), call read_own_chat with words to look for, or with part N to read the note that closed an earlier part; never fill such a detail in from memory. Each line it returns starts with an anchor such as [part 2 #1234 · 10-08 14:22]; pass the anchor as around to read what was said around it.',
|