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.
@@ -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
- async function deliver({ fromId, toId, sessionId, text, role, hop = 0, replyTo, roomId, audience, note, exchangeId = randomUUID(), steer = false, deferred = false }) {
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 agent = await resolveAgent(sessionId ?? rt.chatOf(toId))
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)
@@ -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 { ROOM_BOT_ROUNDS, ROOM_REPLY_TIMEOUT_MS, SOURCE_KIND } from './constants.js'
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 => (line.kind === 'lookup'
52
- ? `[${line.who} looked up its own chat${line.text ? ` for "${line.text}"` : ''}]`
53
- : `${line.who}: ${line.text}`)
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, no name prefix. Reply [PASS] to stay silent when you have nothing new to add or the message is for someone else.'
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
- 'Writing "@Name" calls that member to speak right after you, and "@all" calls every member. When the user asks, change the group with update_group.',
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) return { id: known, brief: 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, or null when the round was stopped. The wait
162
- // restarts on each event of the member's Session, so a member at work is never cut off.
163
- async function roomTurn(room, member, sessionId, text, note, signal, started) {
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 is an event of the round.
354
- for (const post of posts.filter(item => !item.kickoff && !item.failed)) {
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)) room.botRounds = 0
411
+ if (posts.some(post => !post.botId && !post.kickoff)) {
412
+ room.botRounds = 0
413
+ room.lateRounds = 0
414
+ }
362
415
  await rt.save()
363
- const opener = posts.at(-1)
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 => ({ id: member.id, name: member.name, status: 'waiting', ...(opener.kickoff ? { note: 'the group was just created' } : {}) })),
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 : admin ? [admin] : []
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 approval) posts
506
- // what its turn ended with. Only the admin and anyone it names answer, and past the
507
- // Bot round limit nobody does, so late posts cannot keep a group talking.
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.botRounds ?? 0) >= ROOM_BOT_ROUNDS
516
- if (!quiet) room.botRounds = (room.botRounds ?? 0) + 1
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)
@@ -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.',