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/src/host/parts.js CHANGED
@@ -18,7 +18,7 @@ import { randomUUID } from 'node:crypto'
18
18
  import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
19
19
  import { HISTORY_TIMEOUT_MS, REPLY_KIND, SOURCE_KIND } from './constants.js'
20
20
  import { CHECKPOINT_PROMPT, STEWARD_SYSTEM, checkpointCount, checkpointTail, checkpointTailNote, checkpointText, linesFor, parseDecision, stewardRequest } from './steward.js'
21
- import { clip, createClock, escapeRegExp, estimateTokens, textOf } from './text.js'
21
+ import { clip, createClock, escapeRegExp, estimateTokens, splitRef, textOf } from './text.js'
22
22
 
23
23
  // An ask_user card as the user saw it. The user's reply is often just an option label,
24
24
  // which means nothing without the card. Arguments the tool would refuse (no question,
@@ -136,13 +136,32 @@ export function install(rt) {
136
136
  const NOTE_COOLDOWN_MS = [60_000, 300_000, 900_000]
137
137
  const SWITCH_ATTEMPTS = 5
138
138
  const OVERFLOW = 'CONTEXT_WINDOW_EXCEEDED'
139
+ // A failed connection says nothing about the conversation, so it never moves a part on.
140
+ const STUCK_CODES = ['INVALID_REQUEST', 'IMAGE_OFFLOAD_REQUIRED']
139
141
  const ORIGINAL_COMPACT = Symbol.for('ds-bot/compaction')
140
142
  const HANDOFF_ACK = 'Noted. I have my handoff note and carry on from here.'
141
143
  const STEWARD_MAX_TOKENS = 4_096
142
144
  const STEWARD_TIMEOUT_MS = 120_000
143
145
  const STEWARD_LINES = 40
144
146
 
145
- const tokensOf = session => ctx.get('tokenMeter')?.measure(session).totalTokens
147
+ // A broken provider adapter must not crash every turn; estimate from the
148
+ // derived messages instead, with one warning.
149
+ let measureWarned = false
150
+ const tokensOf = (session) => {
151
+ try {
152
+ return ctx.get('tokenMeter')?.measure(session).totalTokens
153
+ } catch (error) {
154
+ if (!measureWarned) {
155
+ measureWarned = true
156
+ rt.warn('ds-bot: token measure failed; context sizes are estimated: %s', error)
157
+ }
158
+ }
159
+ try {
160
+ return estimateTokens(session.deriveMessages().map(message => textOf(message.content)).join('\n'))
161
+ } catch {
162
+ return undefined
163
+ }
164
+ }
146
165
  const clock = createClock(config.timeZone)
147
166
 
148
167
  // Lines for the model the Session last routed to; undefined while it has not
@@ -528,18 +547,22 @@ export function install(rt) {
528
547
  // together without the model carries the last note the model wrote.
529
548
  function noteText(segment) {
530
549
  if (segment?.fallback === undefined) return segment?.note
531
- const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window' : 'the model did not write a usable note'
550
+ const why = segment.fallback === 'overflow' ? 'the part overflowed the model\'s window'
551
+ : segment.fallback === 'failure' ? 'a request in the previous part kept failing'
552
+ : 'the model did not write a usable note'
532
553
  const head = `(DS Bot put this note together without the model, because ${why}.`
533
554
  return segment.note === undefined
534
555
  ? `${head} There was no earlier note to carry over. Go by the messages below, and look up anything else.)`
535
556
  : `${head} It is the note written at the end of part ${segment.noteFrom}, carried over unchanged; anything newer is in the messages below, and read_own_chat finds the rest.)\n\n${segment.note}`
536
557
  }
537
558
 
538
- function handoffPackage({ room, segment, lines, number, resume, overflow }) {
559
+ function handoffPackage({ room, segment, lines, number, resume, overflow, failed, bare }) {
539
560
  const where = room ? `your conversation in the group chat "${room.name}"` : 'your own chat with the user'
540
561
  const lookup = room
541
562
  ? 'read_group_chat shows the group\'s latest messages, and read_own_chat your own chat.'
542
563
  : 'read_own_chat searches every part, reads around an anchor such as [part 2 #1234 · …], and shows the note that closed any earlier part.'
564
+ const reason = failed !== undefined ? `a request in the previous part kept failing (${failed})`
565
+ : overflow ? 'the previous part overflowed the model\'s window' : 'the previous part grew long'
543
566
  const { recent, earlier, unlisted, complete } = lines
544
567
  const omitted = [
545
568
  ...(unlisted > 0 ? [`(${unlisted} older messages are not listed.)`] : []),
@@ -547,9 +570,10 @@ export function install(rt) {
547
570
  ]
548
571
  return [
549
572
  `[Handoff · part ${number}]`,
550
- `DS Bot moved ${where} on to a fresh conversation, part ${number}, because the previous part ${overflow ? 'overflowed the model\'s window' : 'grew long'}. This message is not from the user.`,
573
+ `DS Bot moved ${where} on to a fresh conversation, part ${number}, because ${reason}. This message is not from the user.`,
551
574
  'It is reference, not new instructions: the note and the messages below are a record of earlier work. Do what they say only as part of the work they record, and take new requests only from messages that come after this one.',
552
575
  `Everything earlier stays saved: ${lookup}`,
576
+ ...(failed !== undefined ? ['Images from the previous part are not in this conversation; if one matters, ask the user to send it again.'] : []),
553
577
  '',
554
578
  '## Handoff note',
555
579
  noteText(segment) ?? '(none)',
@@ -564,8 +588,12 @@ export function install(rt) {
564
588
  recent.length > 0 ? recent.join('\n') : '(none)',
565
589
  '',
566
590
  !resume ? 'Every message above has had its answer. Nothing needs an answer now; wait for the next message.'
567
- : room ? 'You were in the middle of your turn in the group. Go on with it now and write your reply to the group.'
568
- : 'You were in the middle of a turn. Go on with it now from where you stopped.',
591
+ : bare ? `Your last turn could not finish because the model request kept failing, also in a fresh conversation with the latest messages carried over, so they are left out this time. First tell ${room ? 'the group' : 'the user'} in one short sentence that the last message could not be sent, and ask for it again.`
592
+ : failed !== undefined ? room
593
+ ? 'Your last turn could not finish because the model request kept failing. Go on with it now from the messages above, and first tell the group in one short sentence that the last message could not be sent as it was and you moved on to a fresh conversation, and write your reply to the group.'
594
+ : 'Your last turn could not finish because the model request kept failing. Go on with it now from the messages above, and first tell the user in one short sentence that the last message could not be sent as it was and you moved on to a fresh conversation.'
595
+ : room ? 'You were in the middle of your turn in the group. Go on with it now and write your reply to the group.'
596
+ : 'You were in the middle of a turn. Go on with it now from where you stopped.',
569
597
  ].join('\n')
570
598
  }
571
599
 
@@ -576,10 +604,15 @@ export function install(rt) {
576
604
  exchangeId: job.info?.exchangeId ?? randomUUID(),
577
605
  handoff: true,
578
606
  ...(resume ? { resume: true } : {}),
607
+ ...(resume && job.joined?.length ? { joined: job.joined } : {}),
579
608
  })
580
609
 
581
- /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, resume:boolean, overflow:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
610
+ /** @type {Map<string, {botId:string, roomId?:string, carry:any[], info?:any, joined?:any[], resume:boolean, overflow:boolean, failed?:string, bare?:boolean, hard:boolean, started?:boolean, moved?:boolean, noteFailed?:boolean}>} */
582
611
  const switches = new Map()
612
+ // Parts opened because requests kept failing, until one of their turns ends well:
613
+ // 'carried' brought the latest messages along, 'bare' left them out.
614
+ /** @type {Map<string, 'carried'|'bare'>} */
615
+ const rescued = new Map()
583
616
  // Parts that have not started a turn yet, with their handoff package's size.
584
617
  const freshParts = new Map()
585
618
  // Context size where a part started, or where a checkpoint or a failed switch left
@@ -620,10 +653,10 @@ export function install(rt) {
620
653
 
621
654
  // `hard`: past the hard line or the window, the part moves on even without a note
622
655
  // from the model.
623
- function startSwitch(agent, { resume = false, carry = [], info, overflow = false, hard = overflow } = {}) {
656
+ function startSwitch(agent, { resume = false, carry = [], info, joined, overflow = false, failed, bare = false, hard = overflow } = {}) {
624
657
  const place = placeOf(agent.id)
625
658
  if (place === undefined || switches.has(agent.id) || readOnly()) return
626
- const job = { ...place, carry: [...carry], info, resume, overflow, hard }
659
+ const job = { ...place, carry: [...carry], info, joined, resume, overflow, failed, bare, hard }
627
660
  switches.set(agent.id, job)
628
661
  void runSwitch(agent, job)
629
662
  .catch(error => rt.warn('ds-bot: %s did not move on to a fresh part: %s', rt.botOf(job.botId)?.name ?? agent.id, error))
@@ -637,9 +670,10 @@ export function install(rt) {
637
670
  // The stopped turn goes on where it was.
638
671
  const back = job.carry.splice(0)
639
672
  if (resume) {
673
+ const carried = job.joined?.length ? { joined: job.joined } : {}
640
674
  back.unshift(createUserMessage({
641
675
  content: [{ type: 'text', text: 'DS Bot paused your turn for a context check. Go on with it now from where you stopped.' }],
642
- source: job.info ?? { kind: SOURCE_KIND, form: 'relay', role: 'resume', exchangeId: randomUUID(), senderName: 'DS Bot', hop: 0 },
676
+ source: job.info ? { ...job.info, ...carried } : { kind: SOURCE_KIND, form: 'relay', role: 'resume', exchangeId: randomUUID(), senderName: 'DS Bot', hop: 0, ...carried },
643
677
  }))
644
678
  }
645
679
  await forward(job, back).catch(error => rt.warn('ds-bot: input for %s was not handed on: %s', agent.id, error))
@@ -678,7 +712,7 @@ export function install(rt) {
678
712
  const number = parts.length
679
713
  let written
680
714
  let failure
681
- if (!job.overflow) {
715
+ if (!job.overflow && job.failed === undefined) {
682
716
  try {
683
717
  written = await writeNote(agent, AbortSignal.any([signal, AbortSignal.timeout(NOTE_TIMEOUT_MS)]))
684
718
  } catch (error) {
@@ -699,15 +733,16 @@ export function install(rt) {
699
733
  if (failure !== undefined) rt.warn('ds-bot: handoff note for %s failed past the hard line; moving on with the earlier note: %s', bot.name, failure)
700
734
  const previous = parts.at(-2)
701
735
  const noted = written !== undefined ? { note: written.note }
702
- : { fallback: job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
736
+ : { fallback: job.failed !== undefined ? 'failure' : job.overflow ? 'overflow' : 'failed', ...(previous?.note ? { note: previous.note, noteFrom: previous.noteFrom ?? previous.number } : {}) }
703
737
  const budget = Math.min(RECENT_TOKENS, Math.floor((lines?.check ?? 4 * RECENT_TOKENS) / 4))
704
- const carried = await handoffLines(agent.id, { budget, group: room !== undefined, number })
738
+ const carried = job.bare ? { recent: [], earlier: [], unlisted: 0, complete: true } : await handoffLines(agent.id, { budget, group: room !== undefined, number })
705
739
  if (signal.aborted) throw signal.reason
706
740
  const freshId = await rt.createSession(room ? `${bot.name} · ${room.name}` : bot.name)
707
741
  const route = session.requestHeader()?.config
708
- const model = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
709
- if (model !== undefined) {
710
- await rt.selectSessionModel(freshId, model)
742
+ const ref = route?.provider && route?.model ? `${route.provider}/${route.model}` : await rt.desiredModel(bot)
743
+ if (ref !== undefined) {
744
+ const effort = route?.reasoningEffort ?? await rt.desiredEffort(bot, ref)
745
+ await rt.selectSessionModel(freshId, { ...splitRef(ref), ...(effort === undefined ? {} : { reasoningEffort: effort }) })
711
746
  .catch(error => rt.warn('ds-bot: model for the fresh part of %s not applied: %s', bot.name, error))
712
747
  }
713
748
  await rt.applyPermission(bot, freshId)
@@ -729,10 +764,11 @@ export function install(rt) {
729
764
  }
730
765
  }
731
766
  await rt.save()
767
+ if (job.failed !== undefined) rescued.set(freshId, job.bare ? 'bare' : 'carried')
732
768
  void rt.moveSchedules(agent.id, freshId)
733
769
  job.moved = true
734
770
  const resume = job.resume || job.overflow
735
- const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow })
771
+ const text = handoffPackage({ room, segment, lines: carried, number: number + 1, resume, overflow: job.overflow, failed: job.failed, bare: job.bare })
736
772
  freshParts.set(freshId, estimateTokens(text))
737
773
  const fresh = await rt.resolveAgent(freshId)
738
774
  fresh.followup(createUserMessage({ content: [{ type: 'text', text }], source: handoffSource(job, resume) }))
@@ -781,10 +817,28 @@ export function install(rt) {
781
817
  // `messages` left the inbox for this step, so the switch carries them along.
782
818
  const entry = rt.liveOf(agent.id)
783
819
  entry.switched = true
784
- startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, hard: true })
820
+ startSwitch(agent, { resume: midTurn, carry: payload.messages, info: midTurn ? entry.info : undefined, joined: midTurn ? [...(entry.joined ?? [])] : undefined, hard: true })
785
821
  return { kind: 'reject' }
786
822
  }, { prepend: true })
787
823
 
824
+ ctx.on('agent/request-error', async (payload, next) => {
825
+ const failure = payload.failure
826
+ if (!STUCK_CODES.includes(failure?.code)) return next()
827
+ const decided = await next()
828
+ if (decided !== undefined || payload.signal?.aborted) return decided
829
+ await rt.load()
830
+ const { agent } = payload
831
+ if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly() || rescued.get(agent.id) === 'bare') return decided
832
+ // A provider may turn down one request for reasons of its own; a history that
833
+ // breaks every request fails the next turn as well.
834
+ if (failure.code === 'INVALID_REQUEST' && !STUCK_CODES.includes(lastTurnEnd(agent)?.data?.reason?.error?.code)) return decided
835
+ const entry = rt.liveOf(agent.id)
836
+ entry.switched = true
837
+ // The latest messages carried into a rescued part may be what breaks the request.
838
+ startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true, failed: failure.code, bare: rescued.has(agent.id) })
839
+ return undefined
840
+ }, { prepend: true })
841
+
788
842
  ctx.on('agent/request-error', async (payload, next) => {
789
843
  if (payload.failure?.code !== OVERFLOW || payload.signal?.aborted) return next()
790
844
  await rt.load()
@@ -792,7 +846,7 @@ export function install(rt) {
792
846
  if (!rt.isBotSession(agent.id) || switches.has(agent.id) || readOnly()) return next()
793
847
  const entry = rt.liveOf(agent.id)
794
848
  entry.switched = true
795
- startSwitch(agent, { resume: true, info: entry.info, overflow: true })
849
+ startSwitch(agent, { resume: true, info: entry.info, joined: [...(entry.joined ?? [])], overflow: true })
796
850
  return undefined
797
851
  }, { prepend: true })
798
852
 
@@ -819,8 +873,25 @@ export function install(rt) {
819
873
  }
820
874
  }
821
875
 
876
+ // The newest turn/end among the Session's last events in memory.
877
+ function lastTurnEnd(agent) {
878
+ const latest = agent.session.seq - 1
879
+ for (let seq = latest; seq >= Math.max(0, latest - 199); seq -= 1) {
880
+ const event = agent.session.eventAt?.(seq)
881
+ if (event?.type === 'turn/end') return event
882
+ }
883
+ return undefined
884
+ }
885
+
886
+ function releaseRescued(agent) {
887
+ if (!rescued.has(agent.id)) return
888
+ const end = lastTurnEnd(agent)
889
+ if (end !== undefined && end.data?.reason?.kind !== 'error') rescued.delete(agent.id)
890
+ }
891
+
822
892
  ctx.on('agent/status', ({ agent, status }) => {
823
893
  if (status === 'idle' && rt.isBotSession(agent.id)) {
894
+ releaseRescued(agent)
824
895
  void checkContext(agent).catch(error => rt.warn('ds-bot: context check for %s failed: %s', agent.id, error))
825
896
  }
826
897
  })
@@ -864,7 +935,7 @@ export function install(rt) {
864
935
  } else if (source?.kind === REPLY_KIND) {
865
936
  const reply = readReply(textOf(event.data.content))
866
937
  if (reply !== undefined) items.push({ kind: 'answer', questions: reply.questions, answers: reply.answers, time: event.time })
867
- } else if (source?.kind === SOURCE_KIND && source.handoff !== true && ['request', 'reply', 'report'].includes(source.role)) {
938
+ } else if (source?.kind === SOURCE_KIND && source.handoff !== true && source.deferred !== true && ['request', 'reply', 'report', 'stopped'].includes(source.role)) {
868
939
  items.push({ kind: 'event', role: source.role, botId: source.senderSessionId ?? null, name: source.senderName ?? null, roomId: source.roomId ?? null, time: event.time })
869
940
  }
870
941
  }
@@ -5,13 +5,16 @@ const TEAM_RULES = [
5
5
  '## How your Bot team works',
6
6
  '- Every Bot is its own long-lived conversation with the user, with its own memory and computer.',
7
7
  '- The team changes over time. Call list_bots whenever you need to know who exists; do not rely on memory.',
8
- '- message_bot sends an asynchronous message to another Bot. Its reply arrives later as a new "[Reply from …]" message. Never wait, poll, or resend.',
8
+ '- message_bot sends a message to another Bot. If it is working, it reads your message at its next step, so you can also use message_bot to warn it off a mistake or redirect it in time; it answers once that work is done. Pass when "queue" only for a new task unrelated to what it is doing now; it then waits until its current work is done. Its reply arrives later as a new "[Reply from …]" message. Never wait, poll, or resend.',
9
9
  '- When the user wants other Bots to tell them something themselves (introduce themselves, report in their own chats), call message_bot with reply_to "user": each Bot then answers the user in its own chat and nothing comes back to you. Do not relay those answers yourself.',
10
- '- When a "[Message from …]" arrives, your final text in that turn is delivered back to that Bot automatically and the user does not see it. Answer it directly and briefly, as a list of facts. Never invent facts: say "not discussed yet" when you do not know.',
10
+ '- When a "[Message from …]" opens a turn, your final text in that turn is delivered back to that Bot automatically and the user does not see it. If one arrives while you are working on something else, take it into account in that work but do not answer it there: once the work is done, it comes back to you in a turn of its own to answer. Answer it directly and briefly, as a list of facts. Never invent facts: say "not discussed yet" when you do not know. If the message brings a request or a decision meant for the user, handle it in the same turn too — do the work, or ask the user with ask_user.',
11
11
  '- When a "[Message from … · the user sees your reply here]" arrives, your reply is shown to the user in your own chat and is not sent back. Talk to the user directly, as yourself.',
12
12
  '- When a "[Reply from …]" arrives, tell the user the outcome if it matters to them.',
13
13
  '- Chat like a teammate texting: short sentences, often two or three short messages instead of one long one, no headings. When the user asks for a deliverable (a plan, schedule, draft, or list), write it out in full in the chat; never claim you gave something you did not write. Mention other Bots by their exact name.',
14
+ '- Only for a direct user request in your own chat that needs several steps: write one short sentence of normal reply text before your first tool call, and make that tool call in the same response. Never start such a task with tools alone or end it with a promise. On long tasks, give brief factual updates only at meaningful milestones, then keep working.',
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.',
14
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
+ '- 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.',
15
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.',
16
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.',
17
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.',
@@ -25,6 +28,7 @@ const MAIN_RULES = [
25
28
  '- When the user wants something that deserves its own ongoing specialist, call create_bot (check list_bots first to avoid duplicates). Give it a short name, a two-to-four character role label, and concrete instructions.',
26
29
  '- After creating a Bot, tell the user in one line that it is ready, using its exact name.',
27
30
  '- Route work to the right Bot with message_bot, then tell the user what you asked and that you will report back.',
31
+ '- If a Bot does not heed your message and must not finish what it is doing, stop_bot ends its turn at once and tells it why; the reply comes back to you.',
28
32
  '- Every Bot runs on one model of its own (list_bots shows it). Pass model to create_bot or update_bot only when the user names one; list_models gives the provider/model refs.',
29
33
  '- Use update_bot to rename a Bot, change its role, color, model, or instructions when the user asks.',
30
34
  '- You run the group chats too. create_group makes a group (you join it as its admin unless you name another); update_group changes any group\'s name, notice, members, admin, or reply mode; post_to_group posts into any group as you, which starts a round of replies the user reads there; delete_group archives a group, only when the user asks. list_bots shows every group.',
@@ -4,6 +4,7 @@ import { mkdir } from 'node:fs/promises'
4
4
  import { join } from 'node:path'
5
5
  import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
6
6
  import { currentColor, isColor, unquote } from './text.js'
7
+ import { nameProblem } from '../shared/mentions.js'
7
8
 
8
9
  export function install(rt) {
9
10
  const { ctx, config, home, defaults, state } = rt
@@ -56,52 +57,66 @@ export function install(rt) {
56
57
  // greeting waits until a model is chosen. So does a Bot made when no model is served.
57
58
  async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, copyOf, pickModel = false }) {
58
59
  const cleanName = String(botName ?? '').trim().slice(0, 40)
59
- if (cleanName === '') throw new Error('A Bot needs a name')
60
+ const problem = nameProblem(cleanName)
61
+ if (problem !== undefined) throw new Error(problem)
60
62
  if (rt.findBot(cleanName)) throw new Error(`A Bot named ${cleanName} already exists`)
61
63
  const cleanAvatarValue = cleanAvatar(avatar)
62
64
  // The model is fixed at creation, so a later change of the DSH default leaves it alone.
63
65
  const modelRef = model ? await rt.resolveModel(model) : pickModel ? undefined : await rt.defaultModel()
64
66
  const effortId = modelRef !== undefined && effort !== undefined ? await rt.resolveEffort(modelRef, effort) : undefined
65
67
  const id = await createSession(cleanName)
66
- state.bots[id] = {
67
- id,
68
- name: cleanName,
69
- role: String(role).trim().slice(0, 24),
70
- // A color is only stored when the user picks one; otherwise the id picks it.
71
- ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
72
- ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
73
- ...(modelRef ? { model: modelRef } : {}),
74
- ...(effortId ? { effort: effortId } : {}),
75
- instructions: String(instructions).trim(),
76
- createdAt: Date.now(),
77
- createdBy: createdBy ?? null,
78
- }
79
- if (main) state.mainBotIds.push(id)
80
- const creator = rt.botOf(createdBy)
81
- const original = rt.botOf(copyOf)
82
- const language = rt.uiLanguage?.()
83
- const kickoff = main
84
- ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
85
- : original
86
- ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
87
- : creator
88
- ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
89
- : brief
90
- ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
91
- : 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
92
- const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
93
- const bot = state.bots[id]
94
- if (modelRef === undefined) {
95
- bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
68
+ try {
69
+ state.bots[id] = {
70
+ id,
71
+ name: cleanName,
72
+ role: String(role).trim().slice(0, 24),
73
+ // A color is only stored when the user picks one; otherwise the id picks it.
74
+ ...(isColor(currentColor(color)) ? { color: currentColor(color) } : {}),
75
+ ...(cleanAvatarValue ? { avatar: cleanAvatarValue } : {}),
76
+ ...(modelRef ? { model: modelRef } : {}),
77
+ ...(effortId ? { effort: effortId } : {}),
78
+ instructions: String(instructions).trim(),
79
+ createdAt: Date.now(),
80
+ createdBy: createdBy ?? null,
81
+ }
82
+ if (main) state.mainBotIds.push(id)
83
+ const creator = rt.botOf(createdBy)
84
+ const original = rt.botOf(copyOf)
85
+ const language = rt.uiLanguage?.()
86
+ const kickoff = main
87
+ ? `You were just set up as the user's Main Bot. Greet the user in one or two short messages: say you will run their Bot team and that they can come to you for anything that needs a decision. Then use ask_user to ask what they want the team to help with first (3-4 short options). Write in ${language ?? 'the user\'s language if known; otherwise Chinese'}.`
88
+ : original
89
+ ? `The user just made you as a copy of ${original.name}: same job, fresh memory. Greet the user in one short message, then use ask_user to ask what this copy should focus on (3-4 short options).`
90
+ : creator
91
+ ? `${creator.name} just created you for the user.${brief ? ` Why you exist: ${brief}` : ''}\nGreet the user in one short message, then use ask_user to ask where to start (3-4 short options relevant to your role, with allowCustom). Keep it brief.`
92
+ : brief
93
+ ? `The user just created you and sent this first message:\n\n${brief}\n\nIntroduce yourself in one short line, then help with it.`
94
+ : 'The user just created you. Greet the user in one short message, then use ask_user to ask what you should focus on (3-4 short options, with allowCustom).'
95
+ const text = !main && language ? `${kickoff}\nWrite in ${language}.` : kickoff
96
+ const bot = state.bots[id]
97
+ if (modelRef === undefined) {
98
+ bot.kickoff = { text, from: createdBy ?? null, ...(main ? { main: true } : {}) }
99
+ await rt.save()
100
+ return bot
101
+ }
96
102
  await rt.save()
103
+ // The greeting runs on the Bot's own model, once its provider is registered.
104
+ void rt.waitForModel(bot)
105
+ .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
106
+ .then(() => sendKickoff(bot, { text, from: createdBy }))
97
107
  return bot
108
+ } catch (error) {
109
+ // The Bot's Session was already made: archive it instead of leaving an orphan.
110
+ delete state.bots[id]
111
+ state.mainBotIds = state.mainBotIds.filter(other => other !== id)
112
+ rt.chatOwners.delete(id)
113
+ // A save that another change queued meanwhile still carries this Bot; a later
114
+ // one writes the team without it.
115
+ void rt.save().catch(() => {})
116
+ await ctx.workspaceRegistry.archiveSession(id)
117
+ .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', cleanName, archiveError))
118
+ throw error
98
119
  }
99
- await rt.save()
100
- // The greeting runs on the Bot's own model, once its provider is registered.
101
- void rt.waitForModel(bot)
102
- .then(() => rt.applyModel(bot).catch(error => rt.warn('ds-bot: model for %s not applied: %s', cleanName, error)))
103
- .then(() => sendKickoff(bot, { text, from: createdBy }))
104
- return bot
105
120
  }
106
121
 
107
122
  function sendKickoff(bot, { text, from }) {
@@ -114,7 +129,7 @@ export function install(rt) {
114
129
  const bot = rt.botOf(botId)
115
130
  if (bot?.kickoff === undefined) return
116
131
  delete bot.kickoff
117
- void rt.save()
132
+ void rt.save().catch(() => {})
118
133
  }
119
134
 
120
135
  async function duplicateBot(id) {
@@ -140,6 +155,9 @@ export function install(rt) {
140
155
  const next = String(patch.name).trim().slice(0, 40)
141
156
  const clash = rt.findBot(next)
142
157
  if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
158
+ // A name saved before the rule stays usable until it changes.
159
+ const problem = next === bot.name ? undefined : nameProblem(next)
160
+ if (problem !== undefined) throw new Error(problem)
143
161
  bot.name = next
144
162
  const agent = await rt.resolveAgent(rt.chatOf(id))
145
163
  await ctx.sessionTitle.rename(agent.session, next)
@@ -242,19 +260,28 @@ export function install(rt) {
242
260
  if (mode !== undefined && !ROOM_MODES.includes(mode)) throw new Error(`Reply mode must be one of ${ROOM_MODES.join(', ')}`)
243
261
  const title = custom || autoRoomName(members)
244
262
  const id = await createSession(title)
245
- state.rooms[id] = {
246
- id,
247
- name: title,
248
- ...(custom ? { named: true } : {}),
249
- members,
250
- admin: adminId,
251
- mode: mode ?? 'admin',
252
- notice: cleanNotice(notice),
253
- createdAt: Date.now(),
254
- createdBy: createdBy ?? null,
263
+ try {
264
+ state.rooms[id] = {
265
+ id,
266
+ name: title,
267
+ ...(custom ? { named: true } : {}),
268
+ members,
269
+ admin: adminId,
270
+ mode: mode ?? 'admin',
271
+ notice: cleanNotice(notice),
272
+ createdAt: Date.now(),
273
+ createdBy: createdBy ?? null,
274
+ }
275
+ await rt.save()
276
+ return state.rooms[id]
277
+ } catch (error) {
278
+ // The group's Session was already made: archive it instead of leaving an orphan.
279
+ delete state.rooms[id]
280
+ void rt.save().catch(() => {})
281
+ await ctx.workspaceRegistry.archiveSession(id)
282
+ .catch(archiveError => rt.warn('ds-bot: archiving the Session for %s failed: %s', title, archiveError))
283
+ throw error
255
284
  }
256
- await rt.save()
257
- return state.rooms[id]
258
285
  }
259
286
 
260
287
  // `actorId` is the Bot asking, or undefined for the user. Returns what changed, in words.
@@ -328,8 +355,12 @@ export function install(rt) {
328
355
  for (const botId of Object.keys(room.sessions ?? {})) rt.dropGroupSession(room, botId)
329
356
  delete state.rooms[id]
330
357
  rt.roomInbox.delete(id)
331
- await rt.save()
332
- await ctx.workspaceRegistry.archiveSession(id).catch(() => {})
358
+ const failed = await saveForDelete()
359
+ // Armed scheduled tasks refuse a plain archive and would keep delivering into
360
+ // the deleted group; the session-stop that stopActivity runs removes them.
361
+ await ctx.workspaceRegistry.archiveSession(id, { stopActivity: true })
362
+ .catch(error => rt.warn('ds-bot: group chat %s not archived: %s', room.name, error))
363
+ if (failed !== undefined) throw failed
333
364
  }
334
365
 
335
366
  // The first Main Bot is the sidebar tile. `replace` gives a Main Bot's place to
@@ -364,13 +395,14 @@ export function install(rt) {
364
395
  }
365
396
 
366
397
  async function deleteBot(id) {
367
- if (rt.botOf(id) === undefined) throw new Error('Unknown Bot')
398
+ const bot = rt.botOf(id)
399
+ if (bot === undefined) throw new Error('Unknown Bot')
368
400
  if (rt.isMain(id)) throw new Error('A Main Bot cannot be deleted; remove its Main Bot role first')
369
401
  // Before the Bot and its group Sessions leave the state, which the archive reads.
370
402
  const archived = rt.archiveBotMemory(id)
371
403
  const current = rt.chatOf(id)
372
404
  // Questions and exchange turns are kept per Session, so every part of the chat is cleared.
373
- const parts = new Set([id, current, ...(rt.botOf(id).segments ?? []).map(segment => segment.sessionId)])
405
+ const parts = new Set([id, current, ...(bot.segments ?? []).map(segment => segment.sessionId)])
374
406
  delete state.bots[id]
375
407
  for (const part of parts) {
376
408
  delete state.questions[part]
@@ -387,8 +419,29 @@ export function install(rt) {
387
419
  await followMemberNames(touched)
388
420
  await rt.forgetBotSecrets(id)
389
421
  await archived
390
- await rt.save()
391
- await ctx.workspaceRegistry.archiveSession(current).catch(() => {})
422
+ const failed = await saveForDelete()
423
+ // Same as deleteRoom: stopActivity removes the Session's armed scheduled tasks.
424
+ // Past parts are archived too: a part move can leave tasks on the old Session,
425
+ // which otherwise stays armed and invisible. Already-archived ids are a no-op.
426
+ for (const part of parts) {
427
+ await ctx.workspaceRegistry.archiveSession(part, { stopActivity: true })
428
+ .catch(error => rt.warn('ds-bot: Bot %s not archived: %s', bot.name, error))
429
+ }
430
+ if (failed !== undefined) throw failed
431
+ }
432
+
433
+ // A deletion whose save fails stays in memory, and the next save writes it, so its
434
+ // Sessions are stopped anyway: left armed, their scheduled tasks would keep running
435
+ // with nothing in the team to show or delete them. Only a host that lost the team
436
+ // to another dsh keeps them, since the other host's copy still has them.
437
+ async function saveForDelete() {
438
+ try {
439
+ await rt.save()
440
+ return undefined
441
+ } catch (error) {
442
+ if (error?.code === 'ELOCKLOST' || rt.readOnly() !== undefined) throw error
443
+ return error
444
+ }
392
445
  }
393
446
 
394
447
  async function bootstrap() {