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/src/host/turns.js CHANGED
@@ -31,9 +31,45 @@ export function attachReference(queued, source, text) {
31
31
  post.references = [...(post.references ?? []), { labels, text }]
32
32
  }
33
33
 
34
+ // A tool call some models write into their text instead of making it (the XML-like
35
+ // form of their own chat template).
36
+ const WRITTEN_CALL = /<tool_call>\s*<function=|<function=[\w.-]+>\s*<parameter=/
37
+ export const writesToolCall = text => typeof text === 'string' && WRITTEN_CALL.test(text)
38
+ export const stripWrittenCalls = text => text
39
+ .replace(/<tool_call>[\s\S]*?(?:<\/tool_call>|$)/g, '')
40
+ .replace(/<function=[\w.-]+>[\s\S]*?(?:<\/function>|$)/g, '')
41
+ .trim()
42
+
43
+ // Under DSH's PTC presentation the model calls only this tool; the program's sub-calls
44
+ // are the tools it really runs.
45
+ const PROGRAM_TOOL = 'run_code'
46
+
47
+ const RETRY_TEXT = 'Your last reply wrote a tool call as plain text (<tool_call>…), so it did not run and nothing happened. Call the tool through the tool interface now, then reply as you meant to.'
48
+
34
49
  export function install(rt) {
35
50
  const { ctx, state } = rt
36
51
 
52
+ // The retry goes where the turn's trigger came from: a group round's waiter or a
53
+ // Bot's request keeps its exchange; anything else is a nudge in the Bot's own chat.
54
+ const retryWrittenCall = (id, info) => {
55
+ const keeps = info !== undefined && (info.role === 'room' || expectsReply(info))
56
+ return rt.deliver({
57
+ ...(keeps && info.senderSessionId ? { fromId: info.senderSessionId } : {}),
58
+ toId: rt.selfOf(id),
59
+ sessionId: id,
60
+ role: keeps ? info.role : 'retry',
61
+ ...(keeps && info.roomId ? { roomId: info.roomId } : {}),
62
+ hop: keeps ? info.hop ?? 0 : 0,
63
+ ...(keeps && info.exchangeId ? { exchangeId: info.exchangeId } : {}),
64
+ ...(keeps && info.audience ? { audience: info.audience } : {}),
65
+ ...(keeps && info.askedBy ? { askedBy: info.askedBy } : {}),
66
+ ...(keeps && info.senderRoomId ? { fromRoomId: info.senderRoomId } : {}),
67
+ note: 'your tool call did not run',
68
+ retried: true,
69
+ text: RETRY_TEXT,
70
+ })
71
+ }
72
+
37
73
  /** @type {Map<string, {turn?:number, pending?:any, info?:any, texts:string[], opened?:boolean, joined?:{info:any, from:number}[]}>} */
38
74
  const live = new Map()
39
75
  /** @type {Map<string, {sessionId:string, resolve:(text:string|null)=>void, fail:(failure:any)=>void, touch:()=>void, approval:()=>void}>} member turn waiters keyed by exchange id */
@@ -88,6 +124,22 @@ export function install(rt) {
88
124
  else next[id] = value
89
125
  activity = next
90
126
  }
127
+ // The newest call still running, a program's sub-calls included, is what the Session
128
+ // is doing.
129
+ const showCall = (id, entry) => {
130
+ const name = [...(entry.calls?.values() ?? [])].at(-1)?.name
131
+ entry.tool = name
132
+ if (name !== undefined) setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
133
+ else if (entry.turn !== undefined) setActivity(id, 'thinking')
134
+ }
135
+ const startCall = (id, entry, callId, name, args) => {
136
+ if (typeof callId === 'string') (entry.calls ??= new Map()).set(callId, { name, args })
137
+ showCall(id, entry)
138
+ }
139
+ const endCall = (id, entry, callId) => {
140
+ entry.calls?.delete(callId)
141
+ showCall(id, entry)
142
+ }
91
143
 
92
144
  const markExchangeTurn = (sessionId, turn) => {
93
145
  const turns = state.exchangeTurns[sessionId] ??= []
@@ -157,11 +209,29 @@ export function install(rt) {
157
209
  return bots
158
210
  }
159
211
 
212
+ // Per group, the members whose turn there is running; the round's current speaker is
213
+ // marked, since its round shows it.
214
+ const roomWorkView = () => {
215
+ const speaking = new Set([...roomProgress.values()].map(round => `${round.roomId}:${round.speaking}`))
216
+ const rooms = {}
217
+ for (const [id, entry] of live) {
218
+ const owner = rt.groupOwners.get(id)
219
+ if (entry.turn === undefined || owner === undefined) continue
220
+ rooms[owner.roomId] = [...(rooms[owner.roomId] ?? []), {
221
+ botId: owner.botId,
222
+ since: entry.since,
223
+ ...(entry.tool ? { tool: entry.tool } : {}),
224
+ ...(speaking.has(`${owner.roomId}:${owner.botId}`) ? { speaking: true } : {}),
225
+ }]
226
+ }
227
+ return rooms
228
+ }
229
+
160
230
  // What a running tool call is about, for an approval card: its own description and
161
231
  // the command, path or address it acts on.
162
232
  const callDetail = (sessionId, callId) => {
163
233
  let args
164
- try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId) ?? '{}') } catch { args = {} }
234
+ try { args = JSON.parse(live.get(sessionId)?.calls?.get(callId)?.args ?? '{}') } catch { args = {} }
165
235
  const first = keys => keys.map(key => args?.[key]).find(value => typeof value === 'string' && value.trim() !== '')
166
236
  const text = first(['description', 'justification'])
167
237
  const code = first(['command', 'cmd', 'file_path', 'path', 'url', 'query'])
@@ -195,15 +265,22 @@ export function install(rt) {
195
265
  switch (event.type) {
196
266
  case 'tool/call': {
197
267
  const name = String(event.data?.name ?? '')
198
- setActivity(id, name === 'ask_user' ? undefined : activityOf(name))
199
- entry.tool = name
200
- if (typeof event.data?.callId === 'string') (entry.calls ??= new Map()).set(event.data.callId, String(event.data.arguments ?? ''))
268
+ startCall(id, entry, event.data?.callId, name, String(event.data?.arguments ?? ''))
269
+ // A program is as quick as the tools it runs, so its sub-calls decide.
270
+ if (rt.groupOwners.has(id) && name !== PROGRAM_TOOL) rt.workRoomTurn?.(id, name)
271
+ break
272
+ }
273
+ case 'tool/ptc-dispatch-start': {
274
+ const name = String(event.data?.name ?? '')
275
+ startCall(id, entry, event.data?.subCallId, name, JSON.stringify(event.data?.arguments ?? {}))
276
+ if (rt.groupOwners.has(id)) rt.workRoomTurn?.(id, name)
201
277
  break
202
278
  }
279
+ case 'tool/ptc-dispatch':
280
+ endCall(id, entry, event.data?.subCallId)
281
+ break
203
282
  case 'tool/result':
204
- if (entry.turn !== undefined) setActivity(id, 'thinking')
205
- entry.tool = undefined
206
- entry.calls?.delete(event.data?.callId)
283
+ endCall(id, entry, event.data?.callId)
207
284
  break
208
285
  case 'turn/start':
209
286
  setActivity(id, 'thinking')
@@ -255,6 +332,10 @@ export function install(rt) {
255
332
  ...(source.late ? { late: true } : {}),
256
333
  ...(source.quiet ? { quiet: true } : {}),
257
334
  ...(source.failed ? { failed: source.failed } : {}),
335
+ ...(source.resumed ? { resumed: true } : {}),
336
+ ...(source.again ? { again: true } : {}),
337
+ ...(source.stopped ? { stopped: true } : {}),
338
+ ...(source.askedBy ? { askedBy: source.askedBy } : {}),
258
339
  })
259
340
  } else if (source?.kind === SOURCE_KIND && source.role === 'kickoff' && rt.roomOf(id)) {
260
341
  queueRoomMessage(id, { text: '', kickoff: true })
@@ -295,8 +376,11 @@ export function install(rt) {
295
376
  break
296
377
  }
297
378
  case 'assistant/message': {
298
- const text = textOf(event.data?.message?.content)
379
+ const content = event.data?.message?.content
380
+ const text = textOf(content)
299
381
  if (text !== '') entry.texts.push(text)
382
+ entry.lastText = text
383
+ entry.lastCalled = Array.isArray(content) && content.some(block => block?.type === 'tool-call')
300
384
  break
301
385
  }
302
386
  case 'turn/end': {
@@ -304,6 +388,7 @@ export function install(rt) {
304
388
  const info = entry.info
305
389
  const joined = entry.joined ?? []
306
390
  const texts = entry.texts
391
+ const { lastText, lastCalled } = entry
307
392
  // A turn stopped for a switch goes on in the next part, which answers instead.
308
393
  const switched = entry.switched === true
309
394
  entry.turn = undefined
@@ -314,20 +399,37 @@ export function install(rt) {
314
399
  entry.since = undefined
315
400
  entry.tool = undefined
316
401
  entry.calls = undefined
402
+ entry.lastText = undefined
403
+ entry.lastCalled = undefined
317
404
  const reason = event.data?.reason
318
405
  const failure = reason?.kind === 'error' && !switched ? turnFailure(reason.error) : undefined
319
406
  if (failure !== undefined) noteTurnFailure(id, failure)
320
407
  if (switched) break
408
+ // A model that writes its tool call as text ends the turn as if it were done,
409
+ // though nothing ran. It gets one more turn on the same trigger, so a waiting
410
+ // round or sender gets the real answer; a second time, the text goes on without
411
+ // the call.
412
+ const wroteCall = failure === undefined && reason?.kind === 'completed' && lastCalled !== true && writesToolCall(lastText)
413
+ const retry = wroteCall && info?.retried !== true && !(info?.role === 'room' && stoppedRoomTurns.has(info.exchangeId))
414
+ if (retry) void retryWrittenCall(id, info).catch(error => rt.warn('ds-bot: asking %s to run its written tool call failed: %s', id, error))
415
+ const replyText = () => {
416
+ const reply = (wroteCall ? texts.map(stripWrittenCalls) : texts).join('\n\n').trim()
417
+ return wroteCall && reply === '' ? 'Tried to run a tool but wrote the call as text, so it did not run.' : reply
418
+ }
321
419
  // Whoever the turn answers gets the same three-way rule: a failure notice,
322
420
  // the turn's text (or a stopped-before-answer line), or nothing.
323
421
  const answer = (source, reply) => {
324
422
  if (!expectsReply(source) || answersUser(source)) return
325
423
  const name = rt.botOf(rt.selfOf(id))?.name ?? 'The other Bot'
424
+ // A sender that wrote from a group gets the reply in its Session there, while
425
+ // it is still a member.
426
+ const back = rt.roomOf(source.senderRoomId)?.sessions?.[source.senderSessionId]
326
427
  if (failure !== undefined) {
327
428
  // The sender was told a reply comes later; without this it would wait forever.
328
429
  void rt.deliver({
329
430
  fromId: rt.selfOf(id),
330
431
  toId: source.senderSessionId,
432
+ ...(back ? { sessionId: back } : {}),
331
433
  text: `${name} could not answer your message: its model call failed (${failureLine(failure)}). Tell the user if it matters; do not retry right away.`,
332
434
  role: 'reply',
333
435
  hop: (source.hop ?? 0) + 1,
@@ -339,6 +441,7 @@ export function install(rt) {
339
441
  void rt.deliver({
340
442
  fromId: rt.selfOf(id),
341
443
  toId: source.senderSessionId,
444
+ ...(back ? { sessionId: back } : {}),
342
445
  text: reply !== '' ? reply : `${name} was stopped before it could answer your message.`,
343
446
  role: 'reply',
344
447
  hop: (source.hop ?? 0) + 1,
@@ -346,20 +449,28 @@ export function install(rt) {
346
449
  }).catch(error => rt.warn('ds-bot: reply delivery failed: %s', error))
347
450
  }
348
451
  }
349
- if (info?.role === 'room') {
452
+ // A retried turn answers the same waiter or sender, so this one passes nothing on.
453
+ if (!retry && info?.role === 'room') {
350
454
  const waiter = roomWaiters.get(info.exchangeId)
351
455
  roomWaiters.delete(info.exchangeId)
352
456
  const stopped = stoppedRoomTurns.delete(info.exchangeId)
353
457
  if (waiter !== undefined) {
354
458
  if (failure !== undefined) waiter.fail(failure)
355
- else waiter.resolve(reason?.kind === 'aborted' ? null : texts.join('\n\n').trim())
459
+ else waiter.resolve(reason?.kind === 'aborted' ? null : replyText())
356
460
  } else if (!stopped && reason?.kind !== 'aborted') {
357
461
  // The round moved on without this member; how its turn ends still reaches the group.
358
462
  if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
359
- else void rt.lateRoomReply?.(id, info, texts.join('\n\n').trim())
463
+ else void rt.lateRoomReply?.(id, info, replyText())
464
+ }
465
+ } else if (!retry && info !== undefined) {
466
+ // A group Session speaks only in its group: the turn that takes a reply to a
467
+ // message it sent from there posts what it ends with.
468
+ if (rt.groupOwners.has(id) && (info.role === 'reply' || info.role === 'retry')) {
469
+ if (failure !== undefined) void rt.lateRoomFailure?.(id, info, failure)
470
+ else if (reason?.kind !== 'aborted') void rt.lateRoomReply?.(id, info, replyText())
471
+ } else {
472
+ answer(info, replyText())
360
473
  }
361
- } else if (info !== undefined) {
362
- answer(info, texts.join('\n\n').trim())
363
474
  }
364
475
  for (const request of joined) {
365
476
  // A failed or stopped turn closes the request like any other; a finished
@@ -376,6 +487,7 @@ export function install(rt) {
376
487
  role: 'request',
377
488
  hop: request.info.hop ?? 0,
378
489
  exchangeId: request.info.exchangeId,
490
+ ...(request.info.senderRoomId ? { fromRoomId: request.info.senderRoomId } : {}),
379
491
  deferred: true,
380
492
  }).catch(error => rt.warn('ds-bot: deferred request delivery failed: %s', error))
381
493
  }
@@ -384,5 +496,5 @@ export function install(rt) {
384
496
  }
385
497
  })
386
498
 
387
- Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, callDetail, stopRoomTurn })
499
+ Object.assign(rt, { live, liveOf, roomWaiters, roomProgress, roomInbox, activityView, workView, roomWorkView, callDetail, stopRoomTurn })
388
500
  }
package/src/host/usage.js CHANGED
@@ -523,6 +523,9 @@ export function install(rt) {
523
523
  const tag = from !== null && typeof from === 'object' ? tags.get(from) : undefined
524
524
  if (tag !== undefined) tagUsage(to, tag)
525
525
  }
526
+ // DS Bot's own requests name the Session they serve, as some routes require, so
527
+ // `sessionId` alone does not make a request that Session's own round; the tag does.
528
+ const ownRequest = options => tags.has(options?.messages)
526
529
  // Owner and group use are journaled with the call itself: a Bot or room that
527
530
  // is deleted before the first scan still leaves its calls under its own name.
528
531
  const journaled = new Map()
@@ -590,5 +593,5 @@ export function install(rt) {
590
593
  })()
591
594
  })
592
595
 
593
- Object.assign(rt, { usageView, tagUsage, carryUsageTag })
596
+ Object.assign(rt, { usageView, tagUsage, carryUsageTag, ownRequest })
594
597
  }